├── tests └── __init__.py ├── src └── homedeck │ ├── __init__.py │ ├── event_bus.py │ ├── enums.py │ ├── configuration.py │ ├── template.py │ ├── home_assistant.py │ ├── utils.py │ ├── elements.py │ ├── yaml │ ├── configuration.base.yml │ └── configuration.schema.yml │ ├── dataclasses.py │ ├── homedeck.py │ └── icons.py ├── .gitattributes ├── assets ├── fonts │ └── Roboto-SemiBold.ttf └── configuration.yml.example ├── standalone.sh ├── deck.py ├── guides ├── README.md ├── linux │ └── install.sh └── orange-pi.md ├── .vscode └── settings.json ├── .env.example ├── LICENSE ├── pyproject.toml ├── .gitignore ├── server.py └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/homedeck/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /assets/fonts/Roboto-SemiBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redphx/homedeck/HEAD/assets/fonts/Roboto-SemiBold.ttf -------------------------------------------------------------------------------- /standalone.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | /lib/systemd/systemd-udevd --daemon 4 | udevadm control --reload-rules 5 | udevadm trigger 6 | 7 | python standalone.py 8 | -------------------------------------------------------------------------------- /deck.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from homedeck.homedeck import HomeDeck 4 | 5 | 6 | async def main(): 7 | deck = HomeDeck() 8 | await deck.connect() 9 | 10 | asyncio.run(main()) 11 | -------------------------------------------------------------------------------- /guides/README.md: -------------------------------------------------------------------------------- 1 | HomeDeck should work on any Linux devices running Linux. 2 | 3 | It can run on a Raspberry Pi Zero 2W, but my go to choice is Orange Pi Zero 2W becase it has 2 USB Type-C port. There is also Banana Pi BPI-M4 Zero. 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "yaml.completion": true, 3 | "yaml.validate": true, 4 | "yaml.schemaStore.enable": false, 5 | // "yaml.trace.server": "verbose", 6 | 7 | "json.schemas": [], 8 | "yaml.schemas": { 9 | "src/homedeck/yaml/configuration.schema.yml": [ 10 | "src/homedeck/yaml/configuration.base.yml", 11 | "assets/configuration.yml", 12 | "assets/configuration.yml.example", 13 | ], 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /assets/configuration.yml.example: -------------------------------------------------------------------------------- 1 | brightness: 90 2 | 3 | sleep: 4 | dim_brightness: 20 5 | dim_timeout: 10 6 | sleep_timeout: 300 7 | 8 | system_buttons: 9 | $page.back: 10 | position: 1 11 | $page.previous: 12 | position: 2 13 | $page.next: 14 | position: 3 15 | 16 | presets: {} 17 | 18 | pages: 19 | $root: 20 | buttons: 21 | - text: Hello 22 | icon_background_color: b9003e 23 | 24 | - text: World! 25 | icon_background_color: b9003e 26 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # "wss" = secure websocket, "ws" = normal websocket 2 | HA_HOST="ws://localhost:8123" 3 | # Long-lived access token: https://www.home-assistant.io/docs/authentication 4 | HA_ACCESS_TOKEN="" 5 | 6 | # Check "TZ idenfifier" column: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones 7 | TIMEZONE="America/New_York" 8 | 9 | # mDNS service's unique identifier 10 | # Must be unique within the local network 11 | # Accepted characters: A-Z, a-z, 0-9, - _ and spaces 12 | # Leaving empty = use IP address 13 | MDNS_SERVICE_ID="" 14 | -------------------------------------------------------------------------------- /guides/linux/install.sh: -------------------------------------------------------------------------------- 1 | # USB HID 2 | apt install -y udev usbutils libusb-1.0-0-dev libudev-dev 3 | # Python3 with venv support 4 | apt install -y python3-venv 5 | # Tools for HomeDeck 6 | apt install -y libcairo2 optipng 7 | 8 | mkdir -p /app 9 | # Download HomeDeck 10 | git clone https://github.com/redphx/homedeck.git 11 | 12 | # Create custom venv 13 | python3 -m venv /app/homedeck-venv 14 | # Install HomeDeck 15 | source /app/homedeck-venv/bin/activate && cd /app/homedeck && pip install -e . 16 | 17 | # Run HomeDeck at startup 18 | ( crontab -l 2>/dev/null; echo "@reboot /bin/bash -c 'source /app/homedeck-venv/bin/activate && python /app/homedeck/server.py'" ) | crontab - 19 | 20 | echo "Installed HomeDeck successfully!" 21 | -------------------------------------------------------------------------------- /guides/orange-pi.md: -------------------------------------------------------------------------------- 1 | ## OrangePi Zero 2W 2 | 3 | ### OrangePi OS (Ubuntu) 4 | 5 | [Download](https://drive.google.com/drive/folders/1g806xyPnVFyM8Dz_6wAWeoTzaDg3PH4Z) 6 | 7 | Install the `*_sever_linux version`. If kernel 6.1 doesn't work then try 5.4. 8 | 9 | 1. Burn the OS to the SD card 10 | 2. Plug in HDMI, USB Keyboard (middle port) and power (right-most port) 11 | 3. Default password is `orangepi` 12 | 4. Optional: run `apt update && apt upgrade` 13 | 5. Run `orangepi-config` and setup WiFi (same network as Home Assistant), timezone and SSH 14 | 6. Use [Terminal & SSH addon](https://github.com/hassio-addons/addon-ssh) to connect to the device (`ssh root@`, `orangepi` as password) 15 | 7. Run this command to install HomeDeck: 16 | ```bash 17 | curl -fsSL https://raw.githubusercontent.com/redphx/homedeck/main/guides/linux/install.sh | bash 18 | ``` 19 | 8. 20 | 21 | ### Armbian (Debian) 22 | 23 | [Download](https://www.armbian.com/orange-pi-zero-2w/) 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 redphx 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=70", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "homedeck" 7 | version = "0.1.0" 8 | description = "Control Home Assistant using Stream Deck-like devices" 9 | authors = [{name = "redphx"}] 10 | readme = {file = "README.md", content-type = "text/markdown"} 11 | license = {file = "LICENSE"} 12 | keywords = ["streamdeck", "stream deck", "stream-deck"] 13 | requires-python = ">=3.9" 14 | dependencies = [ 15 | "strmdck==0.1.0rc1", 16 | "cairosvg==2.7.1", 17 | "construct==2.10.70", 18 | "deepdiff==8.2.0", 19 | "fastapi[standard]==0.115.11", 20 | "httpx==0.28.1", 21 | "Jinja2==3.1.5", 22 | "jsonschema==4.23.0", 23 | "materialyoucolor==2.0.10", 24 | "pillow==11.1.0", 25 | "psutil==7.0.0", 26 | "pytest==8.3.4", 27 | "python-dotenv==1.0.1", 28 | "PyYAML==6.0.2", 29 | "toml==0.10.2", 30 | "uvicorn==0.34.0", 31 | "watchdog==6.0.0", 32 | "websockets==14.2", 33 | "zeroconf==0.146.1" 34 | ] 35 | 36 | [project.urls] 37 | Homepage = "https://github.com/redphx/strmdck-home-assistant" 38 | Issues = "https://github.com/redphx/strmdck-home-assistant/issues" 39 | Repository = "https://github.com/redphx/strmdck-home-assistant.git" 40 | -------------------------------------------------------------------------------- /src/homedeck/event_bus.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import inspect 3 | from enum import Enum 4 | 5 | 6 | class EventName(Enum): 7 | DECK_RELOAD = 'deck-reload' 8 | DECK_FORCE_RELOAD = 'deck-force-reload' 9 | 10 | 11 | class EventBus: 12 | def __init__(self): 13 | self.listeners = {} 14 | 15 | def subscribe(self, event_name, callback): 16 | """Subscribe a function (sync or async) to an event.""" 17 | if not callable(callback): 18 | raise TypeError('Callback must be a callable (function or coroutine function)') 19 | 20 | if event_name not in self.listeners: 21 | self.listeners[event_name] = [] 22 | 23 | self.listeners[event_name].append(callback) 24 | 25 | def unsubscribe(self, event_name, callback): 26 | """Unsubscribe a function from an event.""" 27 | if event_name in self.listeners: 28 | self.listeners[event_name].remove(callback) 29 | if not self.listeners[event_name]: # Remove event if no more listeners 30 | del self.listeners[event_name] 31 | 32 | async def publish(self, event_name, *args, **kwargs): 33 | """Publish an event and notify all subscribers, handling both sync and async functions.""" 34 | if event_name not in self.listeners: 35 | return 36 | 37 | tasks = [] 38 | for callback in self.listeners[event_name]: 39 | if inspect.iscoroutinefunction(callback): # Async function 40 | tasks.append(callback(*args, **kwargs)) 41 | else: # Sync function 42 | callback(*args, **kwargs) 43 | 44 | if tasks: 45 | await asyncio.gather(*tasks) # Run async functions concurrently 46 | 47 | 48 | # Example Usage 49 | def sync_handler(data): 50 | print(f'Sync handler received: {data}') 51 | 52 | 53 | async def async_handler(data): 54 | print(f'Async handler received: {data}') 55 | 56 | 57 | event_bus = EventBus() 58 | -------------------------------------------------------------------------------- /src/homedeck/enums.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class ButtonElementAction(Enum): 5 | PAGE_BACK = '$page.back' 6 | PAGE_PREVIOUS = '$page.previous' 7 | PAGE_NEXT = '$page.next' 8 | PAGE_GO_TO = '$page.go_to' 9 | 10 | 11 | class InteractionType(Enum): 12 | TAP = 'tap' 13 | HOLD = 'hold' 14 | 15 | 16 | class IconSource(Enum): 17 | BLANK = 'blank' 18 | LOCAL = 'local' 19 | URL = 'url' 20 | TEXT = 'text' 21 | MATERIAL_DESIGN = 'mdi' 22 | PHOSPHOR = 'pi' 23 | 24 | 25 | class PhosphorIconVariant: 26 | THIN = 'thin' 27 | LIGHT = 'light' 28 | REGULAR = 'regular' 29 | BOLD = 'bold' 30 | FILL = 'fill' 31 | DUOTONE = 'duotone' 32 | 33 | 34 | class SleepStatus: 35 | WAKE = 'wake' 36 | DIM = 'dim' 37 | SLEEP = 'sleep' 38 | 39 | 40 | class MaterialYouScheme: 41 | PRIMARY = 'primary' 42 | ON_PRIMARY = 'on-primary' 43 | PRIMARY_CONTAINER = 'primary-container' 44 | ON_PRIMARY_CONTAINER = 'on-primary-container' 45 | SECONDARY = 'secondary' 46 | ON_SECONDARY = 'on-secondary' 47 | SECONDARY_CONTAINER = 'secondary-container' 48 | ON_SECONDARY_CONTAINER = 'on-secondary-container' 49 | TERTIARY = 'tertiary' 50 | ON_TERTIARY = 'on-tertiary' 51 | TERTIARY_CONTAINER = 'tertiary-container' 52 | ON_TERTIARY_CONTAINER = 'on-tertiary-container' 53 | ERROR = 'error' 54 | ON_ERROR = 'on-error' 55 | ERROR_CONTAINER = 'error-container' 56 | ON_ERROR_CONTAINER = 'on-error-container' 57 | BACKGROUND = 'background' 58 | ON_BACKGROUND = 'on-background' 59 | SURFACE = 'surface' 60 | ON_SURFACE = 'on-surface' 61 | SURFACE_VARIANT = 'surface-variant' 62 | ON_SURFACE_VARIANT = 'on-surface-variant' 63 | OUTLINE = 'outline' 64 | OUTLINE_VARIANT = 'outline-variant' 65 | SHADOW = 'shadow' 66 | SCRIM = 'scrim' 67 | INVERSE_SURFACE = 'inverse-surface' 68 | INVERSE_ON_SURFACE = 'inverse-on-surface' 69 | INVERSE_PRIMARY = 'inverse-primary' 70 | -------------------------------------------------------------------------------- /src/homedeck/configuration.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | 5 | import jsonschema 6 | import jsonschema.exceptions 7 | import yaml 8 | from strmdck.device import DeckDevice 9 | 10 | from .dataclasses import MainConfig 11 | from .elements import PageElement 12 | 13 | 14 | class Configuration: 15 | def __init__(self, *, device: DeckDevice, source_dict: dict, all_states: dict): 16 | self._device = device 17 | self._config_dict = source_dict 18 | 19 | self._is_valid = self._validate() 20 | if self._is_valid: 21 | self._post_process(all_states=all_states) 22 | 23 | def _validate(self): 24 | script_dir = os.path.dirname(os.path.realpath(__file__)) 25 | with open(os.path.join(script_dir, 'yaml', 'configuration.schema.yml'), 'r', encoding='utf-8') as fp: 26 | try: 27 | jsonschema.validate(instance=self._config_dict, schema=yaml.safe_load(fp)) 28 | return True 29 | except jsonschema.exceptions.ValidationError as e: 30 | print(e) 31 | 32 | return False 33 | 34 | def _post_process(self, all_states: dict): 35 | self._config = MainConfig(**self._config_dict) 36 | self._config.post_setup(device=self._device, all_states=all_states) 37 | 38 | self._page_elements = {} 39 | 40 | self._config_dict.setdefault('presets', {}) 41 | 42 | def is_valid(self): 43 | return self._is_valid 44 | 45 | @property 46 | def brightness(self): 47 | return self._config.brightness 48 | 49 | @property 50 | def label_style(self): 51 | return self._config.label_style 52 | 53 | @property 54 | def sleep(self): 55 | return self._config.sleep 56 | 57 | @property 58 | def system_buttons(self): 59 | return self._config.system_buttons 60 | 61 | @property 62 | def presets(self): 63 | return self._config_dict.get('presets', {}) 64 | 65 | @property 66 | def page_elements(self): 67 | return self._page_elements 68 | 69 | def get_page_element(self, page_id: str) -> PageElement: 70 | if page_id in self._page_elements: 71 | return self._page_elements[page_id] 72 | 73 | page_element = PageElement(self._config.pages[page_id]) 74 | self._page_elements[page_id] = page_element 75 | 76 | return page_element 77 | 78 | def has_page(self, page_id: str) -> bool: 79 | return page_id in self._config.pages 80 | 81 | def get_button(self, page_id: str, button_index: int): 82 | page = self.get_page_element(page_id) 83 | if page: 84 | return page.get_button_at(button_index) 85 | 86 | return None 87 | 88 | def __eq__(self, other: Configuration): 89 | # Only compare settings that are not `pages` 90 | return self._config == other._config 91 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | 162 | # Project specific 163 | .build 164 | .cache 165 | *.dylib 166 | assets/configuration.yml 167 | assets/icons/ 168 | assets/fonts/ 169 | -------------------------------------------------------------------------------- /src/homedeck/template.py: -------------------------------------------------------------------------------- 1 | import functools as ft 2 | import traceback 3 | from typing import Union 4 | 5 | import jinja2 6 | 7 | env = jinja2.Environment() 8 | 9 | 10 | def _to_float(s: str) -> Union[float, bool]: 11 | try: 12 | return float(s) 13 | except ValueError: 14 | return False 15 | 16 | 17 | def _to_int(s: str) -> Union[int, bool]: 18 | try: 19 | return int(s) 20 | except ValueError: 21 | return False 22 | 23 | 24 | def _auto_cast(s: str, *, rounded: bool = False) -> Union[int, float, str]: 25 | if not isinstance(s, str): 26 | return s 27 | 28 | if s == 'true': 29 | return True 30 | elif s == 'false': 31 | return False 32 | 33 | num = _to_int(s) 34 | if num is False: 35 | num = _to_float(s) 36 | 37 | if num is not False: 38 | return rounded(num) if rounded else num 39 | 40 | return s 41 | 42 | 43 | def _states(entity_id: str, *, with_unit: bool = False, rounded: bool = False, all_states: Union[dict, None] = None): 44 | assert all_states is not None 45 | 46 | entity_state = all_states.get(entity_id, {}) 47 | if not entity_state: 48 | return None 49 | 50 | state = entity_state['state'] 51 | if state == 'unavailable': 52 | return '' 53 | 54 | state = _auto_cast(state, rounded=rounded) 55 | if with_unit: 56 | unit = entity_state.get('attributes', {}).get('unit_of_measurement') 57 | if unit: 58 | state = f'{state} {unit}' 59 | 60 | return state 61 | 62 | 63 | def _self_states(*, with_unit: bool = False, rounded: bool = False, entity_id: str, all_states: Union[dict, None] = None): 64 | return _states(with_unit=with_unit, rounded=rounded, entity_id=entity_id, all_states=all_states) 65 | 66 | 67 | def _state_attr(entity_id: str, attr: str, all_states: dict): 68 | attrs = all_states.get(entity_id, {}).get('attributes', {}) 69 | state_attr = attrs.get(attr) 70 | 71 | return _auto_cast(state_attr) 72 | 73 | 74 | def _self_state_attr(attr: str, entity_id: str, all_states: dict): 75 | return _state_attr(entity_id, attr, all_states=all_states) 76 | 77 | 78 | def _is_state(entity_id: str, state: str, all_states: dict) -> bool: 79 | return _states(entity_id, all_states=all_states) == _auto_cast(state) 80 | 81 | 82 | def _self_is_state(state: str, entity_id: str, all_states: dict) -> bool: 83 | return _is_state(entity_id, state, all_states) 84 | 85 | 86 | def _binary_text(entity_id: str, on_text: str, off_text: str, all_states: dict) -> bool: 87 | is_on = _is_state(entity_id, 'on', all_states) 88 | return on_text if is_on else off_text 89 | 90 | 91 | def _self_binary_text(on_text: str, off_text: str, entity_id: str, all_states: dict) -> bool: 92 | return _binary_text(entity_id, on_text, off_text, all_states) 93 | 94 | 95 | def render_template(source, all_states: dict, entity_id=None): 96 | if isinstance(source, dict): 97 | return {k: render_template(v, all_states, entity_id=entity_id) for k, v in source.items()} 98 | elif isinstance(source, list): 99 | return [render_template(v, all_states, entity_id=entity_id) for v in source] 100 | elif isinstance(source, str): 101 | try: 102 | return env.from_string(source).render( 103 | state_attr=ft.partial(_state_attr, all_states=all_states), 104 | is_state=ft.partial(_is_state, all_states=all_states), 105 | states=ft.partial(_states, all_states=all_states), 106 | binary_text=ft.partial(_binary_text, all_states=all_states), 107 | 108 | self_state_attr=ft.partial(_self_state_attr, entity_id=entity_id, all_states=all_states), 109 | self_is_state=ft.partial(_self_is_state, entity_id=entity_id, all_states=all_states), 110 | self_states=ft.partial(_self_states, entity_id=entity_id, all_states=all_states), 111 | self_binary_text=ft.partial(_self_binary_text, entity_id=entity_id, all_states=all_states), 112 | ).strip() 113 | except Exception: 114 | print('⚠️', source) 115 | traceback.print_exc() 116 | return '#BUG' 117 | 118 | return source 119 | 120 | 121 | def has_jinja_template(d: dict): 122 | if isinstance(d, dict): 123 | return any(has_jinja_template(v) for v in d.values()) 124 | elif isinstance(d, list): 125 | return any(has_jinja_template(v) for v in d) 126 | elif isinstance(d, str): 127 | return '{{' in d or '{%' in d or '{#' in d 128 | 129 | return False 130 | -------------------------------------------------------------------------------- /src/homedeck/home_assistant.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import logging 4 | from contextlib import asynccontextmanager 5 | 6 | import websockets 7 | 8 | logging.basicConfig(level=logging.INFO) 9 | 10 | 11 | class HomeAssistantWebSocket: 12 | def __init__(self, host: str, token: str): 13 | self._host = host.rstrip('/') 14 | self._token = token 15 | self._ws = None 16 | self._message_id = 1 17 | self._event_listeners = {} 18 | self._lock = asyncio.Lock() 19 | self._should_reconnect = True 20 | 21 | self._states = {} 22 | 23 | self._callbacks = {} 24 | self._error_callbacks = {} 25 | 26 | @asynccontextmanager 27 | async def connect(self) -> websockets.WebSocketClientProtocol: 28 | ws_url = f'{self._host}/api/websocket' 29 | 30 | async with websockets.connect(ws_url, ping_timeout=5) as ws: 31 | self._ws = ws 32 | await self._authenticate() 33 | yield ws 34 | 35 | async def disconnect(self): 36 | await self._ws.close() 37 | 38 | async def get_entity_state(self, entity_id: str): 39 | if entity_id in self._states: 40 | return self._states[entity_id] 41 | 42 | return None 43 | 44 | async def _on_state_changed(self, data): 45 | try: 46 | self._states[data['entity_id']] = data['new_state'] 47 | 48 | if self._callback: 49 | await self._callback() 50 | except Exception as e: 51 | print('_on_state_changed', e) 52 | 53 | @property 54 | def all_states(self): 55 | return self._states 56 | 57 | async def _authenticate(self): 58 | response = await self._ws.recv() 59 | auth_message = json.dumps({ 60 | 'type': 'auth', 61 | 'access_token': self._token, 62 | }) 63 | await self._ws.send(auth_message) 64 | response = await self._ws.recv() 65 | 66 | data = json.loads(response) 67 | if data.get('type') != 'auth_ok': 68 | raise Exception('Authentication failed!') 69 | 70 | logging.info('Authenticated successfully.') 71 | 72 | async def send_message(self, message: dict, callback=None): 73 | logging.info('send_message: ' + str(message)) 74 | async with self._lock: 75 | message['id'] = self._message_id 76 | if callback: 77 | self._callbacks[message['id']] = callback 78 | 79 | self._message_id += 1 80 | await self._ws.send(json.dumps(message)) 81 | 82 | return message['id'] 83 | 84 | async def send_message_sync(self, message: dict): 85 | logging.info('send_message (sync): ' + str(message)) 86 | async with self._lock: 87 | message['id'] = self._message_id 88 | self._message_id += 1 89 | await self._ws.send(json.dumps(message)) 90 | 91 | while True: 92 | data = json.loads(await self._ws.recv()) 93 | if data['type'] == 'result': 94 | return data['result'] 95 | 96 | async def call_service(self, *, domain: str, service: str, service_data: dict = None): 97 | return await self.send_message({ 98 | 'type': 'call_service', 99 | 'domain': domain, 100 | 'service': service, 101 | 'service_data': service_data or {} 102 | }) 103 | 104 | async def get_state(self, entity_id: str): 105 | return await self.send_message({ 106 | 'type': 'get_states', 107 | 'entity_id': entity_id 108 | }) 109 | 110 | async def get_all_states(self, callback=None): 111 | states = await self.send_message_sync({'type': 'get_states'}) 112 | logging.info('Received all_states') 113 | self._states = {state['entity_id']: state for state in states} 114 | 115 | async def turn_on(self, entity_id: str): 116 | return await self.call_service('homeassistant', 'turn_on', { 117 | 'entity_id': entity_id, 118 | }) 119 | 120 | async def turn_off(self, entity_id: str): 121 | return await self.call_service('homeassistant', 'turn_off', { 122 | 'entity_id': entity_id, 123 | }) 124 | 125 | async def subscribe_events(self, event_type: str): 126 | return await self.send_message({ 127 | 'type': 'subscribe_events', 128 | 'event_type': event_type, 129 | }) 130 | 131 | async def listen(self): 132 | async for message in self._ws: 133 | data = json.loads(message) 134 | # logging.info(f'Received: {message}') 135 | 136 | if 'id' in data and data['id'] in self._callbacks: 137 | if 'result' not in data: 138 | continue 139 | 140 | callback = self._callbacks.pop(data['id']) 141 | if callback: 142 | await callback(data['result']) 143 | elif data.get('type') == 'event' and data.get('event', {}).get('event_type') in self._event_listeners: 144 | event = data['event'] 145 | event_type = event['event_type'] 146 | event_data = event['data'] 147 | # Update state 148 | if event_type == 'state_changed': 149 | self._states[event_data['entity_id']] = event_data['new_state'] 150 | 151 | for callback in self._event_listeners[event_type]: 152 | await callback(event_data) 153 | 154 | def on_event(self, event_type: str, callback): 155 | if event_type not in self._event_listeners: 156 | self._event_listeners[event_type] = [] 157 | 158 | self._event_listeners[event_type].append(callback) 159 | -------------------------------------------------------------------------------- /src/homedeck/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import shutil 4 | import subprocess 5 | import zipfile 6 | from typing import Union 7 | 8 | from materialyoucolor.dynamiccolor.material_dynamic_colors import MaterialDynamicColors 9 | from materialyoucolor.hct import Hct 10 | from materialyoucolor.scheme.scheme_content import SchemeContent 11 | from materialyoucolor.scheme.scheme_expressive import SchemeExpressive 12 | from materialyoucolor.scheme.scheme_fidelity import SchemeFidelity 13 | from materialyoucolor.scheme.scheme_fruit_salad import SchemeFruitSalad 14 | from materialyoucolor.scheme.scheme_monochrome import SchemeMonochrome 15 | from materialyoucolor.scheme.scheme_neutral import SchemeNeutral 16 | from materialyoucolor.scheme.scheme_rainbow import SchemeRainbow 17 | from materialyoucolor.scheme.scheme_tonal_spot import SchemeTonalSpot 18 | from materialyoucolor.scheme.scheme_vibrant import SchemeVibrant 19 | 20 | from .enums import ButtonElementAction 21 | 22 | HAS_OPTIPNG = shutil.which('optipng') is not None 23 | 24 | 25 | def normalize_tuple(offset): 26 | if isinstance(offset, tuple): 27 | return offset 28 | 29 | if isinstance(offset, int): 30 | return (offset, offset) 31 | 32 | if isinstance(offset, str): 33 | try: 34 | tmp = offset.split(' ') 35 | return (int(tmp[0]), int(tmp[1])) 36 | except Exception: 37 | pass 38 | 39 | return (0, 0) 40 | 41 | 42 | def normalize_hex_color(color: Union[str, int]): 43 | if not color: 44 | return None 45 | 46 | try: 47 | if isinstance(color, list): 48 | r, g, b, _ = color 49 | return '{:02X}{:02X}{:02X}'.format(r, g, b) 50 | 51 | color = str(color) 52 | 53 | # Remove '/' prefix 54 | if color.startswith('/'): 55 | color = color[1:] 56 | 57 | # Padding 58 | color = color.ljust(6, '0') 59 | color = color.upper() 60 | 61 | assert re.match(r'[0-9A-F]{6}', color) 62 | return color 63 | except Exception: 64 | return None 65 | 66 | 67 | def hex_to_rgb(hex_color: str, alpha=None): 68 | hex_color = normalize_hex_color(hex_color) 69 | r, g, b = [int(hex_color[i:i + 2], 16) for i in (0, 2, 4)] 70 | 71 | if alpha is not None: 72 | return (r, g, b, alpha) 73 | 74 | return (r, g, b) 75 | 76 | 77 | def deep_merge(base: dict, override: dict, *, allow_none=False): 78 | for key, value in override.items(): 79 | if key not in base: 80 | base[key] = value 81 | elif isinstance(base[key], dict) and isinstance(value, dict): 82 | base[key] = deep_merge(base[key], value) 83 | elif allow_none or value is not None: 84 | base[key] = value 85 | 86 | return base 87 | 88 | 89 | def apply_presets(*, source: dict, presets_config={}): 90 | if presets_config is None or not isinstance(source, dict): 91 | return source 92 | 93 | # Save a set of applied presets to avoid infinite loop 94 | applied_presets = set() 95 | 96 | output = source 97 | while True: 98 | output.setdefault('presets', []) 99 | preset_list = output['presets'] 100 | del output['presets'] 101 | if not preset_list: 102 | break 103 | 104 | if not isinstance(preset_list, list): 105 | preset_list = [preset_list] 106 | 107 | # Loop through presets, reversed 108 | merged_data = {} 109 | for preset_name in reversed(preset_list): 110 | if preset_name in applied_presets: 111 | continue 112 | 113 | applied_presets.add(preset_name) 114 | 115 | preset_data = presets_config.get(preset_name, None) 116 | if not preset_data: 117 | continue 118 | 119 | # Loop through preset_data 120 | for key, value in preset_data.items(): 121 | if key not in merged_data: 122 | merged_data[key] = value 123 | elif isinstance(value, dict): 124 | merged_data[key] = deep_merge(merged_data[key], value) 125 | 126 | output = deep_merge(merged_data, output, allow_none=True) 127 | 128 | return output 129 | 130 | 131 | def compress_folder(folder_path, output_zip, compress_level=0): 132 | method = zipfile.ZIP_STORED if compress_level == 0 else zipfile.ZIP_DEFLATED 133 | with zipfile.ZipFile(output_zip, 'w', method, compresslevel=compress_level) as zipf: 134 | # Write dummy.txt at the beginning of the zip file 135 | dummy_path = os.path.join(folder_path, 'dummy.txt') 136 | if (os.path.exists(dummy_path)): 137 | zipf.write(dummy_path, os.path.relpath(dummy_path, folder_path)) 138 | 139 | for root, _, files in os.walk(folder_path): 140 | for file in files: 141 | file_path = os.path.join(root, file) 142 | if file_path == dummy_path: 143 | # Don't write dummy file again 144 | continue 145 | 146 | arcname = os.path.relpath(file_path, folder_path) # Preserve folder structure 147 | zipf.write(file_path, arcname) 148 | 149 | 150 | def optimize_image(file_path, optimize_level=2): 151 | if not HAS_OPTIPNG: 152 | return 153 | 154 | try: 155 | subprocess.run(['optipng', f'-o{optimize_level}', file_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True) 156 | except Exception as e: 157 | print(e) 158 | 159 | 160 | def normalize_button_positions(positions: dict): 161 | ''' convert "$page.next" to enum("$page.next") ''' 162 | keys = list(positions.keys()) 163 | for key in keys: 164 | positions[ButtonElementAction(key)] = positions[key] 165 | del positions[key] 166 | 167 | return positions 168 | 169 | 170 | def camel_to_kebab(name): 171 | return re.sub(r'(? bool: # type: ignore 43 | if interaction not in self._actions: 44 | return False 45 | 46 | main_action = self._actions[interaction] 47 | print('⚠️', interaction.value, main_action) 48 | 49 | action = main_action.action 50 | if action == ButtonElementAction.PAGE_BACK.value: 51 | deck.page_go_back() 52 | elif action == ButtonElementAction.PAGE_PREVIOUS.value: 53 | deck.page_go_previous() 54 | elif action == ButtonElementAction.PAGE_NEXT.value: 55 | deck.page_go_next() 56 | elif action == ButtonElementAction.PAGE_GO_TO.value: 57 | deck.page_go_to(main_action.data) 58 | else: 59 | domain, action = action.split('.') 60 | await deck.call_ha_service(domain=domain, service=action, service_data=main_action.data) 61 | return True 62 | 63 | 64 | class PageElement: 65 | def __init__(self, page_config: PageConfig): 66 | self._page_config = page_config 67 | self._button_elements: Dict[ButtonElement] = {} 68 | self._button_raws = {} 69 | self._changed_button_elements = {} 70 | 71 | @property 72 | def buttons(self) -> Dict[int, ButtonElement]: 73 | return self._button_elements 74 | 75 | @property 76 | def changed_buttons(self) -> Dict[int, ButtonElement]: 77 | return self._changed_button_elements 78 | 79 | @property 80 | def page_config(self): 81 | return self._page_config 82 | 83 | @property 84 | def button_raws(self): 85 | return self._button_raws 86 | 87 | def _to_button_element(self, button): 88 | button_element = None 89 | if button: 90 | button_config = PageButtonConfig(**deepcopy(button)) 91 | button_element = ButtonElement(button_config) 92 | 93 | return button_element 94 | 95 | def _shift_index_right(self, buttons: dict, start_index: int): 96 | return {k + (1 if k >= start_index else 0): v for k, v in buttons.items()} 97 | 98 | def _insert_button_at(self, button: dict, index: int): 99 | # Shift buttons 100 | self._button_raws = self._shift_index_right(self._button_raws, index) 101 | self._button_elements = self._shift_index_right(self._button_elements, index) 102 | 103 | self._button_raws[index] = button 104 | self._button_elements[index] = self._to_button_element(button) 105 | 106 | def render_buttons(self, *, system_buttons: Dict[ButtonElementAction, SystemButtonConfig], page_number: int = 1, is_sub_page: bool = False, buttons_per_page=0, all_states=dict) -> bool: 107 | old_raws = self._button_raws 108 | new_raws = {} 109 | self._button_elements = {} 110 | 111 | total_skipped = 0 112 | for index, button in enumerate(deepcopy(self._page_config.buttons_raw)): 113 | if not button: 114 | new_raws[index] = None 115 | continue 116 | 117 | # Get entity_id for self_*() mixins 118 | entity_id = None 119 | if 'entity_id' in button: 120 | entity_id = button['entity_id'] 121 | states = all_states.get(entity_id) 122 | if states: 123 | if 'icon' not in button: 124 | # Use icon in states 125 | icon = states.get('attributes', {}).get('icon') 126 | if icon: 127 | button['icon'] = icon 128 | 129 | # Apply presets based on state 130 | state = states.get('state') 131 | if state and 'states' in button and state in button['states']: 132 | button = deep_merge(button, button['states'][state]) 133 | 134 | # Get default name 135 | if 'name' not in button: 136 | button['name'] = states.get('attributes', {}).get('friendly_name') 137 | 138 | # Render templates 139 | if button.get('is_dynamic'): 140 | button = render_template(button, entity_id=entity_id, all_states=all_states) 141 | 142 | # Check visibility 143 | visibility = button.get('visibility', True) 144 | is_hidden = visibility is False or visibility == 'False' or visibility == 'hidden' 145 | is_gone = visibility is None or visibility == 'None' or visibility == 'gone' 146 | 147 | if is_hidden: 148 | # Hide button 149 | button = None 150 | if is_gone: 151 | # Skip button 152 | total_skipped += 1 153 | continue 154 | 155 | # Save button element 156 | real_index = index - total_skipped 157 | new_raws[real_index] = button 158 | self._button_elements[real_index] = self._to_button_element(button) 159 | 160 | # Save current buttons 161 | self._button_raws = new_raws 162 | 163 | start = 0 164 | tmp_page_number = 1 165 | while start < len(new_raws): 166 | if is_sub_page and tmp_page_number == 1: 167 | # Insert Back button for page #1 168 | back_button = system_buttons[ButtonElementAction.PAGE_BACK] 169 | if back_button.position > 0: 170 | self._insert_button_at(back_button.button, start + (back_button.position - 1)) 171 | 172 | if tmp_page_number > 1: 173 | previous_button = system_buttons[ButtonElementAction.PAGE_PREVIOUS] 174 | if previous_button.position > 0: 175 | self._insert_button_at(previous_button.button, start + (previous_button.position - 1)) 176 | 177 | if start + buttons_per_page < len(new_raws): 178 | next_button = system_buttons[ButtonElementAction.PAGE_NEXT] 179 | self._insert_button_at(next_button.button, start + (next_button.position - 1)) 180 | 181 | new_raws = self._button_raws 182 | start += buttons_per_page 183 | tmp_page_number += 1 184 | 185 | # self._insert_button_at(system_buttons[ButtonElementAction.PAGE_NEXT], 12) 186 | # new_raws = self._button_raws 187 | buttons_range = range((page_number - 1) * buttons_per_page, page_number * buttons_per_page) 188 | 189 | # Find changed buttons 190 | self._changed_button_elements = {} 191 | for index in range(buttons_per_page): 192 | old_button = old_raws.get(index) 193 | new_button = new_raws.get(index + (page_number - 1) * buttons_per_page) 194 | 195 | changed = DeepDiff(old_button, new_button) 196 | if not changed: 197 | continue 198 | 199 | # Set changed button 200 | self._changed_button_elements[index] = self._to_button_element(new_button) 201 | 202 | # Limit number of buttons 203 | self._button_elements = {(index % buttons_per_page): value for index, value in self._button_elements.items() if index in buttons_range} 204 | self._button_raws = {(index % buttons_per_page): value for index, value in self._button_raws.items() if index in buttons_range} 205 | 206 | return bool(self._changed_button_elements) 207 | 208 | def get_button_at(self, button_index: int) -> ButtonElement: 209 | return self._button_elements.get(button_index) 210 | 211 | def __eq__(self, other: PageElement): 212 | # Compare configs and buttons_raw 213 | if not other or self._page_config != other.page_config: 214 | return False 215 | 216 | diff = DeepDiff(self.button_raws, other.button_raws) 217 | return not diff 218 | 219 | @staticmethod 220 | def generate(buttons: Dict[int, ButtonElement]): 221 | output = {} 222 | 223 | for index, button in buttons.items(): 224 | if not button: 225 | output[index] = None 226 | continue 227 | 228 | output[index] = {} 229 | if button.name and len(button.name) > 0: 230 | output[index]['name'] = button.name.strip() 231 | 232 | icon = button.get_icon() 233 | icon_name = icon.generated_filename() 234 | icon_path = os.path.join('.cache', 'icons', '_generated', icon_name) 235 | if os.path.exists(icon_path): 236 | # Copy icon 237 | icons_build_path = os.path.join('.build', 'page', 'icons') 238 | os.makedirs(icons_build_path, exist_ok=True) 239 | shutil.copyfile(icon_path, os.path.join(icons_build_path, icon_name)) 240 | output[index]['icon'] = icon_name 241 | 242 | print('page', output) 243 | return output 244 | -------------------------------------------------------------------------------- /src/homedeck/yaml/configuration.base.yml: -------------------------------------------------------------------------------- 1 | # DON'T EDIT THIS FILE. IT WILL BE RESET AFTER EACH UPDATE. 2 | # EDIT assets/configuration.yml INSTEAD 3 | brightness: 80 4 | 5 | label_style: 6 | align: bottom 7 | color: FFFFFF 8 | font: 8 9 | show_title: true 10 | size: 9 11 | weight: 80 12 | 13 | sleep: 14 | dim_brightness: 10 15 | dim_timeout: 30 16 | sleep_timeout: 300 17 | 18 | system_buttons: 19 | $page.back: 20 | position: 1 21 | button: 22 | presets: $page 23 | tap_action: 24 | action: $page.back 25 | icon: mdi:arrow-up-left 26 | $page.previous: 27 | position: 2 28 | button: 29 | presets: $page 30 | tap_action: 31 | action: $page.previous 32 | icon: mdi:chevron-left 33 | $page.next: 34 | position: 3 35 | button: 36 | presets: $page 37 | tap_action: 38 | action: $page.next 39 | icon: mdi:chevron-right 40 | 41 | presets: 42 | $default: 43 | icon_size: 60 44 | icon_color: FFFFFF 45 | icon_padding: 40 46 | icon_variant: duotone 47 | 48 | text_color: FFFFFF 49 | text_align: bottom 50 | text_size: 24 51 | text_font: Roboto-SemiBold 52 | 53 | $page: 54 | icon_padding: 10 55 | icon_border_radius: 10 56 | icon_border_width: 4 57 | icon_variant: fill 58 | icon_offset: 0 59 | icon_size: 100 60 | 61 | $page.go_to: 62 | presets: $page 63 | 64 | $light: 65 | name: "{{ 'On' if self_states() == 'on' else 'Off' }}" 66 | tap_action: 67 | action: light.toggle 68 | 69 | hold_action: 70 | action: $page.go_to 71 | data: $light 72 | 73 | icon: mdi:lightbulb-outline 74 | icon_color: FFFFFF 75 | 76 | states: 77 | 'on': 78 | icon: | 79 | {% set percentage = ((self_state_attr('brightness') or 255) / 255) * 100 %} 80 | {% set mapped_percentage = (((percentage // 10) * 10) if percentage % 10 == 0 else (((percentage // 10) + 1) * 10)) | int %} 81 | {% if mapped_percentage == 100 %} 82 | mdi:lightbulb-on 83 | {% else %} 84 | {{ 'mdi:lightbulb-on-' ~ mapped_percentage }} 85 | {% endif %} 86 | 87 | icon_color: FFEC27 88 | 89 | # Sensors 90 | $sensor: 91 | icon: mdi:eye 92 | icon_offset: -10 93 | text: "{{ self_states() ~ (self_state_attr('unit_of_measurement') or '') }}" 94 | text_align: center 95 | text_offset: 0 40 96 | 97 | $sensor.battery: 98 | icon: | 99 | {% set percentage = self_states() or 0 %} 100 | {% set mapped_percentage = (((percentage // 10) * 10) if percentage % 10 == 0 else (((percentage // 10) + 1) * 10)) | int %} 101 | {% if mapped_percentage == 100 %} 102 | mdi:battery 103 | {% elif mapped_percentage == 0 %} 104 | mdi:battery-alert-variant-outline 105 | {% else %} 106 | {{ 'mdi:battery-' ~ mapped_percentage }} 107 | {% endif %} 108 | text: "{{ self_states() ~ '%'}}" 109 | 110 | $sensor.carbon_dioxide: 111 | # name: 'Carbon Dioxide Level' 112 | icon: mdi:molecule-co2 113 | 114 | $sensor.carbon_monoxide: 115 | # name: 'Carbon Monoxide Level' 116 | icon: mdi:molecule-co 117 | 118 | $sensor.current: 119 | # name: 'Current Power' 120 | icon: mdi:current-ac 121 | 122 | $sensor.data_rate: 123 | # name: 'Data Rate' 124 | icon: mdi:transmission-tower 125 | 126 | $sensor.data_size: 127 | # name: 'Data Size' 128 | icon: mdi:database 129 | 130 | $sensor.date: 131 | # name: 'Date' 132 | icon: mdi:calendar 133 | 134 | $sensor.distance: 135 | # name: 'Distance' 136 | icon: mdi:map-marker-distance 137 | 138 | $sensor.duration: 139 | # name: 'Duration' 140 | icon: mdi:timer-outline 141 | 142 | $sensor.energy: 143 | # name: 'Energy Consumption' 144 | icon: mdi:lightning-bolt 145 | 146 | $sensor.frequency: 147 | # name: 'Frequency' 148 | icon: mdi:sine-wave 149 | 150 | $sensor.gas: 151 | # name: 'Gas Level' 152 | icon: mdi:gas-cylinder 153 | 154 | $sensor.humidity: 155 | # name: 'Humidity Level' 156 | icon: mdi:water-percent 157 | 158 | $sensor.illuminance: 159 | # name: 'Light Intensity' 160 | icon: mdi:brightness-5 161 | 162 | $sensor.moisture: 163 | # name: 'Soil Moisture' 164 | icon: mdi:water 165 | 166 | $sensor.monetary: 167 | # name: 'Monetary Value' 168 | icon: mdi:cash 169 | 170 | $sensor.temperature: 171 | # name: 'Temperature' 172 | icon: mdi:thermometer 173 | 174 | $sensor.power: 175 | # name: 'Power' 176 | icon: mdi:sine-wave 177 | 178 | $sensor.pressure: 179 | # name: 'Pressure' 180 | icon: mdi:gauge 181 | 182 | $sensor.speed: 183 | # name: 'Speed' 184 | icon: mdi:speedometer 185 | 186 | $sensor.voltage: 187 | # name: 'Voltage' 188 | icon: mdi:sine-wave 189 | 190 | $sensor.volume: 191 | # name: 'Volume' 192 | icon: mdi:cup-water 193 | 194 | $sensor.weight: 195 | # name: 'Weight' 196 | icon: mdi:scale 197 | 198 | $sensor.wind_speed: 199 | # name: 'Wind Speed' 200 | icon: mdi:weather-windy 201 | 202 | # Binary sensors 203 | $binary_sensor: 204 | icon_size: 60 205 | icon_color: FFFFFF 206 | icon_offset: -10 207 | text: "{{ self_binary_text('On', 'Off') }}" 208 | text_align: center 209 | text_offset: 0 40 210 | 211 | states: 212 | 'on': 213 | icon_color: 80FF00 214 | 215 | $binary_sensor.battery: 216 | text: "{{ self_binary_text('Alert', 'Normal') }}" 217 | icon: mdi:battery 218 | states: 219 | 'on': 220 | icon: mdi:battery-alert 221 | 222 | $binary_sensor.battery_charging: 223 | text: "{{ self_binary_text('Charging', 'Charged') }}" 224 | icon: mdi:battery 225 | states: 226 | 'on': 227 | icon: mdi:battery-charging 228 | 229 | $binary_sensor.carbon_monoxide: 230 | text: "{{ self_binary_text('Detected', 'Clear') }}" 231 | icon: mdi:smoke-detector 232 | states: 233 | 'on': 234 | icon: mdi:smoke-detector-alert 235 | 236 | $binary_sensor.cold: 237 | text: "{{ self_binary_text('Cold', 'Normal') }}" 238 | icon: mdi:thermometer 239 | states: 240 | 'on': 241 | icon: mdi:snowflake 242 | 243 | $binary_sensor.connectivity: 244 | text: "{{ self_binary_text('Connected', 'Disconnected') }}" 245 | icon: mdi:wifi-off 246 | states: 247 | 'on': 248 | icon: mdi:wifi 249 | 250 | $binary_sensor.door: 251 | text: "{{ self_binary_text('Open', 'Closed') }}" 252 | icon: mdi:door-closed 253 | states: 254 | 'on': 255 | icon: mdi:door-open 256 | 257 | $binary_sensor.garage_door: 258 | text: "{{ self_binary_text('Open', 'Closed') }}" 259 | icon: mdi:garage 260 | states: 261 | 'on': 262 | icon: mdi:garage-open 263 | 264 | $binary_sensor.gas: 265 | text: "{{ self_binary_text('Detected', 'Clear') }}" 266 | icon: mdi:gas-cylinder 267 | states: 268 | 'on': 269 | icon: mdi:gas-burner 270 | 271 | $binary_sensor.heat: 272 | text: "{{ self_binary_text('Heating', 'No Heating') }}" 273 | icon: mdi:thermometer 274 | states: 275 | 'on': 276 | icon: mdi:fire 277 | 278 | $binary_sensor.light: 279 | text: "{{ self_binary_text('On', 'Off') }}" 280 | icon: mdi:brightness-2 281 | states: 282 | 'on': 283 | icon: mdi:brightness-5 284 | 285 | $binary_sensor.lock: 286 | text: "{{ self_binary_text('Unlocked', 'Locked') }}" 287 | icon: mdi:lock 288 | states: 289 | 'on': 290 | icon: mdi:lock-open 291 | 292 | $binary_sensor.moisture: 293 | text: "{{ self_binary_text('Detected', 'Clear') }}" 294 | icon: mdi:water-check 295 | states: 296 | 'on': 297 | icon: mdi:water-alert 298 | 299 | $binary_sensor.motion: 300 | text: "{{ self_binary_text('Detected', 'Clear') }}" 301 | icon: mdi:motion-sensor-off 302 | states: 303 | 'on': 304 | icon: mdi:motion-sensor 305 | 306 | $binary_sensor.occupancy: 307 | text: "{{ self_binary_text('Occupied', 'Clear') }}" 308 | icon: mdi:home-outline 309 | states: 310 | 'on': 311 | icon: mdi:home 312 | 313 | $binary_sensor.opening: 314 | text: "{{ self_binary_text('Opening', 'Closed') }}" 315 | icon: mdi:door-closed 316 | states: 317 | 'on': 318 | icon: mdi:door-open 319 | 320 | $binary_sensor.plug: 321 | text: "{{ self_binary_text('Plugged', 'Unplugged') }}" 322 | icon: mdi:power-plug-off 323 | states: 324 | 'on': 325 | icon: mdi:power-plug 326 | 327 | $binary_sensor.power: 328 | text: "{{ self_binary_text('On', 'Off') }}" 329 | icon: mdi:power-off 330 | states: 331 | 'on': 332 | icon: mdi:power 333 | 334 | $binary_sensor.presence: 335 | text: "{{ self_binary_text('Detected', 'Clear') }}" 336 | icon: mdi:account-off 337 | states: 338 | 'on': 339 | icon: mdi:account 340 | 341 | $binary_sensor.problem: 342 | text: "{{ self_binary_text('Detected', 'Clear') }}" 343 | icon: mdi:check-circle 344 | states: 345 | 'on': 346 | icon: mdi:alert-circle 347 | 348 | $binary_sensor.running: 349 | text: "{{ self_binary_text('Running', 'Stopped') }}" 350 | icon: mdi:stop 351 | states: 352 | 'on': 353 | icon: mdi:play 354 | 355 | $binary_sensor.safety: 356 | text: "{{ self_binary_text('Issue', 'Safe') }}" 357 | icon: mdi:shield-check 358 | states: 359 | 'on': 360 | icon: mdi:alert 361 | 362 | $binary_sensor.smoke: 363 | text: "{{ self_binary_text('Detected', 'Clear') }}" 364 | icon: mdi:smoke-detector 365 | states: 366 | 'on': 367 | icon: mdi:smoke-detector-alert 368 | 369 | $binary_sensor.sound: 370 | text: "{{ self_binary_text('On', 'Off') }}" 371 | icon: mdi:volume-off 372 | states: 373 | 'on': 374 | icon: mdi:volume-high 375 | 376 | $binary_sensor.tamper: 377 | text: "{{ self_binary_text('Detected', 'Clear') }}" 378 | icon: mdi:shield-check 379 | states: 380 | 'on': 381 | icon: mdi:shield-alert 382 | 383 | $binary_sensor.update: 384 | text: "{{ self_binary_text('Available', 'None') }}" 385 | icon: mdi:package 386 | states: 387 | 'on': 388 | icon: mdi:package-up 389 | 390 | $binary_sensor.vibration: 391 | text: "{{ self_binary_text('Detected', 'Clear') }}" 392 | icon: mdi:crop-portrait 393 | states: 394 | 'on': 395 | icon: mdi:vibrate 396 | 397 | $binary_sensor.window: 398 | text: "{{ self_binary_text('Open', 'Closed') }}" 399 | icon: mdi:window-closed 400 | states: 401 | 'on': 402 | icon: mdi:window-open 403 | 404 | pages: 405 | $root: 406 | buttons: [] 407 | 408 | $light: 409 | buttons: [] 410 | -------------------------------------------------------------------------------- /src/homedeck/dataclasses.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import copy 4 | from dataclasses import dataclass, field, fields 5 | from typing import Dict, List, Optional, Tuple, Union 6 | 7 | from deepdiff import DeepDiff 8 | from strmdck.device import DeckDevice 9 | 10 | from .enums import ButtonElementAction, IconSource 11 | from .template import has_jinja_template 12 | from .utils import apply_presets, normalize_button_positions, normalize_hex_color 13 | 14 | FONTS_MAP = { 15 | 1: 'Source Han Sans SC', 16 | 2: 'FZShuSong-Z01', 17 | 3: 'DejaVu Sans', 18 | 4: 'Bareona', 19 | 5: 'Crimson Text', 20 | 6: 'Magiera', 21 | 7: 'Syke', 22 | 8: 'Roboto', 23 | } 24 | 25 | 26 | @dataclass 27 | class SleepConfig: 28 | dim_brightness: Optional[int] = field(default=1) 29 | dim_timeout: Optional[int] = field(default=0) 30 | 31 | sleep_timeout: Optional[int] = field(default=0) 32 | 33 | 34 | @dataclass 35 | class LabelStyleConfig: 36 | align: str = field(default='bottom') 37 | color: str = field(default='FFFFFF') 38 | font: int = field(default=1) 39 | show_title: bool = field(default=True) 40 | size: int = field(default=11) 41 | weight: int = field(default=80) 42 | 43 | font_name: Optional[str] = field(init=False) 44 | 45 | def __post_init__(self): 46 | self.color = normalize_hex_color(self.color) 47 | 48 | try: 49 | self.font_name = FONTS_MAP[self.font] 50 | except Exception: 51 | self.font_name = FONTS_MAP[1] 52 | 53 | 54 | @dataclass 55 | class PageButtonActionConfig: 56 | entity_id: str 57 | 58 | action: str 59 | data: Optional[object] = field(default_factory=lambda: {}) 60 | 61 | def __post_init__(self): 62 | if isinstance(self.data, dict) and self.entity_id and 'entity_id' not in self.data: 63 | self.data['entity_id'] = self.entity_id 64 | 65 | 66 | @dataclass 67 | class PageButtonConfig: 68 | entity_id: Optional[str] = None 69 | 70 | tap_action: Optional[PageButtonActionConfig] = None 71 | hold_action: Optional[PageButtonActionConfig] = None 72 | name: Optional[str] = None 73 | domain: Optional[str] = None 74 | visibility: Optional[Union[bool, str, None]] = True 75 | presets: Optional[Union[str | List[str]]] = None 76 | 77 | states: Optional[Dict[str, Dict]] = field(default_factory=lambda: {}) 78 | is_dynamic: Optional[bool] = False 79 | 80 | material_you_color: Optional[str] = field(default=None, metadata={'icon': True, 'text_icon': True}) 81 | material_you_scheme: Optional[str] = field(default=None, metadata={'icon': True, 'text_icon': True}) 82 | 83 | # Icon fields 84 | icon_variant: Optional[str] = field(default=None, metadata={'icon': True}) 85 | icon: Optional[str] = field(default=None, metadata={'icon': True}) 86 | icon_size: Optional[int] = field(default=None, metadata={'icon': True}) 87 | icon_size_mode: Optional[str] = field(default='cover', metadata={'icon': True}) 88 | icon_padding: Optional[int] = field(default=None, metadata={'icon': True}) 89 | icon_color: Optional[str] = field(default=None, metadata={'icon': True}) 90 | icon_background_color: Optional[str] = field(default=None, metadata={'icon': True}) 91 | icon_offset: Optional[Tuple[int, int]] = field(default_factory=lambda: (0, 0), metadata={'icon': True}) 92 | icon_border_radius: Optional[int] = field(default=None, metadata={'icon': True}) 93 | icon_border_width: Optional[int] = field(default=None, metadata={'icon': True}) 94 | icon_border_color: Optional[str] = field(default=None, metadata={'icon': True}) 95 | icon_brightness: Optional[int] = field(default=None, metadata={'icon': True}) 96 | 97 | max_width: Optional[int] = field(default=0, metadata={'icon': True}) 98 | max_height: Optional[int] = field(default=0, metadata={'icon': True}) 99 | 100 | text: Optional[str] = field(default=None, metadata={'text_icon': True}) 101 | text_color: Optional[str] = field(default=None, metadata={'text_icon': True}) 102 | text_align: Optional[str] = field(default=None, metadata={'text_icon': True}) 103 | text_font: Optional[str] = field(default=None, metadata={'text_icon': True}) 104 | text_size: Optional[int] = field(default=None, metadata={'text_icon': True}) 105 | text_offset: Optional[int] = field(default=None, metadata={'text_icon': True}) 106 | 107 | additional_icons: Optional[List[Dict]] = field(default_factory=lambda: []) 108 | 109 | icon_source: Optional[IconSource] = field(init=False, default=None) 110 | icon_name: Optional[str] = field(init=False, default=None) 111 | 112 | def __post_init__(self): 113 | if self.tap_action: 114 | self.tap_action = PageButtonActionConfig(entity_id=self.entity_id, **self.tap_action) 115 | 116 | if self.hold_action: 117 | self.hold_action = PageButtonActionConfig(entity_id=self.entity_id, **self.hold_action) 118 | 119 | # Normalize presets 120 | if not self.presets: 121 | self.presets = [] 122 | 123 | if self.presets and not isinstance(self.presets, list): 124 | self.presets = [self.presets] 125 | 126 | @staticmethod 127 | def transform(button: dict, *, device: 'DeckDevice', all_states: dict, presets_config={}, is_states=False): 128 | # Ignore null button 129 | if not button: 130 | return button 131 | 132 | if isinstance(button, str): 133 | if button == '$break': 134 | return button 135 | else: 136 | # Ignore unknown buttons 137 | return button 138 | 139 | if not is_states and presets_config and 'presets' not in button: 140 | default_style = None 141 | 142 | if 'tap_action' in button: 143 | action = button['tap_action']['action'] 144 | if action == ButtonElementAction.PAGE_GO_TO.value: 145 | default_style = '$page.go_to' 146 | 147 | # Set default presets 148 | default_style = default_style or '$default' 149 | button['presets'] = [default_style] 150 | 151 | entity_id = button.get('entity_id', '') 152 | domain = button.get('domain') 153 | if entity_id and not domain: 154 | domain = entity_id.split('.')[0] 155 | button['domain'] = domain 156 | 157 | # Set domain's style 158 | if domain: 159 | domain_style = f'${domain}' 160 | button['presets'].append(domain_style) 161 | 162 | if entity_id and (domain == 'binary_sensor' or domain == 'sensor'): 163 | # Add device_class to binary_sensor 164 | device_class = all_states.get(entity_id, {}).get('attributes', {}).get('device_class') 165 | if device_class: 166 | # Domain with device_class 167 | domain_style += f'.{device_class}' 168 | button['presets'].append(domain_style) 169 | 170 | if 'presets' in button: 171 | # Apply presets 172 | button = apply_presets(source=button, presets_config=presets_config) 173 | 174 | if not is_states: 175 | button.setdefault('icon_size', (device.ICON_WIDTH, device.ICON_HEIGHT)) 176 | 177 | # Dimension 178 | button.setdefault('max_width', device.ICON_WIDTH) 179 | button.setdefault('max_height', device.ICON_HEIGHT) 180 | 181 | # Visibility 182 | if 'visibility' not in button: 183 | button['visibility'] = True 184 | 185 | # Check template string 186 | button['is_dynamic'] = 'states' in button or has_jinja_template(button) 187 | 188 | # Transform states 189 | if 'states' in button: 190 | for state in button['states']: 191 | button['states'][state] = PageButtonConfig.transform(button['states'][state], device=device, all_states=all_states, presets_config=presets_config, is_states=True) 192 | 193 | if 'name' in button: 194 | button['name'] = str(button['name']) 195 | 196 | return button 197 | 198 | 199 | # Icon fields for calculating unique ID in Icon._calculate_id() 200 | ICON_FIELDS = [field.name for field in fields(PageButtonConfig) if field.metadata and field.metadata.get('icon')] 201 | TEXT_ICON_FIELDS = [field.name for field in fields(PageButtonConfig) if field.metadata and field.metadata.get('text_icon')] 202 | 203 | 204 | @dataclass(init=False) 205 | class PageConfig: 206 | id: str 207 | buttons: List[PageButtonConfig] # Input is `str` 208 | buttons_raw: Dict = None 209 | 210 | button_positions: Optional[Dict[str, Dict]] = field(default_factory=lambda: {}) 211 | 212 | def __init__(self, id: str, buttons: dict, button_positions: dict = {}): 213 | self.id = id 214 | self.buttons = [] 215 | self.button_positions = button_positions 216 | 217 | # Set `buttons` string to buttons_raw 218 | self.buttons_raw = copy.deepcopy(buttons) 219 | 220 | def post_setup(self, *, device: 'DeckDevice', main_config: MainConfig, all_states: dict, presets_config={}): 221 | # Merge button positions 222 | self.button_positions = normalize_button_positions(self.button_positions or {}) 223 | # TODO: FIX this 224 | # self.button_positions = deep_merge(main_config.button_positions, self.button_positions) 225 | 226 | # Transform button_raws 227 | for index, button in enumerate(self.buttons_raw): 228 | self.buttons_raw[index] = PageButtonConfig.transform(button, device=device, all_states=all_states, presets_config=presets_config) 229 | 230 | 231 | @dataclass 232 | class SystemButtonConfig: 233 | button: Dict 234 | position: Optional[int] = 0 235 | 236 | 237 | @dataclass 238 | class MainConfig: 239 | brightness: int = field(default=100) 240 | label_style: LabelStyleConfig = None 241 | sleep: SleepConfig = None 242 | 243 | pages: Dict[str, PageConfig] = field(default_factory=lambda: {}) 244 | presets: Dict[str, Dict] = field(default_factory=lambda: {}) 245 | 246 | system_buttons: Dict[str, Dict] = field(default_factory=lambda: {}) 247 | 248 | def __post_init__(self): 249 | if self.label_style: 250 | self.label_style = LabelStyleConfig(**self.label_style) 251 | 252 | if self.sleep: 253 | self.sleep = SleepConfig(**self.sleep) 254 | 255 | # Limit sleep.dim_brightness <= brightness 256 | self.sleep.dim_brightness = min(self.sleep.dim_brightness, self.brightness) 257 | 258 | def post_setup(self, device: DeckDevice, all_states: dict): 259 | # System buttons 260 | system_keys = list(self.system_buttons.keys()) 261 | for key in system_keys: 262 | value = self.system_buttons[key] 263 | if value.get('button'): 264 | value['button'] = PageButtonConfig.transform(value['button'], device=device, all_states=all_states, presets_config=self.presets) 265 | 266 | self.system_buttons[ButtonElementAction(key)] = SystemButtonConfig(**value) 267 | del self.system_buttons[key] 268 | 269 | # Setup pages 270 | for page_id, page_value in self.pages.items(): 271 | page_config = PageConfig(id=page_id, **page_value) 272 | page_config.post_setup(device=device, main_config=self, all_states=all_states, presets_config=self.presets) 273 | 274 | self.pages[page_id] = page_config 275 | 276 | def __eq__(self, other: MainConfig): 277 | same = self.brightness == other.brightness and self.label_style == other.label_style and self.sleep == other.sleep and not DeepDiff(self.presets, other.presets) 278 | if not same: 279 | return False 280 | 281 | # Compage pages 282 | diff = DeepDiff(self.pages, other.pages) 283 | return not diff 284 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HomeDeck 2 | 3 | > [!WARNING] 4 | > The documents are not finished yet, but the script is usable if you know how to run it. I'm writing the installation guide. 5 | 6 | A lightweight Python library to control Home Assistant using Stream Deck-like devices. It's designed to run on a less powerful Linux SBC (like Raspberry Pi Zero 2W, OrangePi Zero 2W...) with a deck connected so you can put it anywhere in the house. 7 | 8 | ### Features 9 | - ✅ Easy to use, syntax is similar to Home Assistant and CSS 10 | - 🛠️ Highly customizable with YAML configuration 11 | - 📝 Template support for advanced customization 12 | - 🧩 [Home Assistant Add-on support](https://github.com/redphx/homedeck-home-assistant-addon) 13 | 14 | ### Supported decks 15 | 16 | | Name | Features | Price | Where to buy | 17 | |-------|----------|-------|--------------| 18 | | [Ulanzi D200](https://www.ulanzi.com/products/stream-controller-d200) | 13 physical buttons, 1 info window | ~$55 | [Aliexpress](https://www.aliexpress.com/item/1005007809064199.html), [Tmall](https://detail.tmall.com/item.htm?id=835654847615) | 19 | 20 | ### Other hardwares 21 | 22 | | SBC | Price | Where to buy | 23 | |-------|-------|--------------| 24 | | Orange Pi Zero 2W 1GB (or more) | ~$16 | [Aliexpress](https://www.aliexpress.com/item/1005006016211902.html), [Taobao](https://item.taobao.com/item.htm?id=739803125913) | 25 | | Raspberry Pi 2W | | | 26 | 27 | **SD Card:** minimum 8GB 28 | 29 | ### Configuration 30 | 31 | 1. Rename a `.env.example` to `.env` and follow the instructions in the file. 32 | 2. Rename a `assets/configuration.yml.example` to `assets/configuration.yml` and start editing. 33 | 34 | > [!IMPORTANT] 35 | > Check [`configuration.base.yml`](/redphx/homedeck/blob/main/src/homedeck/yaml/configuration.base.yml) for the base configuration. You can override any of them if you want in your own configuration file. 36 | > Check [`configuration.yml.example`](/redphx/homedeck/blob/main/assets/configuration.yml.example) for working examples. 37 | 38 | 39 | | Property | Description | Default | Type | 40 | |:------------------|:------------|:----------|:-----| 41 | | `brightness` | The default brightness level of the buttons | 80 | `int` (1-100) | 42 | | `sleep` | Sleep mode configuration when inactive | | `Sleep` | 43 | | `label_style` | Label's style | | `LabelStyle` | 44 | | `system_buttons` | Setup the position of system buttons (back, previous, next) | | `Dict[String, SystemButton]` | 45 | | `presets` | Preset definitions | | `Dict[String, Preset]` | 46 | | `pages` | Define deck's layout | | `Dict[String, Page]` | 47 | 48 | #### `Sleep` 49 | 50 | | Property | Description | Default | Type | 51 | |:------------------|:------------|:----------|:-----| 52 | | `dim_brightness` | Brightness when dimming | 10 | `int` (0-100) | 53 | | `dim_timeout` | Start dimming after X second(s) | 30 | `int` (>= 1) | 54 | | `sleep_timeout` | Start dimming after X second(s) | 300 | `int` (>= 1) | 55 | 56 | #### `LabelStyle` 57 | 58 | | Property | Description | Default | Type | 59 | |:--------------|:------------|:----------|:-----| 60 | | `align` | Label's position | bottom | top/center/bottom | 61 | | `color` | Label's color | FFFFFF | `Color` | 62 | | `font` | Font's ID | 8 | `Font` (1-8) | 63 | | `show_title` | Show label or not | true | `bool` | 64 | | `size` | Font's size | 9 | `int` | 65 | | `weight` | Font's weight | 80 | `int` (unused?) | 66 | 67 | #### `Preset` 68 | 69 | A set of `Button`'s properties so you can reuse them as many times as you want. 70 | Example: 71 | ```yaml 72 | presets: 73 | red_button: 74 | icon_background_color: FF0000 75 | green_text: 76 | text_color: 00FF00 77 | text_size: 20 78 | 79 | # Later 80 | pages: 81 | $root: 82 | buttons: 83 | # This button will have a red background and green text 84 | - name: Red button 85 | presets: 86 | - red_button 87 | - green_text 88 | icon: mdi:sofa 89 | text: Couch 90 | ``` 91 | 92 | #### `Font` 93 | 94 | Value from 1 to 8. 95 | 96 | - 1: Source Han Sans SC 97 | - 2: FZShuSong-Z01 98 | - 3: DejaVu Sans 99 | - 4: Bareona 100 | - 5: Crimson Text 101 | - 6: Magiera 102 | - 7: Syke 103 | - 8: Roboto 104 | 105 | 106 | #### `Color` 107 | 108 | Hex color, can be either string or number (must be 6 characters long) 109 | 110 | Examples 111 | 112 | ```yaml 113 | FFFFFF 114 | 123456 115 | ``` 116 | 117 | For colors that start with number `0`, you must add quotes around it. Or you can add a `/` symbol at the beginning. 118 | 119 | Examples: 120 | ```yaml 121 | # Invalid 122 | 012ABC 123 | # Valid 124 | "012ABC" 125 | /012ABC 126 | # Also valid 127 | /FF0000 128 | ``` 129 | 130 | 131 | #### `SystemButton` 132 | 133 | Example 134 | ```yaml 135 | system_buttons: 136 | $page.back: 137 | position: 1 138 | button: 139 | presets: $page 140 | tap_action: 141 | action: $page.back 142 | icon: mdi:arrow-up-left 143 | $page.previous: 144 | position: 2 145 | #... 146 | $page.next: 147 | position: 3 148 | # .. 149 | ``` 150 | 151 | Available buttons: 152 | - $page.back 153 | - $page.previous 154 | - $page.next 155 | 156 | | Property | Description | Default | Type | 157 | |:--------------|:------------|:----------|:-----| 158 | | `position` | Position of the button | | `Optional[int]` | 159 | | `button` | Style of the button | | `Optional[Button]` | 160 | 161 | #### `Page` 162 | 163 | Example: 164 | ```yml 165 | pages: 166 | $root: 167 | buttons: 168 | - name: Living Room 169 | presets: room 170 | tap_action: 171 | action: $page.go_to 172 | data: living-room 173 | icon: mdi:sofa-outline 174 | icon_background_color: b9003e 175 | living-room: 176 | buttons: 177 | ... 178 | ``` 179 | 180 | Each button can take the following configuration 181 | 182 | | Property | Description | Default | Type | 183 | |:--------------|:------------|:----------|:-----| 184 | | `position` | Position of the button, starting with 1 | | `Optional[int]` | 185 | | `button` | Style of the button | | `Button` | 186 | 187 | 188 | #### `Button` 189 | 190 | Define content of the button 191 | 192 | > All properties are optional 193 | 194 | | Property | Description | Default | Type | Template support | 195 | |:--------------|:------------|:----------|:-----|:-----------------| 196 | | `presets` | Preset name or a list of preset names | | - `Preset`
- `List[Preset]` | ❌ | 197 | | `entity_id` | Device's `entity_id` in HA | | `str` | ❌ | 198 | | `domain` | Device's `domain` in HA. Leave empty = automatically detect from `entity_id` | | `str` | ❌ | 199 | | `name` | Button's label | | `str` | ✅ | 200 | | `tap_action` | Action when pressing the button | `null` | - `ButtonAction`
- `null` (do nothing) | | 201 | | `hold_action` | Action when holding the button for `0.5s` | `null` | - `ButtonAction`
- `null` (do nothing) | | 202 | | `visibility` | Controls button's visibility | `true` | - `true`/`visible`: show button's content
- `false`/`hidden`: show an empty button
- `null`/`gone`: not showing the button at all (skip it) | ✅ | 203 | | `states` | Overrides for the button appearance per entity state | | `ButtonState` | ❌ | 204 | | `icon`
`icon_variant`
`icon_size`
`icon_padding`
`icon_offset`
`icon_border_radius`
`icon_border_width`
`icon_border_color`
`icon_brightness`
`icon_color`
`icon_background_color`
`icon_size_mode`
`z_index` | Icon's properties | | `ButtonIcon` | | 205 | | `text`
`text_color`
`text_align`
`text_font`
`text_size`
`text_offset`
`z_index`
| Text icon's properties | | `ButtonTextIcon` | | 206 | | `additional_icons` | List of additional icon layers | [] | `List[ButtonIcon \| ButtonTextIcon]` | ❌ | 207 | | `states` | Overrides for the button appearance per entity state | | `ButtonState` | | 208 | 209 | #### `ButtonAction` 210 | 211 | For `tap_action` and `hold_action` properties. 212 | Defines what happens when a button is pressed or held. 213 | 214 | | Property | Description | Type | Template support | 215 | |:--------------|:------------|:-----|:-----------------| 216 | | `action` | Home Assistant's action/service name or one of HomeDeck's actions:
- `$page.go_to`
- `$page.back`
- `$page.previous`
- `$page.next` | `str` | ❌ | 217 | | `data` | Action's data, either string or object | `Optional[str \| dict]` | ❌ | 218 | 219 | How to go to a different page (`living-room` in this example): 220 | ```yaml 221 | tap_action: 222 | action: $page.go_to 223 | data: living-room 224 | ``` 225 | 226 | #### Icons 227 | 228 | 1. `ButtonIcon` 229 | 230 | | Property | Description | Default | Type | Template support | 231 | |:--------------|:------------|:----------|:-----|:-----------------| 232 | | `icon` | - `none`: no icon
- `local:`: path to the local icon file. It can be either an absolute path (e.g. `local:/icons/test.png`) or a relative path to the `assets/icons` folder (e.g. `local:test.png`)
- `url:`: URL to the external image
- `mdi:`: icon from [Material Design Icons](https://pictogrammers.com/library/mdi/), e.g. `mdi:lightbulb`
- `pi:`: icon from [Phosphor Icons](https://phosphoricons.com), e.g. `pi:lightbulb` | `none` | `str` | ✅ | 233 | | `icon_variant` | Icon's variant. Only available when using [Phosphor Icons](https://phosphoricons.com). | `regular` | - `thin`
- `light`
- `regular`
- `bold`
- `fill`
- `duotone` | ✅ | 234 | | `icon_size` | Icon's size, in pixel
-` `: set width and height, e.g. `icon_size: 100 120`
- ``: set both width and height to the same value, e.g. `icon_size: 100` is the same as `icon_size: 100 100`
- When width or height is `0`, its value will be calculated based on the image's ratio | `0` | `int`
`str` | ✅ | 235 | | `icon_padding` | Padding around the icon | `0` | `int` | ✅ | 236 | | `icon_offset` | X/Y offset position of the icon relative to the original position | | `Offset` | ✅ | 237 | | `icon_border_radius` | Radius for rounding the icon corners | `0` | `int` | ✅ | 238 | | `icon_border_width` | Width of the icon border | `0` | `int` | ✅ | 239 | | `icon_border_color` | Color of the icon border | `FFFFFF` | `Color` | ✅ | 240 | | `icon_brightness` | Brightness adjustment for the icon | `100` | `int` | ✅ | 241 | | `icon_color` | Main color of the icon | `FFFFFF` | `Color` | ✅ | 242 | | `icon_background_color` | Background color behind the icon | `null` | `Color` | ✅ | 243 | | `icon_size_mode` | How the icon fits inside its designated space | `cover` | - `cover`
- `contain`
- `stretch` | ✅ | 244 | | `z_index` | Similar to [CSS `z-index`](https://developer.mozilla.org/en-US/docs/Web/CSS/z-index). Rendering order: Highest -> lowest. | 0 | `int` | ✅ | 245 | 246 | 2. `ButtonTextIcon` 247 | 248 | | Property | Description | Default | Type | Template support | 249 | |:--------------|:------------|:----------|:-----|:-----------------| 250 | | `text` | Text to display | | `str` | ✅ | 251 | | `text_color` | Text's color | | `Color` | ✅ | 252 | | `text_align` | Vertical alignment of the text | `center` | `top`
`center`
`bottom` | ✅ | 253 | | `text_font` | Text's font. It's the name of the TTF font (without `.ttf` extension) inside the `assets/fonts` folder. | | `str` | ✅ | 254 | | `text_size` | Font size of the text | | `int` | ✅ | 255 | | `text_offset` | X/Y offset position of the text relative to the original position | | `Offset` | ✅ | 256 | | `z_index` | Similar to [CSS `z-index`](https://developer.mozilla.org/en-US/docs/Web/CSS/z-index). Rendering order: Highest -> lowest. | 0 | `int` | ✅ | 257 | 258 | #### `ButtonState` 259 | 260 | Overrides for the button appearance per entity state. It can override every properties in the `Button` type, except for `states` 261 | 262 | Example: 263 | ```yaml 264 | buttons: 265 | - entity_id: light.living_room_light 266 | name: 'Off' 267 | icon: mdi:lightbulb-outline 268 | states: 269 | 'on': 270 | name: 'On' 271 | icon: mdi:lightbulb 272 | ``` 273 | 274 | It's the same as using this template 275 | 276 | ```yaml 277 | buttons: 278 | - entity_id: light.living_room_light 279 | name: "{{ self_binary_text('On', 'Off') }}" 280 | icon: "{{ self_binary_text('mdi:lightbulb', 'mdi:lightbulb-outline') }}" 281 | ``` 282 | 283 | #### Supported template functions 284 | 285 | | Function | Description | 286 | |:--------------|:------------| 287 | | `states(entity_id)` | Get state of an entity | 288 | | `state_attr(entity_id, attribute)` | Get attribute value of an entity | 289 | | `is_state(entity_id, state)` | Check whether current state of an entity is `state` or not | 290 | | `binary_text(entity_id, on_text, off_text)` | Return `on_text` when state is `on`, and `off_text` when state is `off` | 291 | 292 | If the current button has a `entity_id` property, you can use `self_` to skip passing `entity_id` to the function altogether. 293 | 294 | Example: 295 | ```yaml 296 | buttons: 297 | - entity_id: light.living_room_light 298 | name: {{ self_states() }} 299 | ``` 300 | is the same as 301 | ```yaml 302 | buttons: 303 | - name: {{ states("light.living_room_light") }} 304 | ``` 305 | 306 | 307 | ### TODO 308 | - Docker container 309 | - Support `spotify:` icon 310 | - Support `ring:` icon 311 | - Support other decks: 312 | - [ ] MiraBox 313 | - [ ] Elgato Stream Deck 314 | -------------------------------------------------------------------------------- /src/homedeck/homedeck.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import copy 5 | import os 6 | import sys 7 | import time 8 | import traceback 9 | from dataclasses import asdict 10 | 11 | import yaml 12 | from dotenv import load_dotenv 13 | from strmdck.device import ButtonAction 14 | from strmdck.device_manager import auto_connect 15 | from watchdog.events import FileSystemEventHandler 16 | from watchdog.observers import Observer 17 | 18 | from .configuration import Configuration 19 | from .elements import InteractionType, PageElement 20 | from .enums import SleepStatus 21 | from .event_bus import EventName, event_bus 22 | from .home_assistant import HomeAssistantWebSocket 23 | from .utils import deep_merge 24 | 25 | load_dotenv() 26 | HA_HOST = os.getenv('HA_HOST') 27 | HA_ACCESS_TOKEN = os.getenv('HA_ACCESS_TOKEN') 28 | 29 | 30 | class HomeDeck: 31 | class ConfigurationFileChangeHandler(FileSystemEventHandler): 32 | def __init__(self, deck: HomeDeck): 33 | self._deck = deck 34 | self._file_path = os.path.abspath('assets/configuration.yml') 35 | self._last_modified = 0 36 | 37 | def on_modified(self, event): 38 | if event.src_path == self._file_path: 39 | # Ignore events within 1 seconds 40 | now = time.time() 41 | if now - self._last_modified >= 1: 42 | self._last_modified = now 43 | self._deck._need_reload_all = True 44 | 45 | ''' 46 | def on_created(self, event): 47 | if event.src_path == self._file_path: 48 | self._device.reload_all() 49 | 50 | def on_deleted(self, event): 51 | if event.src_path == self._file_path: 52 | raise ValueError('configuration.yml file deleted') 53 | ''' 54 | 55 | def __init__(self, vendor_id: int = 0x2207, product_id: int = 0x0019): 56 | self._vendor_id = vendor_id 57 | self._product_id = product_id 58 | 59 | self._configuration_observer = None 60 | script_dir = os.path.dirname(os.path.realpath(__file__)) 61 | with open(os.path.join(script_dir, 'yaml', 'configuration.base.yml'), 'r') as fp: 62 | self._base_configuration_dict = yaml.safe_load(fp.read()) 63 | 64 | async def connect(self, retries: int = -1): 65 | await self._setup() 66 | 67 | def reload_all(self) -> bool: 68 | if not self._ha: 69 | return False 70 | 71 | self._need_reload_all = False 72 | 73 | try: 74 | with open(os.path.join('assets', 'configuration.yml'), 'r', encoding='utf-8') as fp: 75 | configuration_dict = yaml.safe_load(fp.read()) 76 | configuration_dict = deep_merge(copy.deepcopy(self._base_configuration_dict), configuration_dict) 77 | 78 | new_configuration = Configuration(device=self._device, source_dict=configuration_dict, all_states=self._ha.all_states) 79 | 80 | if not new_configuration or not new_configuration.is_valid(): 81 | # Crash app if the configuration file is invalid on startup 82 | if not self._configuration: 83 | sys.exit(1) 84 | 85 | return 86 | 87 | # Check configuration changed 88 | print('✅ Configuration changed!') 89 | self._configuration = new_configuration 90 | self._wake_up() 91 | except Exception: 92 | traceback.print_exc() 93 | return False 94 | 95 | configuration = self._configuration 96 | # await self._write_packet(b'\x01') # Not sure what this is for 97 | self._device.set_brightness(configuration.brightness) 98 | self._device.set_label_style(asdict(configuration.label_style)) 99 | 100 | self.page_go_to('$root', 1, append_stack=True) 101 | return True 102 | 103 | async def call_ha_service(self, *, domain: str, service: str, service_data: dict): 104 | try: 105 | await self._ha.call_service(domain=domain, service=service, service_data=service_data) 106 | except Exception: 107 | pass 108 | 109 | def reload_current_page(self, *, force=False) -> bool: 110 | return self.reload_page(self._current_page_id, force=force) 111 | 112 | def force_reload_current_page(self) -> bool: 113 | return self.reload_page(self._current_page_id, force=True) 114 | 115 | def reload_page(self, page_id: str, *, force=False) -> bool: 116 | if not self._ha: 117 | return False 118 | 119 | is_sub_page = self._current_page_id != '$root' 120 | page = self._configuration.get_page_element(page_id) 121 | changed = page.render_buttons(system_buttons=self._configuration.system_buttons, page_number=self._current_page_number, is_sub_page=is_sub_page, buttons_per_page=self._device.BUTTON_COUNT, all_states=self._ha.all_states) 122 | 123 | # Don't render the same page 124 | if not force and self._current_page_element == page and not changed: 125 | return 126 | 127 | if force or self._current_page_element != page: 128 | # Update full page 129 | buttons = PageElement.generate(page.buttons) 130 | self._device.set_buttons(buttons) 131 | else: 132 | # Only update changed buttons 133 | buttons = PageElement.generate(page.changed_buttons) 134 | self._device.set_buttons(buttons, update_only=True) 135 | 136 | self._current_page_element = page 137 | return True 138 | 139 | async def _read_packets(self): 140 | button_index = None 141 | button_state = None 142 | 143 | is_holding = False 144 | hold_threshold = 0.5 145 | hold_timer = None 146 | 147 | press_index = -1 148 | press_time = 0 149 | 150 | def set_timeout(callback, delay): 151 | async def wrapper(): 152 | await asyncio.sleep(delay) 153 | await callback() 154 | 155 | return asyncio.create_task(wrapper()) 156 | 157 | async def hold_timer_callback(): 158 | nonlocal is_holding, press_time, hold_timer 159 | 160 | current_time = time.time() 161 | diff_time = current_time - press_time 162 | if diff_time >= hold_threshold: 163 | is_holding = True 164 | hold_timer = None 165 | 166 | await self._on_interacted(InteractionType.HOLD, button_index, button_state) 167 | 168 | async for command in self._device.read_packet(): 169 | if isinstance(command, ButtonAction): 170 | self._last_action_time = time.time() 171 | 172 | button_index = command.index 173 | button_state = command.state 174 | 175 | # Clear hold_timer 176 | if hold_timer: 177 | hold_timer.cancel() 178 | hold_timer = None 179 | 180 | sleep_config = self._configuration.sleep 181 | if sleep_config and self._sleep_status != SleepStatus.WAKE: 182 | if self._sleep_status == SleepStatus.DIM: 183 | self._wake_up() 184 | elif self._sleep_status == SleepStatus.SLEEP: 185 | # Only wake the device up on releasing button 186 | if not is_holding and not command.pressed: 187 | # Reload page 188 | self.force_reload_current_page() 189 | # Reload small window 190 | self._device.restore_small_window() 191 | # Wait for a bit 192 | await asyncio.sleep(0.2) 193 | # Wake up 194 | self._wake_up() 195 | 196 | # Don't accept current action 197 | is_holding = False 198 | continue 199 | 200 | if command.pressed: 201 | press_time = time.time() 202 | is_holding = False 203 | 204 | # Setup hold_timer 205 | hold_timer = set_timeout(hold_timer_callback, hold_threshold) 206 | 207 | if press_index != button_index: 208 | press_index = button_index 209 | else: 210 | if not is_holding: 211 | await self._on_interacted(InteractionType.TAP, button_index, button_state) 212 | 213 | is_holding = False 214 | 215 | async def _keep_alive(self): 216 | while True: 217 | if not self._is_ready: 218 | break 219 | 220 | await asyncio.sleep(1) 221 | # Keep alive 222 | self._device.keep_alive() 223 | 224 | # Update sleep status 225 | if self._sleep_status == SleepStatus.SLEEP or self._last_action_time <= 0: 226 | continue 227 | 228 | sleep_config = self._configuration.sleep 229 | if not sleep_config: 230 | continue 231 | 232 | diff = time.time() - self._last_action_time 233 | if sleep_config.sleep_timeout > 0 and diff > sleep_config.sleep_timeout: 234 | self._sleep() 235 | elif sleep_config.dim_timeout > 0 and self._sleep_status != SleepStatus.DIM and diff > sleep_config.dim_timeout: 236 | # Dim device 237 | self._sleep_status = SleepStatus.DIM 238 | self._device.set_brightness(sleep_config.dim_brightness) 239 | 240 | def _wake_up(self): 241 | if self._sleep_status != SleepStatus.WAKE: 242 | self._device.set_brightness(self._configuration.brightness) 243 | 244 | # Sleep device 245 | self._sleep_status = SleepStatus.WAKE 246 | self._last_action_time = time.time() 247 | 248 | def _sleep(self): 249 | # Sleep device 250 | self._device.set_brightness(0) 251 | self._sleep_status = SleepStatus.SLEEP 252 | self._last_action_time = time.time() 253 | 254 | async def _on_interacted(self, interaction: InteractionType, index: int, state: object): 255 | print('👆', interaction.value, index, state) 256 | 257 | # Small window button 258 | if index == 13: 259 | if interaction == InteractionType.TAP: 260 | self._device.set_small_window_mode(state) 261 | elif interaction == InteractionType.HOLD: 262 | # Sleep 263 | self._sleep() 264 | # Wait for a bit 265 | await asyncio.sleep(0.2) 266 | # Restore to the previous mode 267 | self._device.restore_small_window() 268 | return 269 | 270 | button = self._configuration.get_button(self._current_page_id, index) 271 | if button: 272 | await button.trigger_action(self, interaction) 273 | 274 | def _reset(self): 275 | self._is_ready = False 276 | 277 | if hasattr(self, '_device') and self._device: 278 | self._device.close() 279 | self._device = None 280 | 281 | self._ha = HomeAssistantWebSocket(HA_HOST, HA_ACCESS_TOKEN) 282 | 283 | self._current_page_element = None 284 | self._pages_stack = [] 285 | 286 | self._need_reload_all = True 287 | self._configuration = None 288 | 289 | self._sleep_status = SleepStatus.WAKE 290 | self._last_action_time = time.time() 291 | 292 | async def _setup(self): 293 | # Setup event bus 294 | event_bus.subscribe(EventName.DECK_RELOAD, self.reload_current_page) 295 | event_bus.subscribe(EventName.DECK_FORCE_RELOAD, self.force_reload_current_page) 296 | 297 | reconnect_delay = 3 298 | while True: 299 | self._reset() 300 | 301 | try: 302 | # Setup device 303 | device = None 304 | while True: 305 | try: 306 | device = auto_connect() 307 | if device: 308 | self._device = device 309 | print('Device connected') 310 | break 311 | 312 | print('Could not find any device') 313 | await asyncio.sleep(reconnect_delay) 314 | except Exception as e: 315 | try: 316 | device.close() 317 | except Exception: 318 | pass 319 | 320 | print('Could not open the device:', e) 321 | await asyncio.sleep(reconnect_delay) 322 | 323 | # Setup Home Assistant 324 | async with self._ha.connect(): 325 | await self._ha.get_all_states() 326 | self._ha.on_event('state_changed', self._ha_on_state_changed) 327 | await self._ha.subscribe_events('state_changed') 328 | 329 | self._is_ready = True 330 | await asyncio.gather( 331 | self._ha.listen(), 332 | self._read_packets(), 333 | self._keep_alive(), 334 | self._setup_hot_reload(), 335 | ) 336 | except Exception: 337 | traceback.print_exc() 338 | 339 | # Crash app if error on startup 340 | if not self._is_ready: 341 | sys.exit(1) 342 | 343 | self._is_ready = False 344 | finally: 345 | try: 346 | self._device.close() 347 | except Exception: 348 | pass 349 | 350 | try: 351 | await self._ha.disconnect() 352 | except Exception: 353 | pass 354 | 355 | await asyncio.sleep(reconnect_delay) 356 | 357 | async def _ha_on_state_changed(self, _): 358 | # Only reload page when it's not sleeping 359 | if self._sleep_status != SleepStatus.SLEEP: 360 | self.reload_current_page() 361 | 362 | async def _setup_hot_reload(self): 363 | print('Setting up hot reload') 364 | 365 | if not self._configuration_observer: 366 | event_handler = self.ConfigurationFileChangeHandler(self) 367 | observer = Observer() 368 | observer.schedule(event_handler, path='./assets', recursive=False) 369 | 370 | observer.start() 371 | self._configuration_observer = observer 372 | 373 | while True: 374 | if self._need_reload_all: 375 | self.reload_all() 376 | 377 | await asyncio.sleep(1) 378 | 379 | def page_go_to(self, page_id: str, page_number: int = 1, append_stack=True): 380 | if not self._configuration.has_page(page_id): 381 | print('Invalid page:', page_id) 382 | return 383 | 384 | if append_stack: 385 | self._pages_stack.append((page_id, page_number)) 386 | 387 | self._current_page_id = page_id 388 | self._current_page_number = page_number 389 | self.reload_current_page() 390 | 391 | def page_go_back(self): 392 | # Remove current page 393 | if self._pages_stack: 394 | self._pages_stack.pop() 395 | 396 | # Get last page 397 | target_page, page_number = self._pages_stack[-1] if self._pages_stack else ('$root', 1) 398 | print(target_page, page_number) 399 | self.page_go_to(target_page, page_number=page_number, append_stack=False) 400 | 401 | def page_go_previous(self): 402 | # Update page number in stack 403 | target_page, page_number = self._pages_stack[-1] 404 | page_number = max(1, self._current_page_number - 1) 405 | self._pages_stack[-1] = (target_page, page_number) 406 | 407 | self._current_page_number = page_number 408 | self.reload_current_page() 409 | 410 | def page_go_next(self): 411 | # Update page number in stack 412 | target_page, page_number = self._pages_stack[-1] 413 | page_number = self._current_page_number + 1 414 | self._pages_stack[-1] = (target_page, page_number) 415 | 416 | self._current_page_number = page_number 417 | self.reload_current_page() 418 | -------------------------------------------------------------------------------- /src/homedeck/yaml/configuration.schema.yml: -------------------------------------------------------------------------------- 1 | $schema: 'https://json-schema.org/draft/2020-12/schema' 2 | title: Configuration Schema 3 | description: Schema defining layout and behavior of button pages 4 | 5 | $defs: 6 | TemplateString: 7 | description: A string that supports Jinja-style templates (e.g., {{ value }} or {% if %}) 8 | type: string 9 | pattern: '({{.*?}}|{%.*?%})' 10 | 11 | IntPercentage: 12 | description: Integer value between 0 and 100 (percentages) 13 | type: integer 14 | minimum: 0 15 | maximum: 100 16 | 17 | IntOrTemplateString: 18 | description: Either a raw integer or a string supporting templates 19 | oneOf: 20 | - type: integer 21 | - $ref: '#/$defs/TemplateString' 22 | 23 | StringOrStringArray: 24 | description: Accepts either a single string or an array of strings 25 | oneOf: 26 | - type: string 27 | - type: array 28 | items: 29 | type: string 30 | 31 | StringOrObject: 32 | description: Accepts either a simple string or an object with any properties 33 | oneOf: 34 | - type: string 35 | - type: object 36 | additionalProperties: true 37 | 38 | LabelStyleAlignEnum: 39 | enum: 40 | - top 41 | - center 42 | - bottom 43 | 44 | ActionEnum: 45 | description: List of built-in button actions for navigation 46 | enum: 47 | - $page.go_to 48 | - $page.back 49 | - $page.previous 50 | - $page.next 51 | 52 | EntityId: 53 | description: Entity ID pattern, like "light.living_room" 54 | type: string 55 | pattern: '^[a-z\_]+\.[a-z0-9\_]+$' 56 | 57 | Domain: 58 | description: Domain string, like "light" or "switch" 59 | type: string 60 | pattern: '^[a-z\_]+$' 61 | 62 | MaterialYouSchemeEnum: 63 | enum: 64 | - content 65 | - expressive 66 | - fidelity 67 | - fruit-salad 68 | - monochrome 69 | - rainbow 70 | - spritz 71 | - tonal-spot 72 | - vibrant 73 | 74 | MaterialYouRoleEnum: 75 | enum: 76 | - primary 77 | - on-primary 78 | - primary-container 79 | - on-primary-container 80 | - secondary 81 | - on-secondary 82 | - secondary-container 83 | - on-secondary-container 84 | - tertiary 85 | - on-tertiary 86 | - tertiary-container 87 | - on-tertiary-container 88 | - error 89 | - on-error 90 | - error-container 91 | - on-error-container 92 | - background 93 | - on-background 94 | - surface 95 | - on-surface 96 | - surface-variant 97 | - on-surface-variant 98 | - outline 99 | - outline-variant 100 | - shadow 101 | - scrim 102 | - inverse-surface 103 | - inverse-on-surface 104 | - inverse-primary 105 | 106 | Visibility: 107 | description: Controls visibility. `hidden`/`False`` = showing an empty button. `gone`/`null` = not showing the button at all. 108 | enum: 109 | - True 110 | - False 111 | - null 112 | - 'visible' 113 | - 'hidden' 114 | - 'gone' 115 | 116 | Size: 117 | description: 'Size definition: either an integer or a string with two values like "80 70"' 118 | oneOf: 119 | - type: integer 120 | minimum: 0 121 | examples: 122 | - 80 123 | - type: string 124 | examples: 125 | - 0 80 126 | - 80 70 127 | 128 | Offset: 129 | description: 'Offset: X Y pair or templated string' 130 | oneOf: 131 | - $ref: '#/$defs/IntOrTemplateString' 132 | - type: string 133 | pattern: '^(-?[\d]+) (-?[\d]+)$' 134 | 135 | Page: 136 | title: Page layout 137 | description: Defines a page with a name and list of buttons 138 | type: object 139 | additionalProperties: false 140 | properties: 141 | name: 142 | oneOf: 143 | - type: string 144 | - type: number 145 | buttons: 146 | type: array 147 | items: 148 | oneOf: 149 | - type: 'null' 150 | - $ref: '#/$defs/ButtonWithStates' 151 | - $ref: '#/$defs/SpecialButton' 152 | button_positions: 153 | type: object 154 | properties: 155 | $page.back: 156 | oneOf: 157 | - type: 'null' 158 | - type: integer 159 | minimum: 1 160 | required: 161 | - buttons 162 | 163 | ButtonAction: 164 | title: Button action 165 | description: Defines what happens when a button is pressed or held 166 | type: object 167 | additionalProperties: false 168 | properties: 169 | action: 170 | anyOf: 171 | - $ref: '#/$defs/ActionEnum' 172 | - $ref: '#/$defs/EntityId' 173 | data: 174 | $ref: '#/$defs/StringOrObject' 175 | required: 176 | - action 177 | 178 | Color: 179 | description: Represents a color as either hex string or numeric code 180 | oneOf: 181 | - type: string 182 | pattern: '^\/?[a-fA-F0-9]{6}$' 183 | - type: integer 184 | minimum: 100000 185 | maximum: 999999 186 | examples: 187 | - C0FFEE 188 | - 123456 189 | - /123ABC 190 | 191 | SpecialButton: 192 | description: Special placeholder-style button (e.g., line break) 193 | enum: 194 | - $break 195 | 196 | ButtonIcon: 197 | type: object 198 | additionalProperties: false 199 | properties: 200 | icon: 201 | description: The main icon shown on the button 202 | oneOf: 203 | - type: string 204 | pattern: '^(local|mdi|pi):' 205 | - type: string 206 | format: uri 207 | pattern: 'https?:\/\/.*$' 208 | - $ref: '#/$defs/TemplateString' 209 | - type: 'null' 210 | icon_variant: 211 | type: string 212 | title: Icon variant 213 | description: Variant style of some icon packs, like Phosphor. 214 | examples: 215 | - bold 216 | - thin 217 | icon_size: 218 | $ref: '#/$defs/Size' 219 | description: Size of the icon 220 | icon_padding: 221 | $ref: '#/$defs/IntOrTemplateString' 222 | description: Padding around the icon inside the button 223 | icon_offset: 224 | $ref: '#/$defs/Offset' 225 | description: X/Y offset position of the icon 226 | icon_border_radius: 227 | $ref: '#/$defs/IntOrTemplateString' 228 | description: Radius for rounding the icon corners 229 | icon_border_width: 230 | $ref: '#/$defs/IntOrTemplateString' 231 | description: Width of the icon border 232 | icon_border_color: 233 | description: Color of the icon border 234 | anyOf: 235 | - $ref: '#/$defs/Color' 236 | - $ref: '#/$defs/TemplateString' 237 | icon_brightness: 238 | description: Brightness adjustment for the icon 239 | anyOf: 240 | - $ref: '#/$defs/IntPercentage' 241 | - $ref: '#/$defs/TemplateString' 242 | icon_color: 243 | description: Main color of the icon 244 | anyOf: 245 | - $ref: '#/$defs/Color' 246 | - $ref: '#/$defs/MaterialYouRoleEnum' 247 | - $ref: '#/$defs/TemplateString' 248 | icon_background_color: 249 | description: Background color behind the icon 250 | anyOf: 251 | - $ref: '#/$defs/Color' 252 | - $ref: '#/$defs/MaterialYouRoleEnum' 253 | - $ref: '#/$defs/TemplateString' 254 | icon_size_mode: 255 | description: How the icon fits inside its designated space 256 | $ref: '#/$defs/ButtonIconSizeMode' 257 | material_you_color: 258 | description: Base color for Material You 259 | anyOf: 260 | - $ref: '#/$defs/Color' 261 | - $ref: '#/$defs/TemplateString' 262 | material_you_scheme: 263 | description: Color scheme of Material You 264 | $ref: '#/$defs/MaterialYouSchemeEnum' 265 | z_index: 266 | type: integer 267 | 268 | ButtonTextIcon: 269 | type: object 270 | additionalProperties: false 271 | properties: 272 | text: 273 | title: Text to display 274 | type: string 275 | text_color: 276 | title: Text's color 277 | anyOf: 278 | - $ref: '#/$defs/Color' 279 | - $ref: '#/$defs/MaterialYouRoleEnum' 280 | - $ref: '#/$defs/TemplateString' 281 | text_align: 282 | title: Vertical alignment of the text 283 | $ref: '#/$defs/LabelStyleAlignEnum' 284 | text_font: 285 | title: Text's font. It's the name of the TTF font (without `.ttf` extension) inside the `assets/fonts` folder. 286 | type: string 287 | text_size: 288 | title: Font size of the text 289 | type: integer 290 | minimum: 1 291 | text_offset: 292 | title: Offset position of the text relative to the original position 293 | $ref: '#/$defs/Offset' 294 | material_you_color: 295 | description: Base color for Material You 296 | anyOf: 297 | - $ref: '#/$defs/Color' 298 | - $ref: '#/$defs/TemplateString' 299 | material_you_scheme: 300 | description: Color scheme of Material You 301 | $ref: '#/$defs/MaterialYouSchemeEnum' 302 | z_index: 303 | title: "Similar to \"z-index\" property from CSS. Rendering order: Highest -> lowest." 304 | type: integer 305 | 306 | ButtonIconSizeMode: 307 | enum: 308 | - cover 309 | - contain 310 | - stretch 311 | 312 | Button: 313 | type: object 314 | additionalProperties: false 315 | properties: 316 | presets: 317 | $ref: '#/$defs/StringOrStringArray' 318 | entity_id: 319 | $ref: '#/$defs/EntityId' 320 | domain: 321 | $ref: '#/$defs/Domain' 322 | name: 323 | oneOf: 324 | - type: string 325 | - type: number 326 | tap_action: 327 | oneOf: 328 | - $ref: '#/$defs/ButtonAction' 329 | - type: 'null' 330 | hold_action: 331 | oneOf: 332 | - $ref: '#/$defs/ButtonAction' 333 | - type: 'null' 334 | visibility: 335 | oneOf: 336 | - $ref: '#/$defs/Visibility' 337 | - type: string 338 | 339 | material_you_color: 340 | $ref: '#/$defs/ButtonIcon/properties/material_you_color' 341 | 342 | material_you_scheme: 343 | $ref: '#/$defs/ButtonIcon/properties/material_you_scheme' 344 | 345 | # Icon 346 | icon: 347 | $ref: '#/$defs/ButtonIcon/properties/icon' 348 | icon_variant: 349 | $ref: '#/$defs/ButtonIcon/properties/icon_variant' 350 | icon_size: 351 | $ref: '#/$defs/ButtonIcon/properties/icon_size' 352 | icon_padding: 353 | $ref: '#/$defs/ButtonIcon/properties/icon_padding' 354 | icon_offset: 355 | $ref: '#/$defs/ButtonIcon/properties/icon_offset' 356 | icon_border_radius: 357 | $ref: '#/$defs/ButtonIcon/properties/icon_border_radius' 358 | icon_border_width: 359 | $ref: '#/$defs/ButtonIcon/properties/icon_border_width' 360 | icon_border_color: 361 | $ref: '#/$defs/ButtonIcon/properties/icon_border_color' 362 | icon_brightness: 363 | $ref: '#/$defs/ButtonIcon/properties/icon_brightness' 364 | icon_color: 365 | $ref: '#/$defs/ButtonIcon/properties/icon_color' 366 | icon_background_color: 367 | $ref: '#/$defs/ButtonIcon/properties/icon_background_color' 368 | icon_size_mode: 369 | $ref: '#/$defs/ButtonIcon/properties/icon_size_mode' 370 | 371 | # Text 372 | text: 373 | $ref: '#/$defs/ButtonTextIcon/properties/text' 374 | text_color: 375 | $ref: '#/$defs/ButtonTextIcon/properties/text_color' 376 | text_align: 377 | $ref: '#/$defs/ButtonTextIcon/properties/text_align' 378 | text_font: 379 | $ref: '#/$defs/ButtonTextIcon/properties/text_font' 380 | text_size: 381 | $ref: '#/$defs/ButtonTextIcon/properties/text_size' 382 | text_offset: 383 | $ref: '#/$defs/ButtonTextIcon/properties/text_offset' 384 | 385 | additional_icons: 386 | type: array 387 | items: 388 | anyOf: 389 | - $ref: '#/$defs/ButtonIcon' 390 | - $ref: '#/$defs/ButtonTextIcon' 391 | 392 | ButtonWithStates: 393 | description: A button that supports multiple visual states based on entity state (e.g., toggle appearance when a light is on/off) 394 | type: object 395 | additionalProperties: false 396 | properties: 397 | presets: 398 | $ref: '#/$defs/StringOrStringArray' 399 | description: List of preset style names to inherit from 400 | entity_id: 401 | $ref: '#/$defs/EntityId' 402 | description: Entity ID this button controls or reflects (e.g., light.kitchen) 403 | domain: 404 | $ref: '#/$defs/Domain' 405 | description: Home Assistant domain for the entity (e.g., light, switch) 406 | name: 407 | oneOf: 408 | - type: string 409 | - type: number 410 | description: Optional label or identifier for the button 411 | tap_action: 412 | oneOf: 413 | - $ref: '#/$defs/ButtonAction' 414 | - type: 'null' 415 | description: Defines what happens when the button is tapped 416 | hold_action: 417 | oneOf: 418 | - $ref: '#/$defs/ButtonAction' 419 | - type: 'null' 420 | description: Defines what happens when the button is long-pressed 421 | visibility: 422 | oneOf: 423 | - $ref: '#/$defs/Visibility' 424 | - type: string 425 | description: Controls whether the button is shown, hidden, or gone 426 | 427 | material_you_color: 428 | description: Base color for Material You 429 | anyOf: 430 | - $ref: '#/$defs/Color' 431 | - $ref: '#/$defs/TemplateString' 432 | material_you_scheme: 433 | description: Color scheme of Material You 434 | $ref: '#/$defs/MaterialYouSchemeEnum' 435 | 436 | icon: 437 | $ref: '#/$defs/ButtonIcon/properties/icon' 438 | icon_variant: 439 | $ref: '#/$defs/ButtonIcon/properties/icon_variant' 440 | icon_size: 441 | $ref: '#/$defs/ButtonIcon/properties/icon_size' 442 | icon_padding: 443 | $ref: '#/$defs/ButtonIcon/properties/icon_padding' 444 | icon_offset: 445 | $ref: '#/$defs/ButtonIcon/properties/icon_offset' 446 | icon_border_radius: 447 | $ref: '#/$defs/ButtonIcon/properties/icon_border_radius' 448 | icon_border_width: 449 | $ref: '#/$defs/ButtonIcon/properties/icon_border_width' 450 | icon_border_color: 451 | $ref: '#/$defs/ButtonIcon/properties/icon_border_color' 452 | icon_brightness: 453 | $ref: '#/$defs/ButtonIcon/properties/icon_brightness' 454 | icon_color: 455 | $ref: '#/$defs/ButtonIcon/properties/icon_color' 456 | icon_background_color: 457 | $ref: '#/$defs/ButtonIcon/properties/icon_background_color' 458 | icon_size_mode: 459 | $ref: '#/$defs/ButtonIcon/properties/icon_size_mode' 460 | 461 | text: 462 | $ref: '#/$defs/ButtonTextIcon/properties/text' 463 | text_color: 464 | $ref: '#/$defs/ButtonTextIcon/properties/text_color' 465 | text_align: 466 | $ref: '#/$defs/ButtonTextIcon/properties/text_align' 467 | text_font: 468 | $ref: '#/$defs/ButtonTextIcon/properties/text_font' 469 | text_size: 470 | $ref: '#/$defs/ButtonTextIcon/properties/text_size' 471 | text_offset: 472 | $ref: '#/$defs/ButtonTextIcon/properties/text_offset' 473 | 474 | additional_icons: 475 | type: array 476 | items: 477 | anyOf: 478 | - $ref: '#/$defs/ButtonIcon' 479 | - $ref: '#/$defs/ButtonTextIcon' 480 | 481 | states: 482 | type: object 483 | title: Overrides for the button appearance per entity state 484 | additionalProperties: 485 | $ref: '#/$defs/Button' 486 | 487 | type: object 488 | additionalProperties: false 489 | properties: 490 | brightness: 491 | type: integer 492 | minimum: 1 493 | maximum: 100 494 | title: Initial button brightness level (1-100) 495 | examples: 496 | - 90 497 | 498 | sleep: 499 | title: Sleep mode configuration when inactive 500 | examples: 501 | - dim_brightness: 20 502 | dim_timeout: 30 503 | sleep_timeout: 300 504 | oneOf: 505 | - type: 'null' 506 | - type: object 507 | additionalProperties: false 508 | properties: 509 | dim_brightness: 510 | $ref: '#/$defs/IntPercentage' 511 | title: Brightness level when the device is idle before sleep 512 | dim_timeout: 513 | type: integer 514 | minimum: 1 515 | title: Number of seconds before dimming the display 516 | sleep_timeout: 517 | type: integer 518 | minimum: 1 519 | title: Number of seconds before the display turns off 520 | required: 521 | - dim_brightness 522 | - dim_timeout 523 | - sleep_timeout 524 | 525 | label_style: 526 | type: object 527 | additionalProperties: false 528 | title: Global text styling for all button labels and titles 529 | properties: 530 | align: 531 | $ref: '#/$defs/LabelStyleAlignEnum' 532 | description: Aligns the label vertically on buttons 533 | color: 534 | $ref: '#/$defs/Color' 535 | title: Default label color 536 | font: 537 | type: integer 538 | title: Font index or ID 539 | show_title: 540 | type: boolean 541 | title: Toggle the display of button labels 542 | size: 543 | type: integer 544 | title: Font size for button labels 545 | weight: 546 | type: integer 547 | title: Font weight (unused?) 548 | required: 549 | - align 550 | - color 551 | - font 552 | - show_title 553 | - size 554 | - weight 555 | 556 | presets: 557 | type: object 558 | additionalProperties: false 559 | title: Preset button templates to reduce duplication 560 | properties: 561 | $default: 562 | $ref: '#/$defs/ButtonWithStates' 563 | $page: 564 | $ref: '#/$defs/ButtonWithStates' 565 | patternProperties: 566 | '^[$a-z0-9\_\-\.]+$': 567 | $ref: '#/$defs/ButtonWithStates' 568 | 569 | system_buttons: 570 | description: Defines special buttons like back/previous/next that navigate between pages 571 | type: object 572 | propertyNames: 573 | $ref: '#/$defs/ActionEnum' 574 | additionalProperties: 575 | type: object 576 | additionalProperties: false 577 | properties: 578 | position: 579 | type: integer 580 | minimum: 0 581 | description: Slot number where the system button appears on the screen 582 | button: 583 | $ref: '#/$defs/Button' 584 | description: Button configuration for the system-level button 585 | examples: 586 | - '\$page.back': 587 | position: 1 588 | '\$page.previous': 589 | position: 2 590 | '\$page.next': 591 | position: 3 592 | 593 | pages: 594 | type: object 595 | additionalProperties: false 596 | description: Defines each page layout 597 | properties: 598 | $root: 599 | $ref: '#/$defs/Page' 600 | patternProperties: 601 | '^[$a-z0-9\_\-\.]+$': 602 | $ref: '#/$defs/Page' 603 | required: 604 | - $root 605 | -------------------------------------------------------------------------------- /src/homedeck/icons.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import os 4 | import shutil 5 | from abc import ABC, abstractmethod 6 | from dataclasses import fields 7 | from typing import Dict, List, Tuple, Union 8 | 9 | import cairosvg 10 | import httpx 11 | from PIL import Image, ImageDraw, ImageEnhance, ImageFont 12 | 13 | from .dataclasses import ICON_FIELDS, TEXT_ICON_FIELDS, PageButtonConfig 14 | from .enums import IconSource, MaterialYouScheme, PhosphorIconVariant 15 | from .event_bus import EventName, event_bus 16 | from .utils import ( 17 | generate_material_you_palette, 18 | hex_to_rgb, 19 | normalize_hex_color, 20 | normalize_tuple, 21 | optimize_image, 22 | ) 23 | 24 | logging.basicConfig(level=logging.INFO) 25 | 26 | ENV_ENABLE_CACHE = int(os.getenv('ENABLE_CACHE', 1)) != 0 27 | CACHE_ICONS_DIR = os.path.join('.cache', 'icons') 28 | CACHE_GENERATED_DIR = os.path.join(CACHE_ICONS_DIR, '_generated') 29 | # Remove _generated directory when the script starts 30 | if os.path.exists(CACHE_GENERATED_DIR): 31 | shutil.rmtree(CACHE_GENERATED_DIR) 32 | 33 | 34 | class Icon: 35 | def __init__(self, max_width: int, max_height: int, layers: List[Dict]): 36 | icon_img = Image.new('RGBA', (max_width, max_height), (0, 0, 0, 0)) 37 | 38 | # Sort layers by "z_index" 39 | layers = sorted(layers, key=lambda val: (val.get('z_index', 0)), reverse=False) 40 | 41 | self._icon_layers: List[IconLayer] = [] 42 | for layer in layers: 43 | if not layer: 44 | continue 45 | 46 | layer['max_width'] = max_width 47 | layer['max_height'] = max_height 48 | 49 | # Material You 50 | material_you_palette = None 51 | material_you_color = normalize_hex_color(layer.get('material_you_color')) 52 | if material_you_color: 53 | material_you_palette = generate_material_you_palette(layer.get('material_you_scheme'), material_you_color) 54 | 55 | self._normalize_icon(layer, material_you_palette=material_you_palette) 56 | 57 | icon = None 58 | icon_source = layer['icon_source'] 59 | if icon_source == IconSource.MATERIAL_DESIGN: 60 | icon = MaterialDesignIconLayer(layer) 61 | elif icon_source == IconSource.PHOSPHOR: 62 | icon = PhosphorIconLayer(layer) 63 | elif icon_source == IconSource.TEXT: 64 | icon = TextIconLayer(layer) 65 | elif icon_source == IconSource.URL: 66 | icon = UrlIconLayer(layer) 67 | elif icon_source == IconSource.BLANK: 68 | icon = LocalIconLayer(layer, file_path=None) 69 | elif icon_source == IconSource.LOCAL: 70 | file_path = layer['icon_name'] 71 | layer['icon_name'] = os.path.basename(file_path) 72 | icon = LocalIconLayer(layer, file_path=file_path) 73 | 74 | if not icon: 75 | continue 76 | 77 | if isinstance(icon, RemoteIconLayer) and icon.is_available(): 78 | icon = LocalIconLayer(layer, icon.original_file_path) 79 | 80 | self._icon_layers.append(icon) 81 | 82 | os.makedirs(CACHE_GENERATED_DIR, exist_ok=True) 83 | self._generated_path = os.path.join(CACHE_GENERATED_DIR, self.generated_filename()) 84 | 85 | if not os.path.exists(self._generated_path): 86 | for icon in self._icon_layers: 87 | layer_img = icon.get_image() 88 | if layer_img: 89 | icon_img.paste(layer_img, (0, 0), layer_img) 90 | 91 | # Save image 92 | icon_img.save(self._generated_path, 'PNG') 93 | 94 | # Optimize image 95 | optimize_image(self._generated_path, optimize_level=5) 96 | 97 | def _normalize_icon(self, icon: dict, material_you_palette=None): 98 | icon['icon_source'] = IconSource.BLANK 99 | if icon.get('icon'): 100 | # Set `icon_name` from `icon` 101 | try: 102 | source, name = icon['icon'].split(':', 1) 103 | icon['icon_name'] = name 104 | icon['icon_source'] = IconSource(source) 105 | except Exception: 106 | icon['icon_source'] = IconSource.BLANK 107 | elif icon.get('text'): 108 | icon['icon_source'] = IconSource.TEXT 109 | icon['icon_name'] = '' 110 | 111 | icon.setdefault('text_align', 'center') 112 | icon.setdefault('text_font', 'Roboto-SemiBold') 113 | icon.setdefault('text_size', 20) 114 | icon.setdefault('text_offset', (0, 0)) 115 | 116 | if material_you_palette: 117 | icon.setdefault('text_color', 'on-primary-container') 118 | if icon['text_color'] in material_you_palette: 119 | icon['text_color'] = material_you_palette[icon['text_color']] 120 | else: 121 | icon.setdefault('text_color', 'FFFFFF') 122 | 123 | icon['text_offset'] = normalize_tuple(icon['text_offset']) 124 | icon['text_color'] = normalize_hex_color(icon['text_color']) 125 | 126 | icon.setdefault('icon_source', IconSource.BLANK) 127 | 128 | # Set default properties 129 | if icon['icon_source'] != IconSource.TEXT: 130 | icon.setdefault('icon_variant', None) 131 | icon.setdefault('icon_padding', 0) 132 | icon.setdefault('icon_offset', (0, 0)) 133 | icon.setdefault('icon_border_radius', 0) 134 | icon.setdefault('icon_border_width', 0) 135 | icon.setdefault('icon_brightness', None) 136 | 137 | icon.setdefault('icon_color', 'FFFFFF') 138 | icon.setdefault('icon_background_color', None) 139 | icon.setdefault('icon_border_color', None) 140 | 141 | if material_you_palette: 142 | if icon['icon_color'] in material_you_palette: 143 | icon['icon_color'] = material_you_palette[icon['icon_color']] 144 | 145 | if icon['icon_background_color'] in material_you_palette: 146 | icon['icon_background_color'] = material_you_palette[icon['icon_background_color']] 147 | 148 | if icon['icon_border_color'] in material_you_palette: 149 | icon['icon_border_color'] = material_you_palette[icon['icon_border_color']] 150 | 151 | icon['icon_color'] = normalize_hex_color(icon['icon_color']) 152 | icon['icon_background_color'] = normalize_hex_color(icon['icon_background_color']) 153 | icon['icon_border_color'] = normalize_hex_color(icon['icon_border_color'] or icon['icon_color'] or icon['icon_background_color'] or 'FFFFFF') 154 | 155 | icon.setdefault('icon_size', (icon['max_width'], icon['max_height'])) 156 | icon['icon_size'] = normalize_tuple(icon['icon_size']) 157 | if icon['icon_size'][0] == 0: 158 | icon['icon_size'] = (icon['max_width'], icon['icon_size'][1]) 159 | if icon['icon_size'][1] == 0: 160 | icon['icon_size'] = (icon['icon_size'][0], icon['max_height']) 161 | 162 | icon['icon_offset'] = normalize_tuple(icon['icon_offset']) 163 | 164 | def generated_filename(self): 165 | return f'test-{hash(tuple(self._icon_layers))}.png' 166 | 167 | 168 | class IconLayer(ABC): 169 | def __init__(self, icon: dict, file_path: str = None): 170 | self._is_generated = False 171 | self._icon = icon 172 | self._hash = None 173 | 174 | # Set default name 175 | if not hasattr(self, '_name'): 176 | self._name = icon.get('icon_name', '') 177 | 178 | self._original_file_path = file_path 179 | if file_path: 180 | os.makedirs(os.path.dirname(file_path), exist_ok=True) 181 | 182 | os.makedirs(CACHE_GENERATED_DIR, exist_ok=True) 183 | self._generated_path = os.path.join(CACHE_GENERATED_DIR, self.generated_filename()) 184 | 185 | def is_available(self) -> bool: 186 | # Blank icon 187 | if self._original_file_path is None: 188 | return True 189 | 190 | return os.path.exists(self._original_file_path) 191 | 192 | def __hash__(self): 193 | if not self._hash: 194 | icon_fields = list(self._icon.keys()) 195 | sorted_fields = {key: self._icon[key] for key in icon_fields} 196 | joined = '-'.join([f'{key}{str(value).upper()}' for key, value in sorted_fields.items()]) 197 | self._hash = hash('-'.join([self._icon['icon_source'].value, self._name, joined])) 198 | 199 | return self._hash * (1 if self.is_available() else -1) 200 | 201 | def generated_filename(self) -> str: 202 | return f'{self._icon["icon_source"].value}-{self._name}-{self.__hash__()}.png' 203 | 204 | @property 205 | def original_file_path(self): 206 | return self._original_file_path 207 | 208 | @property 209 | def id(self): 210 | return self.__hash__() 211 | 212 | def get_image(self): 213 | if self._is_generated or ENV_ENABLE_CACHE and os.path.exists(self._generated_path): 214 | try: 215 | return Image.open(self._generated_path).convert('RGBA') 216 | except Exception: 217 | return None 218 | 219 | self._is_generated = True 220 | return self.rasterize() 221 | 222 | @abstractmethod 223 | def rasterize(self): 224 | pass 225 | 226 | 227 | class TextIconLayer(IconLayer): 228 | def is_available(self): 229 | return True 230 | 231 | def rasterize(self): 232 | icon_styles = self._icon 233 | 234 | img = Image.new('RGBA', (icon_styles['max_width'], icon_styles['max_height']), (0, 0, 0, 0)) 235 | img = IconEditor.draw_texts(img, text=icon_styles['text'], color=icon_styles['text_color'], align=icon_styles['text_align'], font=icon_styles['text_font'], size=icon_styles['text_size'], offset=icon_styles['text_offset']) 236 | 237 | # Save image 238 | img.save(self._generated_path, 'PNG') 239 | 240 | # Optimize image 241 | optimize_image(self._generated_path, optimize_level=5) 242 | 243 | return img 244 | 245 | 246 | class LocalIconLayer(IconLayer): 247 | def __init__(self, icon: dict, file_path: str = None): 248 | super().__init__(icon, file_path=file_path) 249 | 250 | def rasterize(self): 251 | icon_styles = self._icon 252 | icon_width, icon_height = icon_styles['icon_size'] 253 | 254 | button_width = icon_styles['max_width'] 255 | button_height = icon_styles['max_height'] 256 | 257 | if self._original_file_path: 258 | # SVG to PNG 259 | is_svg = self._original_file_path.endswith('svg') 260 | if is_svg: 261 | tmp_file = os.path.join(CACHE_ICONS_DIR, f'.tmp-{self.generated_filename()}') 262 | cairosvg.svg2png(url=self._original_file_path, write_to=tmp_file, output_width=icon_width, output_height=icon_height) 263 | img = Image.open(tmp_file) 264 | os.remove(tmp_file) 265 | 266 | # Apply color overlay 267 | img = IconEditor.apply_color(img, icon_styles['icon_color']) 268 | else: 269 | img = Image.open(self.original_file_path).convert('RGBA') 270 | img = IconEditor.resize(img, icon_styles['icon_size_mode'], icon_styles['icon_size']) 271 | else: 272 | # Blank icon 273 | img = Image.new('RGBA', (icon_width, icon_height), 0) 274 | 275 | # Apply icon's padding 276 | img = IconEditor.apply_padding(img, icon_styles['icon_padding']) 277 | 278 | # Apply icon's background color 279 | img = IconEditor.apply_background_color(img, icon_styles['icon_background_color']) 280 | 281 | # Icon's border 282 | img = IconEditor.apply_border(img, width=icon_styles['icon_border_width'], color=icon_styles['icon_border_color'], radius=icon_styles['icon_border_radius']) 283 | 284 | # Shift icon 285 | img = IconEditor.move(img, icon_styles['icon_offset']) 286 | 287 | # Adjust brightness 288 | img = IconEditor.adjust_brightness(img, icon_styles['icon_brightness']) 289 | 290 | # Crop 291 | img = IconEditor.crop(img, width=button_width, height=button_height) 292 | 293 | # Save image 294 | img.save(self._generated_path, 'PNG') 295 | 296 | # Optimize image 297 | optimize_image(self._generated_path, optimize_level=5) 298 | 299 | return img 300 | 301 | 302 | class RemoteIconLayer(IconLayer): 303 | @property 304 | @abstractmethod 305 | def download_url(self) -> str: 306 | pass 307 | 308 | def rasterize(self): 309 | if not self.is_available(): 310 | # Download icon 311 | icon_provider._request_icon(self) 312 | return None 313 | 314 | return None 315 | 316 | 317 | class UrlIconLayer(RemoteIconLayer): 318 | def __init__(self, icon: dict): 319 | self._url = icon['icon_name'] 320 | icon['icon_name'] = f'{hash(self._url):0x}' 321 | self._name = icon['icon_name'] 322 | 323 | file_path = os.path.join(CACHE_ICONS_DIR, icon['icon_source'].value, f'{self._name}.png') 324 | super().__init__(icon, file_path=file_path) 325 | 326 | @property 327 | def download_url(self): 328 | return self._url 329 | 330 | 331 | class RemoteSvgIconLayer(RemoteIconLayer): 332 | def __init__(self, icon: dict): 333 | file_path = os.path.join(CACHE_ICONS_DIR, icon['icon_source'].value, f'{icon["icon_name"]}.svg') 334 | super().__init__(icon, file_path=file_path) 335 | 336 | 337 | class MaterialDesignIconLayer(RemoteSvgIconLayer): 338 | @property 339 | def download_url(self): 340 | return f'https://raw.githubusercontent.com/Templarian/MaterialDesign/refs/heads/master/svg/{self._name}.svg' 341 | 342 | 343 | class PhosphorIconLayer(RemoteSvgIconLayer): 344 | def __init__(self, icon: dict): 345 | name = icon['icon_name'] 346 | if not icon['icon_variant']: 347 | icon['icon_variant'] = PhosphorIconVariant.REGULAR 348 | 349 | # Append variant to icon's name 350 | if icon['icon_variant'] != PhosphorIconVariant.REGULAR: 351 | name += '-' + icon['icon_variant'] 352 | 353 | icon['icon_name'] = name 354 | super().__init__(icon) 355 | 356 | @property 357 | def download_url(self): 358 | return f'https://raw.githubusercontent.com/phosphor-icons/core/refs/heads/main/raw/{self._icon["icon_variant"]}/{self._name}.svg' 359 | 360 | 361 | class IconProvider: 362 | def __init__(self): 363 | self._queue = asyncio.Queue() 364 | self._requested = set() 365 | 366 | async def _create_download_task(self, icon: dict): 367 | self._requested.add(icon.download_url) 368 | await self._queue.put(icon) 369 | await self._worker() 370 | 371 | def _request_icon(self, icon: dict): 372 | if icon.download_url in self._requested: 373 | return 374 | 375 | # Start downloading 376 | loop = asyncio.get_running_loop() 377 | loop.create_task(self._create_download_task(icon)) 378 | 379 | def get_icon(self, button_config: PageButtonConfig) -> Union[IconLayer, None]: 380 | # Extract main icon's fields from PageButtoConfig 381 | main_icon = {} 382 | main_text_icon = {} 383 | 384 | button_fields = [field.name for field in fields(button_config)] 385 | for key in button_fields: 386 | value = getattr(button_config, key) 387 | # Don't set None values so we could set the default values later 388 | if value is None: 389 | continue 390 | 391 | if key in ICON_FIELDS: 392 | main_icon[key] = value 393 | elif key in TEXT_ICON_FIELDS: 394 | main_text_icon[key] = value 395 | 396 | layers = [] 397 | if main_icon: 398 | layers.append(main_icon) 399 | if main_text_icon: 400 | layers.append(main_text_icon) 401 | 402 | additional_icons = button_config.additional_icons or [] 403 | if additional_icons: 404 | layers += additional_icons 405 | 406 | if layers: 407 | return Icon(button_config.max_width, button_config.max_height, layers) 408 | 409 | return None 410 | 411 | async def _worker(self): 412 | """Worker task that processes the queue.""" 413 | icon: IconLayer = await self._queue.get() 414 | if icon.is_available(): 415 | self._queue.task_done() 416 | return 417 | 418 | try: 419 | if isinstance(icon, RemoteIconLayer): 420 | async with httpx.AsyncClient() as client: 421 | url = icon.download_url 422 | logging.info(f'Downloading icon: {url}') 423 | 424 | response = await client.get(url, timeout=5) 425 | if response.status_code == 200: 426 | with open(icon.original_file_path, 'wb') as fp: 427 | fp.write(response.content) 428 | 429 | # Reload deck 430 | await event_bus.publish(EventName.DECK_FORCE_RELOAD) 431 | finally: 432 | if icon.id in self._requested: 433 | self._requested.remove(icon.id) 434 | self._queue.task_done() 435 | 436 | 437 | class IconEditor: 438 | _cached_fonts = {} 439 | 440 | @staticmethod 441 | def apply_color(img: Image, color: str) -> Image: 442 | if not color: 443 | return img 444 | 445 | color = hex_to_rgb(color) 446 | data = img.getdata() 447 | new_data = [ 448 | (color[0], color[1], color[2], pixel[3]) if pixel[3] > 0 else (0, 0, 0, 0) 449 | for pixel in data 450 | ] 451 | img.putdata(new_data) 452 | 453 | return img 454 | 455 | @staticmethod 456 | def apply_background_color(img: Image, color: str) -> Image: 457 | if not color: 458 | color = '000000' 459 | alpha = 0 460 | else: 461 | alpha = 255 462 | 463 | bg_color = Image.new('RGBA', img.size, hex_to_rgb(color, alpha=alpha)) 464 | bg_color.paste(img, (0, 0), img) 465 | img = bg_color 466 | 467 | return img 468 | 469 | @staticmethod 470 | def apply_padding(img: Image, padding: int) -> Image: 471 | if not padding or padding <= 0: 472 | return img 473 | 474 | new_size = (img.width + 2 * padding, img.height + 2 * padding) 475 | padded_img = Image.new('RGBA', new_size, (0, 0, 0, 0)) 476 | 477 | # Paste original image onto the center of the new canvas 478 | padded_img.paste(img, (padding, padding)) 479 | return padded_img 480 | 481 | @staticmethod 482 | def move(img: Image, offset: Tuple[int, int]): 483 | if offset[0] == 0 and offset[1] == 0: 484 | return img 485 | 486 | width, height = img.size 487 | new_width = width + abs(offset[0]) 488 | new_height = height + abs(offset[1]) 489 | 490 | # Create new image with padding and paste the original image 491 | new_image = Image.new('RGBA', (new_width, new_height), (0, 0, 0, 0)) 492 | 493 | x_position = 0 if offset[0] < 0 else offset[0] 494 | y_position = 0 if offset[1] < 0 else offset[1] 495 | new_image.paste(img, (x_position, y_position)) 496 | 497 | return new_image 498 | 499 | @staticmethod 500 | def apply_border(img, *, width: int, color: str, radius: int) -> Image: 501 | # Border width & color 502 | if width is not None and color is not None: 503 | # Add padding with the size of border 504 | img = IconEditor.apply_padding(img, padding=width) 505 | 506 | # Create an out mask and an in mask 507 | border_mask = Image.new('L', img.size, 0) 508 | draw = ImageDraw.Draw(border_mask) 509 | draw.rounded_rectangle( 510 | [0, 0, img.width - 1, img.height - 1], radius=radius, fill=255 511 | ) 512 | 513 | mask = Image.new('L', img.size, 0) 514 | draw = ImageDraw.Draw(mask) 515 | draw.rounded_rectangle( 516 | [ 517 | width, 518 | width, 519 | img.width - width - 1, 520 | img.height - width - 1, 521 | ], 522 | radius=max(0, radius - width), 523 | fill=255, 524 | ) 525 | 526 | border_image = Image.new('RGBA', img.size, color=hex_to_rgb(color, alpha=255)) 527 | new_image = Image.new('RGBA', img.size, color=0) 528 | # Add the border by pasting the border images onto the new image 529 | new_image.paste(border_image, (0, 0), mask=border_mask) 530 | new_image.paste(img, mask=mask) 531 | 532 | img = new_image 533 | 534 | # Border radius 535 | if radius is not None: 536 | border_mask = Image.new('L', img.size, 0) 537 | mask_draw = ImageDraw.Draw(border_mask) 538 | mask_draw.rounded_rectangle((0, 0, img.width, img.height), radius, fill=255) 539 | 540 | img.paste(img, mask=border_mask) 541 | 542 | return img 543 | 544 | @staticmethod 545 | def adjust_brightness(img: Image, brightness: int): 546 | if not brightness or brightness > 100 or brightness < 0: 547 | return img 548 | 549 | # Create an ImageEnhance object for brightness 550 | enhancer = ImageEnhance.Brightness(img) 551 | 552 | # Dim the image by reducing brightness (factor < 1 dims the image) 553 | return enhancer.enhance(brightness / 100) 554 | 555 | @staticmethod 556 | def draw_texts(img: Image, *, text: str, color: str, align: str, font: str, size: int, offset: int): 557 | if not text or not color or not font or not size: 558 | return img 559 | 560 | font_key = f'{font}-{size}' 561 | if font_key not in IconEditor._cached_fonts: 562 | font = ImageFont.truetype(f'assets/fonts/{font}.ttf', size) 563 | IconEditor._cached_fonts[font_key] = font 564 | else: 565 | font = IconEditor._cached_fonts[font_key] 566 | 567 | draw = ImageDraw.Draw(img) 568 | 569 | text_width, text_height = draw.textbbox((0, 0), text, font=font)[2:] 570 | 571 | x = (img.width - text_width) // 2 572 | if align == 'top': 573 | y = 0 574 | elif align == 'center': 575 | y = (img.height - text_height) // 2 576 | else: 577 | y = img.height - text_height 578 | 579 | # Adjust offsets 580 | x += offset[0] 581 | y += offset[1] 582 | 583 | draw.text((x, y), text, font=font, fill=hex_to_rgb(color)) 584 | 585 | return img 586 | 587 | @staticmethod 588 | def crop(img: Image, width: int, height: int) -> Image: 589 | new_img = Image.new('RGBA', (width, height), (0, 0, 0, 0)) 590 | new_img.paste(img, ((width - img.width) // 2, (height - img.height) // 2)) 591 | return new_img 592 | 593 | @staticmethod 594 | def resize(img: Image, mode: str, size: Tuple[int, int]) -> Image: 595 | icon_width, icon_height = size 596 | img_aspect = img.width / img.height 597 | target_aspect = icon_width / icon_height 598 | 599 | if mode == 'cover': 600 | # Scale image to cover entire box while keeping aspect ratio 601 | if img_aspect > target_aspect: 602 | new_height = icon_height 603 | new_width = int(icon_height * img_aspect) 604 | else: 605 | new_width = icon_width 606 | new_height = int(icon_width / img_aspect) 607 | 608 | # Resize 609 | img = img.resize((new_width, new_height), Image.LANCZOS) 610 | 611 | # Crop 612 | img = IconEditor.crop(img, width=icon_width, height=icon_height) 613 | elif mode == 'contain': 614 | img.thumbnail(size, Image.LANCZOS) 615 | new_img = Image.new('RGBA', size, (0, 0, 0, 0)) 616 | x_offset = (icon_width - img.width) // 2 617 | y_offset = (icon_height - img.height) // 2 618 | new_img.paste(img, (x_offset, y_offset)) 619 | img = new_img 620 | elif mode == 'stretch': 621 | img = img.resize(size, Image.LANCZOS) 622 | 623 | return img 624 | 625 | 626 | icon_provider = IconProvider() 627 | --------------------------------------------------------------------------------