├── .gitignore ├── requirements.txt ├── src ├── Assets │ ├── Images │ │ ├── LICENSE.txt │ │ ├── page.png │ │ ├── scene.png │ │ ├── sensor.png │ │ ├── light_off.png │ │ ├── light_on.png │ │ ├── automation_off.png │ │ └── automation_on.png │ └── Fonts │ │ ├── Roboto-Bold.ttf │ │ ├── Roboto-Thin.ttf │ │ ├── Roboto-Black.ttf │ │ ├── Roboto-Italic.ttf │ │ ├── Roboto-Light.ttf │ │ ├── Roboto-Medium.ttf │ │ ├── Roboto-Regular.ttf │ │ ├── Roboto-BlackItalic.ttf │ │ ├── Roboto-BoldItalic.ttf │ │ ├── Roboto-LightItalic.ttf │ │ ├── Roboto-ThinItalic.ttf │ │ ├── Roboto-MediumItalic.ttf │ │ └── LICENSE.txt ├── Tile │ ├── TileManager.py │ ├── Tile.py │ └── TileImage.py ├── HomeAssistantWS │ └── RemoteWS.py ├── config.yaml └── HassClient.py ├── ExampleDeck.jpg ├── .drone.yml └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | .idea/ 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | streamdeck>=0.8.3 2 | Pillow>=8.0.0 3 | aiohttp>=3.7.3 4 | PyYAML>=5.3.1 5 | -------------------------------------------------------------------------------- /src/Assets/Images/LICENSE.txt: -------------------------------------------------------------------------------- 1 | Icons modified from OneBit icon set: http://www.icojam.com/blog/?p=177 2 | -------------------------------------------------------------------------------- /ExampleDeck.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abcminiuser/python-homeassistant-streamdeck/HEAD/ExampleDeck.jpg -------------------------------------------------------------------------------- /src/Assets/Images/page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abcminiuser/python-homeassistant-streamdeck/HEAD/src/Assets/Images/page.png -------------------------------------------------------------------------------- /src/Assets/Images/scene.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abcminiuser/python-homeassistant-streamdeck/HEAD/src/Assets/Images/scene.png -------------------------------------------------------------------------------- /src/Assets/Images/sensor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abcminiuser/python-homeassistant-streamdeck/HEAD/src/Assets/Images/sensor.png -------------------------------------------------------------------------------- /src/Assets/Fonts/Roboto-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abcminiuser/python-homeassistant-streamdeck/HEAD/src/Assets/Fonts/Roboto-Bold.ttf -------------------------------------------------------------------------------- /src/Assets/Fonts/Roboto-Thin.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abcminiuser/python-homeassistant-streamdeck/HEAD/src/Assets/Fonts/Roboto-Thin.ttf -------------------------------------------------------------------------------- /src/Assets/Images/light_off.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abcminiuser/python-homeassistant-streamdeck/HEAD/src/Assets/Images/light_off.png -------------------------------------------------------------------------------- /src/Assets/Images/light_on.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abcminiuser/python-homeassistant-streamdeck/HEAD/src/Assets/Images/light_on.png -------------------------------------------------------------------------------- /src/Assets/Fonts/Roboto-Black.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abcminiuser/python-homeassistant-streamdeck/HEAD/src/Assets/Fonts/Roboto-Black.ttf -------------------------------------------------------------------------------- /src/Assets/Fonts/Roboto-Italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abcminiuser/python-homeassistant-streamdeck/HEAD/src/Assets/Fonts/Roboto-Italic.ttf -------------------------------------------------------------------------------- /src/Assets/Fonts/Roboto-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abcminiuser/python-homeassistant-streamdeck/HEAD/src/Assets/Fonts/Roboto-Light.ttf -------------------------------------------------------------------------------- /src/Assets/Fonts/Roboto-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abcminiuser/python-homeassistant-streamdeck/HEAD/src/Assets/Fonts/Roboto-Medium.ttf -------------------------------------------------------------------------------- /src/Assets/Fonts/Roboto-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abcminiuser/python-homeassistant-streamdeck/HEAD/src/Assets/Fonts/Roboto-Regular.ttf -------------------------------------------------------------------------------- /src/Assets/Images/automation_off.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abcminiuser/python-homeassistant-streamdeck/HEAD/src/Assets/Images/automation_off.png -------------------------------------------------------------------------------- /src/Assets/Images/automation_on.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abcminiuser/python-homeassistant-streamdeck/HEAD/src/Assets/Images/automation_on.png -------------------------------------------------------------------------------- /src/Assets/Fonts/Roboto-BlackItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abcminiuser/python-homeassistant-streamdeck/HEAD/src/Assets/Fonts/Roboto-BlackItalic.ttf -------------------------------------------------------------------------------- /src/Assets/Fonts/Roboto-BoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abcminiuser/python-homeassistant-streamdeck/HEAD/src/Assets/Fonts/Roboto-BoldItalic.ttf -------------------------------------------------------------------------------- /src/Assets/Fonts/Roboto-LightItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abcminiuser/python-homeassistant-streamdeck/HEAD/src/Assets/Fonts/Roboto-LightItalic.ttf -------------------------------------------------------------------------------- /src/Assets/Fonts/Roboto-ThinItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abcminiuser/python-homeassistant-streamdeck/HEAD/src/Assets/Fonts/Roboto-ThinItalic.ttf -------------------------------------------------------------------------------- /src/Assets/Fonts/Roboto-MediumItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abcminiuser/python-homeassistant-streamdeck/HEAD/src/Assets/Fonts/Roboto-MediumItalic.ttf -------------------------------------------------------------------------------- /.drone.yml: -------------------------------------------------------------------------------- 1 | kind: pipeline 2 | name: Build Tests 3 | 4 | platform: 5 | os: linux 6 | arch: amd64 7 | 8 | steps: 9 | - name: Flake8 10 | image: abcminiuser/docker-ci-python:latest 11 | pull: always 12 | commands: 13 | - flake8 --ignore E501 src/ 14 | 15 | - name: Bandit 16 | image: abcminiuser/docker-ci-python:latest 17 | pull: always 18 | commands: 19 | - bandit -r src/ 20 | -------------------------------------------------------------------------------- /src/Tile/TileManager.py: -------------------------------------------------------------------------------- 1 | # Python StreamDeck HomeAssistant Client 2 | # Released under the MIT license 3 | # 4 | # dean [at] fourwalledcubicle [dot] com 5 | # www.fourwalledcubicle.com 6 | # 7 | 8 | from .Tile import BaseTile 9 | 10 | from concurrent.futures import ThreadPoolExecutor 11 | 12 | 13 | class TileManager(object): 14 | def __init__(self, deck, pages): 15 | self.deck = deck 16 | self.key_layout = deck.key_layout() 17 | self.pages = pages 18 | self.current_page = None 19 | self.empty_tile = BaseTile(deck) 20 | self.current_page = pages.get('home') 21 | 22 | if self.current_page is None: 23 | raise KeyError('Deck page configuration must have a default "home" page.') 24 | 25 | self._executor = ThreadPoolExecutor(max_workers=1) 26 | 27 | async def set_deck_page(self, name): 28 | self.current_page = self.pages.get(name, self.pages['home']) 29 | await self.update_page(force_redraw=True) 30 | 31 | async def update_page(self, force_redraw=False): 32 | rows, cols = self.key_layout 33 | 34 | for y in range(rows): 35 | for x in range(cols): 36 | tile = self.current_page.get((x, y), self.empty_tile) 37 | 38 | button_image = await tile.get_image(force=force_redraw) 39 | button_index = (y * cols) + x 40 | 41 | if button_image: 42 | self._executor.submit(self.deck.set_key_image, key=button_index, image=button_image) 43 | 44 | async def button_state_changed(self, key, state): 45 | rows, cols = self.key_layout 46 | 47 | x, y = (key % cols, key // cols) 48 | tile = self.current_page.get((x, y)) 49 | if tile is not None: 50 | await tile.button_state_changed(self, state) 51 | -------------------------------------------------------------------------------- /src/Tile/Tile.py: -------------------------------------------------------------------------------- 1 | # Python StreamDeck HomeAssistant Client 2 | # Released under the MIT license 3 | # 4 | # dean [at] fourwalledcubicle [dot] com 5 | # www.fourwalledcubicle.com 6 | # 7 | 8 | from .TileImage import TileImage 9 | 10 | 11 | class BaseTile(object): 12 | def __init__(self, deck, hass=None, tile_class=None, tile_info=None): 13 | self.deck = deck 14 | self.hass = hass 15 | self.tile_class = tile_class 16 | self.tile_info = tile_info 17 | 18 | self.image_tile = TileImage(deck) 19 | self.old_state = None 20 | 21 | @property 22 | async def state(self): 23 | return None 24 | 25 | async def get_image(self, force=True): 26 | state = await self.state 27 | 28 | if state == self.old_state and not force: 29 | return None 30 | 31 | self.old_state = state 32 | 33 | if self.tile_class is None: 34 | return self.image_tile 35 | 36 | state_tile = self.tile_class['states'].get(state) or self.tile_class['states'].get(None) or {} 37 | 38 | format_dict = {'state': state, **self.tile_info} 39 | 40 | image_tile = self.image_tile 41 | image_tile.color = state_tile.get('color') 42 | image_tile.overlay = state_tile.get('overlay') 43 | image_tile.label = state_tile.get('label', '').format_map(format_dict) 44 | image_tile.label_font = state_tile.get('label_font') 45 | image_tile.label_size = state_tile.get('label_size') 46 | image_tile.value = state_tile.get('value', '').format_map(format_dict) 47 | image_tile.value_font = state_tile.get('value_font') 48 | image_tile.value_size = state_tile.get('value_size') 49 | 50 | return image_tile 51 | 52 | async def button_state_changed(self, tile_manager, state): 53 | pass 54 | 55 | 56 | class HassTile(BaseTile): 57 | def __init__(self, deck, hass, tile_class, tile_info): 58 | super().__init__(deck, hass, tile_class, tile_info) 59 | 60 | @property 61 | async def state(self): 62 | hass_state = await self.hass.get_state(self.tile_info['entity_id']) 63 | return hass_state.get('state') 64 | 65 | async def button_state_changed(self, tile_manager, state): 66 | if not state: 67 | return 68 | 69 | if self.tile_class.get('action') is not None: 70 | action = self.tile_class.get('action').split('/') 71 | if len(action) == 1: 72 | domain = 'homeassistant' 73 | service = action[0] 74 | else: 75 | domain = action[0] 76 | service = action[1] 77 | 78 | await self.hass.set_state(domain=domain, service=service, entity_id=self.tile_info['entity_id']) 79 | 80 | 81 | class PageTile(BaseTile): 82 | def __init__(self, deck, hass, tile_class, tile_info): 83 | super().__init__(deck, hass, tile_class, tile_info) 84 | 85 | async def button_state_changed(self, tile_manager, state): 86 | if not state: 87 | return 88 | 89 | page_name = self.tile_info.get('page') 90 | await tile_manager.set_deck_page(page_name) 91 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Python Elgato HomeAssistant Client 2 | 3 | ![Example Deck](ExampleDeck.jpg) 4 | 5 | This is an open source Python 3 application to control a 6 | [Home Assistant](http://home-assistant.io) home automation instance remotely, 7 | via an [Elgato Stream Deck](https://www.elgato.com/en/gaming/stream-deck). This 8 | client is designed to be able to run run cross-platform, so that the StreamDeck 9 | can be connected to both full PCs as well as stand-alone Raspberry Pis. 10 | 11 | Unlike the official software client, which can be made to integrate with Home 12 | Assistant via it's "Open Website" command macros, this client supports dynamic 13 | updates of the button images to reflect the current entity states. 14 | 15 | ## Status: 16 | 17 | Working. You can define your own page layout in the configuration YAML file, and 18 | attach HomeAssistant lights and other entities to buttons on the StreamDeck. The 19 | current state of the entity can be shown on the button in both text form, as 20 | as image form (live button state updates are supported). The HomeAssistant 21 | action to trigger when a button is pressed is also configurable. 22 | 23 | This is my first asyncio project, and I'm not familiar with the technology, so 24 | everything can be heavily improved. If you know asyncio, please submit patches 25 | to help me out! 26 | 27 | Nothing is robust yet, and the configuration format used in the `config.yaml` 28 | file is not yet documented. 29 | 30 | ## Dependencies: 31 | 32 | ### Python 33 | 34 | Python 3.8 or newer is required. On Debian systems, this can usually be 35 | installed via: 36 | ``` 37 | sudo apt install python3 python3-pip 38 | ``` 39 | 40 | ### Python Libraries 41 | 42 | You will need to have the following libraries installed: 43 | 44 | StreamDeck, [my own library](https://github.com/abcminiuser/python-elgato-streamdeck) 45 | to interface to StreamDeck devices: 46 | ``` 47 | pip3 install StreamDeck 48 | ``` 49 | 50 | Pillow, the Python Image Library (PIL) fork, for dynamic tile image creation: 51 | ``` 52 | pip3 install pillow 53 | ``` 54 | 55 | aiohttp, for Websocket communication with Home Assistant: 56 | ``` 57 | pip3 install aiohttp 58 | ``` 59 | 60 | PyYAML, for configuration file parsing: 61 | ``` 62 | pip3 install pyyaml 63 | ``` 64 | 65 | ## License: 66 | 67 | Released under the MIT license: 68 | 69 | ``` 70 | Permission to use, copy, modify, and distribute this software 71 | and its documentation for any purpose is hereby granted without 72 | fee, provided that the above copyright notice appear in all 73 | copies and that both that the copyright notice and this 74 | permission notice and warranty disclaimer appear in supporting 75 | documentation, and that the name of the author not be used in 76 | advertising or publicity pertaining to distribution of the 77 | software without specific, written prior permission. 78 | 79 | The author disclaims all warranties with regard to this 80 | software, including all implied warranties of merchantability 81 | and fitness. In no event shall the author be liable for any 82 | special, indirect or consequential damages or any damages 83 | whatsoever resulting from loss of use, data or profits, whether 84 | in an action of contract, negligence or other tortious action, 85 | arising out of or in connection with the use or performance of 86 | this software. 87 | ``` 88 | -------------------------------------------------------------------------------- /src/Tile/TileImage.py: -------------------------------------------------------------------------------- 1 | # Python StreamDeck HomeAssistant Client 2 | # Released under the MIT license 3 | # 4 | # dean [at] fourwalledcubicle [dot] com 5 | # www.fourwalledcubicle.com 6 | # 7 | 8 | from PIL import Image, ImageDraw, ImageFont 9 | from StreamDeck.ImageHelpers import PILHelper 10 | 11 | 12 | class TileImage(object): 13 | def __init__(self, deck): 14 | self._pixels = None 15 | self._overlay_image = None 16 | self._deck = deck 17 | 18 | self.color = (0, 0, 0) 19 | self.overlay = None 20 | self.label = None 21 | self.label_font = None 22 | self.label_size = None 23 | self.value = None 24 | self.value_font = None 25 | self.value_size = None 26 | 27 | @property 28 | def color(self): 29 | return self._color 30 | 31 | @property 32 | def overlay(self): 33 | return self._overlay 34 | 35 | @property 36 | def label(self): 37 | return self._label 38 | 39 | @property 40 | def label_font(self): 41 | return self._label_font 42 | 43 | @property 44 | def label_size(self): 45 | return self._label_size 46 | 47 | @property 48 | def value(self): 49 | return self._value 50 | 51 | @property 52 | def value_font(self): 53 | return self._value_font 54 | 55 | @property 56 | def value_size(self): 57 | return self._value_size 58 | 59 | @color.setter 60 | def color(self, value): 61 | self._color = value 62 | self._pixels = None 63 | 64 | @overlay.setter 65 | def overlay(self, overlay): 66 | self._overlay = overlay 67 | self._overlay_image = None 68 | self._pixels = None 69 | 70 | @label.setter 71 | def label(self, text): 72 | self._label = text 73 | self._pixels = None 74 | 75 | @label_font.setter 76 | def label_font(self, font): 77 | self._label_font = font 78 | self._pixels = None 79 | 80 | @label_size.setter 81 | def label_size(self, size): 82 | self._label_size = size 83 | self._pixels = None 84 | 85 | @value.setter 86 | def value(self, value): 87 | self._value = value 88 | self._pixels = None 89 | 90 | @value_font.setter 91 | def value_font(self, font): 92 | self._value_font = font 93 | self._pixels = None 94 | 95 | @value_size.setter 96 | def value_size(self, size): 97 | self._value_size = size 98 | self._pixels = None 99 | 100 | def _draw_overlay(self, image, pos, max_size): 101 | if self._overlay is None: 102 | return 103 | 104 | max_size = min(image.size, max_size) 105 | if max_size[0] < 0 or max_size[1] < 0: 106 | return 107 | 108 | if self._overlay_image is None: 109 | self._overlay_image = Image.open(self._overlay).convert("RGBA") 110 | 111 | overlay_image = self._overlay_image.copy() 112 | overlay_image.thumbnail(max_size, Image.LANCZOS) 113 | 114 | overlay_w, overlay_h = overlay_image.size 115 | overlay_x = pos[0] + int((max_size[0] - overlay_w) / 2) 116 | overlay_y = pos[1] + int((max_size[1] - overlay_h) / 2) 117 | 118 | image.paste(overlay_image, (overlay_x, overlay_y), overlay_image) 119 | 120 | def _draw_label(self, image): 121 | if self._label is None: 122 | return None, None, None, None 123 | 124 | try: 125 | font = ImageFont.truetype(self._label_font or 'Assets/Fonts/Roboto-Bold.ttf', self._label_size or 12) 126 | d = ImageDraw.Draw(image) 127 | 128 | w, h = d.textsize(self._label, font=font) 129 | padding = 2 130 | 131 | pos = ((image.width - w) / 2, padding) 132 | d.text(pos, self._label, font=font, fill=(255, 255, 255, 128)) 133 | return (pos[0], pos[1], w, h + padding) 134 | except OSError: 135 | return (image.width, 0, image.width, 1) 136 | 137 | def _draw_value(self, image): 138 | if self._value is None: 139 | return None, None, None, None 140 | 141 | try: 142 | font = ImageFont.truetype(self._value_font or 'Assets/Fonts/Roboto-Light.ttf', self._value_size or 18) 143 | d = ImageDraw.Draw(image) 144 | 145 | w, h = d.textsize(self._value, font=font) 146 | padding = 2 147 | 148 | pos = ((image.width - w) / 2, image.height - h - padding) 149 | d.text(pos, self._value, font=font, fill=(255, 255, 255, 128)) 150 | return (pos[0], pos[1], w, h + padding) 151 | except OSError: 152 | return (image.width, 0, image.width, 1) 153 | 154 | def __getitem__(self, key): 155 | if self._pixels is None: 156 | image = PILHelper.create_image(self._deck, background=self._color) 157 | 158 | l_x, l_y, l_w, l_h = self._draw_label(image) 159 | v_x, v_y, v_w, v_h = self._draw_value(image) 160 | 161 | o_x = 0 162 | o_y = (l_y or 0) + (l_h or 0) 163 | o_w = image.width 164 | o_h = (v_y or image.height) - o_y 165 | 166 | overlay_pos = (int(o_x), int(o_y)) 167 | overlay_size = (int(o_w), int(o_h)) 168 | self._draw_overlay(image, overlay_pos, overlay_size) 169 | 170 | self._pixels = PILHelper.to_native_format(self._deck, image) 171 | 172 | return self._pixels[key] 173 | -------------------------------------------------------------------------------- /src/HomeAssistantWS/RemoteWS.py: -------------------------------------------------------------------------------- 1 | # Python StreamDeck HomeAssistant Client 2 | # Released under the MIT license 3 | # 4 | # dean [at] fourwalledcubicle [dot] com 5 | # www.fourwalledcubicle.com 6 | # 7 | 8 | import asyncio 9 | import aiohttp 10 | import json 11 | import itertools 12 | import collections 13 | import logging 14 | 15 | 16 | class HomeAssistantWS(object): 17 | def __init__(self, host, ssl=False, port=None, loop=None): 18 | self._host = host 19 | self._port = port or 8123 20 | self._protocol = ('https' if ssl else 'http') 21 | self._loop = loop or asyncio.get_event_loop() 22 | 23 | self._id = itertools.count(start=1, step=1) 24 | self._websocket = None 25 | self._event_subscriptions = collections.defaultdict(list) 26 | self._message_responses = dict() 27 | self._entity_states = dict() 28 | 29 | async def _send_message(self, message): 30 | logging.debug("Sending: {}".format(message)) 31 | message_id = next(self._id) 32 | 33 | response_future = asyncio.Future(loop=self._loop) 34 | self._message_responses[message_id] = response_future 35 | 36 | # All messages other than the initial auth require an ID to be valid 37 | if message['type'] != 'auth': 38 | message['id'] = message_id 39 | 40 | await self._websocket.send_str(json.dumps(message)) 41 | 42 | return response_future 43 | 44 | async def _receiver(self): 45 | async for message in self._websocket: 46 | message = json.loads(message.data) if message.type == aiohttp.WSMsgType.TEXT else None 47 | logging.debug("Received: {}".format(message)) 48 | 49 | if message is None: 50 | continue 51 | 52 | message_type = message.get('type') 53 | 54 | if message_type == 'auth_invalid': 55 | raise RuntimeError("Home Assistant auth failed. {}".format(message)) 56 | elif message_type == 'auth_required': 57 | pass 58 | elif message_type == 'auth_ok': 59 | pass 60 | elif message_type == 'event': 61 | event_type = message['event']['event_type'] 62 | event_data = message['event']['data'] 63 | 64 | for future in self._event_subscriptions.get(event_type, []): 65 | if future is not None: 66 | asyncio.ensure_future(future(event_data)) 67 | elif message_type == 'result': 68 | request_id = message.get('id') 69 | request_succcess = message.get('success') 70 | request_result = message.get('result') or message.get('error') 71 | 72 | future = self._message_responses.get(request_id) 73 | if future is not None: 74 | future.set_result((request_succcess, request_result)) 75 | del self._message_responses[request_id] 76 | else: 77 | logging.warning("Unrecognised message type: {}".format(message)) 78 | 79 | async def _update_all_states(self): 80 | def _got_states(future): 81 | if future.cancelled(): 82 | return 83 | 84 | success, result = future.result() 85 | for state in result: 86 | entity_id = state['entity_id'] 87 | self._entity_states[entity_id] = state 88 | 89 | message = {'type': 'get_states'} 90 | response = await self._send_message(message) 91 | response.add_done_callback(_got_states) 92 | 93 | return response 94 | 95 | async def _update_state(self, data): 96 | entity_id = data['entity_id'] 97 | self._entity_states[entity_id] = data['new_state'] 98 | 99 | async def connect(self, api_password=None, api_token=None): 100 | self._websocket = await aiohttp.ClientSession().ws_connect('{}://{}:{}/api/websocket'.format(self._protocol, self._host, self._port)) 101 | self._loop.create_task(self._receiver()) 102 | 103 | # First request must be an auth message, if a token or legacy password is provided 104 | if api_token is not None: 105 | message = {'type': 'auth', 'access_token': api_token} 106 | await self._send_message(message) 107 | elif api_password is not None: 108 | message = {'type': 'auth', 'api_password': api_password} 109 | await self._send_message(message) 110 | 111 | initial_requests = [ 112 | # We want to track all state changes, to update our local cache 113 | await self.subscribe_to_event('state_changed', self._update_state), 114 | 115 | # We need to retrieve the intial entity states, so we can cache them 116 | await self._update_all_states() 117 | ] 118 | 119 | await asyncio.wait(initial_requests, timeout=5) 120 | 121 | async def subscribe_to_event(self, event_type, future): 122 | self._event_subscriptions[event_type].append(future) 123 | 124 | message = {'type': 'subscribe_events', 'event_type': event_type} 125 | response = await self._send_message(message) 126 | return response 127 | 128 | async def set_state(self, domain, service, entity_id): 129 | message = {'type': 'call_service', 'domain': domain, 'service': service} 130 | if entity_id is not None: 131 | message['service_data'] = {'entity_id': entity_id} 132 | 133 | response = await self._send_message(message) 134 | return response 135 | 136 | async def get_state(self, entity_id): 137 | return self._entity_states.get(entity_id, {}) 138 | 139 | async def get_all_states(self): 140 | return self._entity_states 141 | -------------------------------------------------------------------------------- /src/config.yaml: -------------------------------------------------------------------------------- 1 | debug: False # If True, enables debug output messages 2 | 3 | ################################################# 4 | ### Home Assistant Server Connection Settings ### 5 | ################################################# 6 | home_assistant: 7 | # Hostname or IP address of the HomeAssistant server. 8 | host: 10.0.0.11 9 | 10 | # True if a SSL connection to the HomeAssistant server should be used, 11 | # False for regular HTTP. 12 | ssl: False 13 | 14 | # Custom port to use to connect to use when connecting to the server, 15 | # Set to ~ if default port should be used instead. 16 | port: ~ 17 | 18 | # Legacy password to use to connect to the HomeAssistant server. Newer 19 | # versions of HomeAssistant have deprecated this in favour of long-lived 20 | # access tokens. 21 | api_password: ~ 22 | 23 | # Long lived access token to use to connect to the HomeAssistant server. See 24 | # the Authentication section of the HomeAssistant documentation on how to 25 | # create of these for your server. 26 | api_token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiI5ODIwNTRlN2NjOWI0ZTFkOTM2NWRjMTdjZjk3MDg0MyIsImlhdCI6MTU0MDcyMDI0OSwiZXhwIjoxODU2MDgwMjQ5fQ.LVZkReSK2vKliQWL6j0JaAKC6M877J_Ybeotzt0j8f8 27 | 28 | ################################################# 29 | ### StreamDeck Configuration Settings ### 30 | ################################################# 31 | streamdeck: 32 | # Brightness percentage of the backlight when in use. Should range from 0 to 100. 33 | brightness: 20 34 | 35 | # Timeout in seconds before the screen backlight is turned off automatically. 36 | # Set to ~ or 0 to keep backlight on indefinitely. 37 | screensaver: ~ 38 | 39 | ################################################# 40 | ### Tile Configuration Settings ### 41 | ################################################# 42 | tiles: 43 | # Each tile should define how that tile should be rendered when the associated 44 | # HomeAssistant entity is in a given state. This allows screens to be defined 45 | # as a collection of tiles bound to different entities so that all entities of 46 | # the same type (e.g. all lights) are drawn in the same way. 47 | 48 | - type: "light" 49 | class: 'HassTile' 50 | states: 51 | - state: 'on' 52 | label: '{name}' 53 | label_font: Assets/Fonts/Roboto-Bold.ttf 54 | label_size: 12 55 | overlay: 'Assets/Images/light_on.png' 56 | - state: ~ 57 | label: '{name}' 58 | label_font: Assets/Fonts/Roboto-Bold.ttf 59 | label_size: 12 60 | overlay: 'Assets/Images/light_off.png' 61 | action: 'toggle' 62 | 63 | - type: "automation" 64 | class: 'HassTile' 65 | states: 66 | - state: 'on' 67 | label: '{name}' 68 | label_font: Assets/Fonts/Roboto-Bold.ttf 69 | label_size: 12 70 | overlay: 'Assets/Images/automation_on.png' 71 | - state: ~ 72 | label: '{name}' 73 | label_font: Assets/Fonts/Roboto-Bold.ttf 74 | label_size: 12 75 | overlay: 'Assets/Images/automation_off.png' 76 | action: 'toggle' 77 | 78 | - type: "scene" 79 | class: 'HassTile' 80 | states: 81 | - state: ~ 82 | label: '{name}' 83 | label_font: Assets/Fonts/Roboto-Bold.ttf 84 | label_size: 12 85 | overlay: 'Assets/Images/scene.png' 86 | action: 'automation/trigger' 87 | 88 | - type: "temperature" 89 | class: 'HassTile' 90 | states: 91 | - state: ~ 92 | label: '{name}' 93 | label_font: Assets/Fonts/Roboto-Bold.ttf 94 | label_size: 12 95 | value: '{state} °C' 96 | value_font: Assets/Fonts/Roboto-Light.ttf 97 | value_size: 18 98 | overlay: 'Assets/Images/sensor.png' 99 | action: ~ 100 | 101 | - type: "page" 102 | class: 'PageTile' 103 | states: 104 | - state: ~ 105 | label: '{name}' 106 | label_font: Assets/Fonts/Roboto-Bold.ttf 107 | label_size: 12 108 | overlay: 'Assets/Images/page.png' 109 | 110 | ################################################# 111 | ### Screen Layout Configuration Settings ### 112 | ################################################# 113 | screens: 114 | # Each screen consists of one or more tiles of various types, bound to an 115 | # entity or page. The "home" page is mandatory, and is the one shown when the 116 | # script starts. 117 | 118 | - name: "home" 119 | tiles: 120 | - position: [0, 0] 121 | type: "light" 122 | name: "Study" 123 | entity_id: "group.study_lights" 124 | - position: [0, 1] 125 | type: "scene" 126 | name: "Normal" 127 | entity_id: "automation.bender_pausedstopped" 128 | - position: [1, 1] 129 | type: "scene" 130 | name: "Dim" 131 | entity_id: "automation.bender_playing" 132 | - position: [0, 2] 133 | type: "light" 134 | name: "Mr Ed" 135 | entity_id: "light.mr_ed" 136 | - position: [1, 2] 137 | type: "light" 138 | name: "Desk Lamp" 139 | entity_id: "light.desk_lamp" 140 | - position: [2, 2] 141 | type: "light" 142 | name: "Study Bias" 143 | entity_id: "light.study_bias" 144 | - position: [4, 2] 145 | type: "automation" 146 | name: "Auto Dim" 147 | entity_id: "group.study_automations" 148 | - position: [4, 0] 149 | type: "page" 150 | name: "Sensors" 151 | page: "sensors" 152 | 153 | - name: "sensors" 154 | tiles: 155 | - position: [4, 0] 156 | type: "page" 157 | name: "Home" 158 | page: ~ 159 | - position: [3, 0] 160 | type: "temperature" 161 | name: "Study" 162 | entity_id: "sensor.study_temperature" 163 | - position: [2, 0] 164 | type: "temperature" 165 | name: "Living Room" 166 | entity_id: "sensor.living_room_temperature" 167 | - position: [1, 0] 168 | type: "temperature" 169 | name: "Bedroom" 170 | entity_id: "sensor.bedroom_temperature" 171 | - position: [0, 0] 172 | type: "temperature" 173 | name: "Server" 174 | entity_id: "sensor.server_closet_temperature" 175 | -------------------------------------------------------------------------------- /src/HassClient.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # Python StreamDeck HomeAssistant Client 4 | # Released under the MIT license 5 | # 6 | # dean [at] fourwalledcubicle [dot] com 7 | # www.fourwalledcubicle.com 8 | # 9 | 10 | from HomeAssistantWS.RemoteWS import HomeAssistantWS 11 | from Tile.TileManager import TileManager 12 | 13 | import StreamDeck.DeviceManager as StreamDeck 14 | import logging 15 | import asyncio 16 | import yaml 17 | 18 | 19 | class Config(object): 20 | def __init__(self, filename): 21 | try: 22 | logging.info('Reading config file "{}"...'.format(filename)) 23 | 24 | with open(filename, 'r', encoding='utf-8') as config_file: 25 | self.config = yaml.safe_load(config_file) 26 | except IOError: 27 | logging.error('Failed to read config file "{}"!'.format(filename)) 28 | 29 | self.config = {} 30 | 31 | def get(self, path, default=None): 32 | value = default 33 | 34 | location = self.config 35 | for fragment in path.split('/'): 36 | location = location.get(fragment, None) 37 | if location is None: 38 | return default 39 | 40 | value = location or default 41 | 42 | return value 43 | 44 | 45 | class ScreenSaver: 46 | def __init__(self, loop, deck): 47 | self.deck = deck 48 | 49 | deck.set_key_callback_async(self._handle_button_press) 50 | 51 | async def start(self, brightness, callback, timeout=0): 52 | self.brightness = brightness 53 | self.callback = callback 54 | self.timeout = timeout 55 | 56 | loop.create_task(self._loop()) 57 | 58 | async def _loop(self): 59 | await self._set_on() 60 | 61 | if self.timeout == 0: 62 | return 63 | 64 | while True: 65 | await asyncio.sleep(1) 66 | 67 | if self.on: 68 | self.steps -= 1 69 | if self.steps < 0: 70 | await self._set_off() 71 | 72 | async def _set_on(self): 73 | self.deck.set_brightness(self.brightness) 74 | self.steps = self.timeout 75 | self.on = True 76 | 77 | async def _set_off(self): 78 | self.deck.set_brightness(0) 79 | self.steps = 0 80 | self.on = False 81 | 82 | async def _handle_button_press(self, deck, key, state): 83 | if self.on: 84 | self.steps = self.timeout 85 | await self.callback(deck, key, state) 86 | else: 87 | if not state: 88 | await self._set_on() 89 | 90 | 91 | async def main(loop, config): 92 | conf_deck_brightness = config.get('streamdeck/brightness', 20) 93 | conf_deck_screensaver = config.get('streamdeck/screensaver', 0) 94 | conf_hass_host = config.get('home_assistant/host', 'localhost') 95 | conf_hass_ssl = config.get('home_assistant/ssl', False) 96 | conf_hass_port = config.get('home_assistant/port', 8123) 97 | conf_hass_pw = config.get('home_assistant/api_password') 98 | conf_hass_token = config.get('home_assistant/api_token') 99 | 100 | decks = StreamDeck.DeviceManager().enumerate() 101 | if not decks: 102 | logging.error("No StreamDeck found.") 103 | return False 104 | 105 | deck = decks[0] 106 | hass = HomeAssistantWS(ssl=conf_hass_ssl, host=conf_hass_host, port=conf_hass_port) 107 | 108 | tiles = dict() 109 | pages = dict() 110 | 111 | tile_classes = getattr(__import__("Tile"), "Tile") 112 | 113 | # Build dictionary for the tile class templates given in the config file 114 | conf_tiles = config.get('tiles', []) 115 | for conf_tile in conf_tiles: 116 | conf_tile_type = conf_tile.get('type') 117 | conf_tile_class = conf_tile.get('class') 118 | conf_tile_action = conf_tile.get('action') 119 | conf_tile_states = conf_tile.get('states') 120 | 121 | tile_states = dict() 122 | for conf_tile_state in conf_tile_states: 123 | state = conf_tile_state.get('state') 124 | tile_states[state] = conf_tile_state 125 | 126 | tiles[conf_tile_type] = { 127 | 'class': getattr(tile_classes, conf_tile_class), 128 | 'states': tile_states, 129 | 'action': conf_tile_action, 130 | } 131 | 132 | # Build dictionary of tile pages 133 | conf_screens = config.get('screens', []) 134 | for conf_screen in conf_screens: 135 | conf_screen_name = conf_screen.get('name') 136 | conf_screen_tiles = conf_screen.get('tiles') 137 | 138 | page_tiles = dict() 139 | for conf_screen_tile in conf_screen_tiles: 140 | conf_screen_tile_pos = conf_screen_tile.get('position') 141 | conf_screen_tile_type = conf_screen_tile.get('type') 142 | 143 | conf_tile_class_info = tiles.get(conf_screen_tile_type) 144 | 145 | page_tiles[tuple(conf_screen_tile_pos)] = conf_tile_class_info['class'](deck=deck, hass=hass, tile_class=conf_tile_class_info, tile_info=conf_screen_tile) 146 | 147 | pages[conf_screen_name] = page_tiles 148 | 149 | tile_manager = TileManager(deck, pages) 150 | 151 | async def hass_state_changed(data): 152 | await tile_manager.update_page(force_redraw=False) 153 | 154 | async def steamdeck_key_state_changed(deck, key, state): 155 | await tile_manager.button_state_changed(key, state) 156 | 157 | logging.info("Connecting to %s:%s...", conf_hass_host, conf_hass_port) 158 | await hass.connect(api_password=conf_hass_pw, api_token=conf_hass_token) 159 | 160 | deck.open() 161 | deck.reset() 162 | 163 | screensaver = ScreenSaver(loop=loop, deck=deck) 164 | await screensaver.start(brightness=conf_deck_brightness, callback=steamdeck_key_state_changed, timeout=conf_deck_screensaver) 165 | 166 | await tile_manager.set_deck_page(None) 167 | await hass.subscribe_to_event('state_changed', hass_state_changed) 168 | 169 | return True 170 | 171 | 172 | if __name__ == '__main__': 173 | logging.basicConfig(level=logging.INFO) 174 | 175 | loop = asyncio.get_event_loop() 176 | 177 | config = Config('config.yaml') 178 | if config.get('debug'): 179 | logging.info('Debug enabled') 180 | loop.set_debug(True) 181 | loop.slow_callback_duration = 0.15 182 | 183 | init_ok = loop.run_until_complete(main(loop, config)) 184 | if init_ok: 185 | loop.run_forever() 186 | -------------------------------------------------------------------------------- /src/Assets/Fonts/LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | --------------------------------------------------------------------------------