├── .github └── workflows │ ├── docker-publish.yml │ ├── python-publish.yml │ └── rpi-image.yml ├── .gitignore ├── .vscode └── settings.json ├── Dockerfile ├── LICENSE ├── README.md ├── setup.py ├── streamdeckapi ├── __init__.py ├── api.py ├── const.py ├── server.py ├── tools.py └── types.py ├── test.py └── test.sh /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | on: 4 | release: 5 | types: [published] 6 | workflow_dispatch: 7 | 8 | env: 9 | # Use docker.io for Docker Hub if empty 10 | REGISTRY: ghcr.io 11 | # github.repository as / 12 | IMAGE_NAME: ${{ github.repository }} 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | permissions: 18 | contents: read 19 | packages: write 20 | steps: 21 | - name: Checkout repository 22 | uses: actions/checkout@v2 23 | 24 | # Login against a Docker registry except on PR 25 | # https://github.com/docker/login-action 26 | - name: Log into registry ${{ env.REGISTRY }} 27 | if: github.event_name != 'pull_request' 28 | uses: docker/login-action@28218f9b04b4f3f62068d7b6ce6ca5b26e35336c 29 | with: 30 | registry: ${{ env.REGISTRY }} 31 | username: ${{ github.actor }} 32 | password: ${{ secrets.GITHUB_TOKEN }} 33 | 34 | # Extract metadata (tags, labels) for Docker 35 | # https://github.com/docker/metadata-action 36 | - name: Extract Docker metadata 37 | id: meta 38 | uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 39 | with: 40 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 41 | 42 | # Build and push Docker image with Buildx (don't push on PR) 43 | # https://github.com/docker/build-push-action 44 | - name: Build and push Docker image 45 | uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc 46 | with: 47 | context: . 48 | push: ${{ github.event_name != 'pull_request' }} 49 | tags: ${{ steps.meta.outputs.tags }} 50 | labels: ${{ steps.meta.outputs.labels }} 51 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | on: 12 | release: 13 | types: [published] 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | deploy: 20 | 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - uses: actions/checkout@v3 25 | - name: Set up Python 26 | uses: actions/setup-python@v3 27 | with: 28 | python-version: '3.x' 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install --upgrade pip 32 | pip install build 33 | - name: Build package 34 | run: python -m build 35 | - name: Publish package 36 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 37 | with: 38 | user: __token__ 39 | password: ${{ secrets.PYPI_TOKEN }} 40 | -------------------------------------------------------------------------------- /.github/workflows/rpi-image.yml: -------------------------------------------------------------------------------- 1 | name: RPi Image 2 | 3 | on: 4 | release: 5 | types: [published] 6 | workflow_dispatch: 7 | 8 | jobs: 9 | create-rpi-image: 10 | strategy: 11 | matrix: 12 | arch: 13 | - arch: x32 14 | url: https://downloads.raspberrypi.com/raspios_lite_armhf/images/raspios_lite_armhf-2024-11-19/2024-11-19-raspios-bookworm-armhf-lite.img.xz 15 | - arch: x64 16 | url: https://downloads.raspberrypi.com/raspios_lite_arm64/images/raspios_lite_arm64-2024-11-19/2024-11-19-raspios-bookworm-arm64-lite.img.xz 17 | 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Checkout repository 21 | uses: actions/checkout@v4 22 | - name: Create image 23 | uses: dtcooper/rpi-image-modifier@v1 24 | id: create-image 25 | with: 26 | base-image-url: ${{ matrix.arch.url }} 27 | image-path: image_rpi_${{ matrix.arch.arch }}.img 28 | compress-with-xz: true 29 | cache: true 30 | mount-repository: true 31 | run: | 32 | apt update 33 | apt install -y libudev-dev libusb-1.0-0-dev libhidapi-libusb0 libjpeg-dev zlib1g-dev libopenjp2-7 libtiff5-dev libgtk-3-dev python3-pip python3-full 34 | pip install streamdeckapi 35 | crontab -l | { cat; echo "@reboot streamdeckapi-server"; } | crontab - 36 | - name: Print outputs 37 | shell: bash 38 | run: | 39 | echo 'image-path: ${{ steps.create-image.outputs.image-path }}' 40 | echo 'image-size: ${{ steps.create-image.outputs.image-size }}' 41 | echo 'image-sha256sum: ${{ steps.create-image.outputs.image-sha256sum }}' 42 | - name: Upload build artifact 43 | uses: actions/upload-artifact@v4 44 | with: 45 | name: built-image 46 | path: ${{ steps.create-image.outputs.image-path }} 47 | if-no-files-found: error 48 | retention-days: 2 49 | compression-level: 0 # Already compressed with xz above 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/__pycache__/ 2 | build 3 | *.egg-info 4 | data 5 | *.png 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[python]": { 3 | "editor.defaultFormatter": "ms-python.black-formatter" 4 | }, 5 | "python.formatting.provider": "none", 6 | "python.testing.unittestArgs": [ 7 | "-v", 8 | "-s", 9 | "./tests", 10 | "-p", 11 | "*test.py" 12 | ], 13 | "python.testing.pytestEnabled": false, 14 | "python.testing.unittestEnabled": true 15 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9.18-bookworm 2 | 3 | # Get dependencies 4 | RUN apt update 5 | RUN apt install -y libudev-dev libusb-1.0-0-dev libhidapi-libusb0 libjpeg-dev zlib1g-dev libopenjp2-7 libtiff5-dev libgtk-3-dev 6 | RUN apt clean 7 | RUN rm -rf /var/lib/apt/lists/* 8 | 9 | COPY . /streamdeckapi 10 | WORKDIR /streamdeckapi 11 | 12 | # Install the pip package 13 | RUN pip install --no-cache-dir . 14 | 15 | EXPOSE 6153 16 | 17 | # Run the server 18 | CMD [ "streamdeckapi-server" ] 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Patrick762 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![PyPI version](https://badge.fury.io/py/streamdeckapi.svg)](https://badge.fury.io/py/streamdeckapi) 2 | 3 | # streamdeckapi 4 | Stream Deck API Library for Home Assistant Stream Deck Integration 5 | 6 | Only compatible with separate [Stream Deck Plugin](https://github.com/Patrick762/streamdeckapi-plugin) or the bundled server. 7 | 8 | ## Server inside docker container 9 | The docker image allows you to use the streamdeckapi server inside a docker container. 10 | 11 | ### Usage 12 | ```shell 13 | docker run -v /dev/hidraw7:/dev/hidraw7 -v ./data/streamdeckapi:/streamdeckapi/data -p 6153:6153 --privileged ghcr.io/patrick762/streamdeckapi:main 14 | ``` 15 | 16 | **Note:** You have to change `hidraw7` to the path of your Stream Deck. You can find this path by using `lshid` (https://pypi.org/project/lshid/). 17 | 18 | ## Limitations 19 | - No zeroconf discovery 20 | - No `doubleTap` event 21 | 22 | ## Server without docker 23 | This library also contains a server to use the streamdeck with Linux or without the official Stream Deck Software. 24 | 25 | For this to work, the following software is required: 26 | 27 | - LibUSB HIDAPI [Installation instructions](https://python-elgato-streamdeck.readthedocs.io/en/stable/pages/backend_libusb_hidapi.html) or [Installation instructions](https://github.com/jamesridgway/devdeck/wiki/Installation) 28 | - cairo [Installation instructions for Windows](https://stackoverflow.com/a/73913080) 29 | 30 | Cairo Installation for Windows: 31 | ```bash 32 | pip install pipwin 33 | 34 | pipwin install cairocffi 35 | ``` 36 | 37 | ### Limitations 38 | - No `doubleTap` event 39 | 40 | ### Installation on Linux / Raspberry Pi 41 | 42 | Install requirements: 43 | `sudo apt install -y libudev-dev libusb-1.0-0-dev libhidapi-libusb0 libjpeg-dev zlib1g-dev libopenjp2-7 libtiff5 libgtk-3-dev python3-pip` 44 | 45 | Allow all users non-root access to Stream Deck Devices: 46 | ```bash 47 | sudo tee /etc/udev/rules.d/10-streamdeck.rules << EOF 48 | SUBSYSTEMS=="usb", ATTRS{idVendor}=="0fd9", GROUP="users", TAG+="uaccess" 49 | EOF 50 | ``` 51 | 52 | Reload access rules: 53 | `sudo udevadm control --reload-rules` 54 | 55 | Install the package: 56 | `pip install streamdeckapi` 57 | 58 | Reboot your system 59 | 60 | Start the server: 61 | `streamdeckapi-server` 62 | 63 | ### Example service 64 | To run the server on startup, you can use the following config in the file `/etc/systemd/system/streamdeckapi.service`: 65 | 66 | ```conf 67 | [Unit] 68 | Description=Stream Deck API Service 69 | Wants=network-online.target 70 | After=network.target 71 | 72 | [Service] 73 | WorkingDirectory=/home/pi 74 | ExecStart=/home/pi/.local/bin/streamdeckapi-server 75 | User=pi 76 | StandardOutput=console 77 | 78 | [Install] 79 | WantedBy=multi-user.target 80 | ``` 81 | 82 | To start the service, run `sudo systemctl start streamdeckapi.service`. 83 | 84 | To enable the service, run `sudo systemctl enable streamdeckapi.service`. 85 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Setup for pypi package""" 2 | 3 | import os 4 | import codecs 5 | from setuptools import setup, find_packages 6 | 7 | here = os.path.abspath(os.path.dirname(__file__)) 8 | 9 | with codecs.open(os.path.join(here, "README.md"), encoding="utf-8") as fh: 10 | long_description = "\n" + fh.read() 11 | 12 | VERSION = "0.0.13" 13 | DESCRIPTION = "Stream Deck API Library" 14 | 15 | # Setting up 16 | setup( 17 | name="streamdeckapi", 18 | version=VERSION, 19 | author="Patrick762", 20 | author_email="", 21 | description=DESCRIPTION, 22 | long_description_content_type="text/markdown", 23 | long_description=long_description, 24 | url="https://github.com/Patrick762/streamdeckapi", 25 | packages=find_packages(), 26 | install_requires=[ 27 | "requests", 28 | "websockets>=13.1", 29 | "aiohttp>=3.8", 30 | "human-readable-ids==0.1.3", 31 | "jsonpickle==3.0.1", 32 | "streamdeck==0.9.3", 33 | "pillow", 34 | "cairosvg==2.7.0", 35 | "zeroconf", 36 | ], 37 | keywords=[], 38 | entry_points={ 39 | "console_scripts": ["streamdeckapi-server = streamdeckapi.server:start"] 40 | }, 41 | classifiers=[ 42 | "Development Status :: 1 - Planning", 43 | "Intended Audience :: Developers", 44 | "Programming Language :: Python :: 3", 45 | "Operating System :: Unix", 46 | "Operating System :: MacOS :: MacOS X", 47 | "Operating System :: Microsoft :: Windows", 48 | ], 49 | ) 50 | -------------------------------------------------------------------------------- /streamdeckapi/__init__.py: -------------------------------------------------------------------------------- 1 | """Stream Deck API.""" 2 | 3 | from streamdeckapi.api import * 4 | from streamdeckapi.const import * 5 | from streamdeckapi.tools import * 6 | from streamdeckapi.types import * 7 | -------------------------------------------------------------------------------- /streamdeckapi/api.py: -------------------------------------------------------------------------------- 1 | """Stream Deck API.""" 2 | 3 | import asyncio 4 | from typing import Callable 5 | import json 6 | import logging 7 | 8 | import requests 9 | from websockets.client import connect 10 | from websockets.exceptions import WebSocketException 11 | 12 | from streamdeckapi.const import PLUGIN_ICON, PLUGIN_INFO, PLUGIN_PORT 13 | 14 | from .types import SDInfo, SDWebsocketMessage 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | 19 | class StreamDeckApi: 20 | """Stream Deck API Class.""" 21 | 22 | def __init__( 23 | self, 24 | host: str, 25 | on_button_press: any = None, 26 | on_button_release: any = None, 27 | on_status_update: any = None, 28 | on_ws_message: any = None, 29 | on_ws_connect: any = None, 30 | ) -> None: 31 | """Init Stream Deck API object. 32 | 33 | Args: 34 | on_button_press (Callable[[str], None] or None): Callback if button pressed 35 | on_button_release (Callable[[str], None] or None): Callback if button released 36 | on_status_update (Callable[[SDInfo], None] or None): Callback if status update received 37 | on_ws_message (Callable[[SDWebsocketMessage], None] or None): Callback if websocket message received 38 | on_ws_connect (Callable[[], None] or None): Callback on websocket connected 39 | """ 40 | 41 | self._host = host 42 | self._on_button_press = on_button_press 43 | self._on_button_release = on_button_release 44 | self._on_status_update = on_status_update 45 | self._on_ws_message = on_ws_message 46 | self._on_ws_connect = on_ws_connect 47 | self._loop = asyncio.get_event_loop() 48 | self._running = False 49 | self._task: any = None 50 | 51 | # 52 | # Properties 53 | # 54 | 55 | @property 56 | def host(self) -> str: 57 | """Stream Deck API host.""" 58 | return self._host 59 | 60 | @property 61 | def _info_url(self) -> str: 62 | """URL to info endpoint.""" 63 | return f"http://{self._host}:{PLUGIN_PORT}{PLUGIN_INFO}" 64 | 65 | @property 66 | def _icon_url(self) -> str: 67 | """URL to icon endpoint.""" 68 | return f"http://{self._host}:{PLUGIN_PORT}{PLUGIN_ICON}/" 69 | 70 | @property 71 | def _websocket_url(self) -> str: 72 | """URL to websocket.""" 73 | return f"ws://{self._host}:{PLUGIN_PORT}" 74 | 75 | # 76 | # API Methods 77 | # 78 | 79 | @staticmethod 80 | def _get_request(url: str) -> any: 81 | """Handle GET requests. 82 | 83 | Returns: 84 | requests.Response or None 85 | """ 86 | 87 | try: 88 | res = requests.get(url, timeout=5) 89 | except requests.RequestException: 90 | _LOGGER.debug( 91 | "Error retrieving data from Stream Deck Plugin (exception). Is it offline?" 92 | ) 93 | return None 94 | if res.status_code != 200: 95 | _LOGGER.debug( 96 | "Error retrieving data from Stream Deck Plugin (response code). Is it offline?" 97 | ) 98 | return None 99 | return res 100 | 101 | @staticmethod 102 | def _post_request(url: str, data: str, headers) -> any: 103 | """Handle POST requests. 104 | 105 | Returns: 106 | requests.Response or None 107 | """ 108 | 109 | try: 110 | res = requests.post(url, data, headers=headers, timeout=5) 111 | except requests.RequestException: 112 | _LOGGER.debug("Error sending data to Stream Deck Plugin (exception)") 113 | return None 114 | if res.status_code != 200: 115 | _LOGGER.debug( 116 | "Error sending data to Stream Deck Plugin (%s). Is the button currently visible?", 117 | res.reason, 118 | ) 119 | return None 120 | return res 121 | 122 | async def get_info(self, in_executor: bool = True) -> any: 123 | """Get info about Stream Deck. 124 | 125 | Returns: 126 | SDInfo or None 127 | """ 128 | 129 | res: any = None 130 | if in_executor: 131 | res = await self._loop.run_in_executor( 132 | None, self._get_request, self._info_url 133 | ) 134 | else: 135 | res = self._get_request(self._info_url) 136 | if res is None or res.status_code != 200: 137 | return None 138 | try: 139 | rjson = res.json() 140 | except requests.JSONDecodeError: 141 | _LOGGER.debug("Error decoding response from %s", self._info_url) 142 | return None 143 | try: 144 | info = SDInfo(rjson) 145 | except KeyError: 146 | _LOGGER.debug("Error parsing response from %s to SDInfo", self._info_url) 147 | return None 148 | return info 149 | 150 | async def get_icon(self, btn: str) -> any: 151 | """Get svg icon from Stream Deck button. 152 | 153 | Returns: 154 | str or None 155 | """ 156 | 157 | url = f"{self._icon_url}{btn}" 158 | res = await self._loop.run_in_executor(None, self._get_request, url) 159 | if res is None or res.status_code != 200: 160 | return None 161 | if res.headers.get("Content-Type", "") != "image/svg+xml": 162 | _LOGGER.debug("Invalid content type received from %s", url) 163 | return None 164 | return res.text 165 | 166 | async def update_icon(self, btn: str, svg: str) -> bool: 167 | """Update svg icon of Stream Deck button.""" 168 | url = f"{self._icon_url}{btn}" 169 | res = await self._loop.run_in_executor( 170 | None, 171 | self._post_request, 172 | url, 173 | svg.encode("utf-8"), 174 | {"Content-Type": "image/svg+xml"}, 175 | ) 176 | return isinstance(res, requests.Response) and res.status_code == 200 177 | 178 | # 179 | # Websocket Methods 180 | # 181 | 182 | def _on_button_change(self, uuid: any, state: bool): 183 | """Handle button down event. 184 | 185 | Args: 186 | uuid (str or dict): UUID of the button 187 | state (bool): State of the button 188 | """ 189 | 190 | if not isinstance(uuid, str): 191 | _LOGGER.debug("Method _on_button_change: uuid is not str") 192 | return 193 | if state is True and self._on_button_press is not None: 194 | self._on_button_press(uuid) 195 | elif state is False and self._on_button_release is not None: 196 | self._on_button_release(uuid) 197 | 198 | def _on_ws_status_update(self, info: any): 199 | """Handle Stream Deck status update event. 200 | 201 | Args: 202 | info (SDInfo or str or dict): Stream Deck Info 203 | """ 204 | 205 | if not isinstance(info, SDInfo): 206 | _LOGGER.debug("Method _on_ws_status_update: info is not SDInfo") 207 | return 208 | if self._on_status_update is not None: 209 | self._on_status_update(info) 210 | 211 | def _on_message(self, msg: str): 212 | """Handle websocket messages.""" 213 | if not isinstance(msg, str): 214 | return 215 | 216 | _LOGGER.debug(msg) 217 | 218 | try: 219 | datajson = json.loads(msg) 220 | except json.JSONDecodeError: 221 | _LOGGER.debug("Method _on_message: Websocket message couldn't get parsed") 222 | return 223 | try: 224 | data = SDWebsocketMessage(datajson) 225 | except KeyError: 226 | _LOGGER.debug( 227 | "Method _on_message: Websocket message couldn't get parsed to SDWebsocketMessage" 228 | ) 229 | return 230 | 231 | _LOGGER.debug("Method _on_message: Got event %s", data.event) 232 | 233 | if self._on_ws_message is not None: 234 | self._on_ws_message(data) 235 | 236 | if data.event == "keyDown": 237 | self._on_button_change(data.args, True) 238 | elif data.event == "keyUp": 239 | self._on_button_change(data.args, False) 240 | elif data.event == "status": 241 | self._on_ws_status_update(data.args) 242 | else: 243 | _LOGGER.debug( 244 | "Method _on_message: Unknown event from Stream Deck Plugin received (Event: %s)", 245 | data.event, 246 | ) 247 | 248 | async def _websocket_loop(self): 249 | """Start the websocket client loop.""" 250 | self._running = True 251 | while self._running: 252 | info = await self.get_info() 253 | if isinstance(info, SDInfo): 254 | _LOGGER.debug("Method _websocket_loop: Streamdeck online") 255 | try: 256 | async with connect(self._websocket_url) as websocket: 257 | if self._on_ws_connect is not None: 258 | self._on_ws_connect() 259 | try: 260 | while self._running: 261 | data = await asyncio.wait_for( 262 | websocket.recv(), timeout=60 263 | ) 264 | self._on_message(data) 265 | await websocket.close() 266 | _LOGGER.debug("Method _websocket_loop: Websocket closed") 267 | except WebSocketException: 268 | _LOGGER.debug( 269 | "Method _websocket_loop: Websocket client crashed. Restarting it" 270 | ) 271 | except asyncio.TimeoutError: 272 | _LOGGER.debug( 273 | "Method _websocket_loop: Websocket client timed out. Restarting it" 274 | ) 275 | except WebSocketException: 276 | _LOGGER.debug( 277 | "Method _websocket_loop: Websocket client not connecting. Restarting it" 278 | ) 279 | 280 | def start_websocket_loop(self): 281 | """Start the websocket client.""" 282 | self._task = asyncio.create_task(self._websocket_loop()) 283 | 284 | def stop_websocket_loop(self): 285 | """Stop the websocket client.""" 286 | self._running = False 287 | -------------------------------------------------------------------------------- /streamdeckapi/const.py: -------------------------------------------------------------------------------- 1 | """Stream Deck API const.""" 2 | 3 | PLUGIN_PORT = 6153 4 | PLUGIN_INFO = "/sd/info" 5 | PLUGIN_ICON = "/sd/icon" 6 | 7 | DB_FILE = "data/streamdeckapi.db" 8 | SD_SSDP = "urn:home-assistant-device:stream-deck" 9 | SD_ZEROCONF = "_stream-deck-api._tcp.local." 10 | DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S.%f" 11 | LONG_PRESS_SECONDS = 2 12 | -------------------------------------------------------------------------------- /streamdeckapi/server.py: -------------------------------------------------------------------------------- 1 | """Stream Deck API Server.""" 2 | 3 | from concurrent.futures import ProcessPoolExecutor 4 | import re 5 | import io 6 | import asyncio 7 | import platform 8 | import sqlite3 9 | import base64 10 | import socket 11 | from datetime import datetime 12 | from typing import List, Dict 13 | import aiohttp 14 | import human_readable_ids as hri 15 | from jsonpickle import encode 16 | from aiohttp import web 17 | from StreamDeck.DeviceManager import DeviceManager 18 | from StreamDeck.Devices.StreamDeck import StreamDeck 19 | from StreamDeck.ImageHelpers import PILHelper 20 | import cairosvg 21 | from PIL import Image 22 | from zeroconf import ServiceInfo, Zeroconf 23 | 24 | from streamdeckapi.const import ( 25 | DATETIME_FORMAT, 26 | DB_FILE, 27 | LONG_PRESS_SECONDS, 28 | PLUGIN_ICON, 29 | PLUGIN_INFO, 30 | PLUGIN_PORT, 31 | SD_ZEROCONF, 32 | ) 33 | from streamdeckapi.types import SDApplication, SDButton, SDButtonPosition, SDDevice 34 | 35 | 36 | DEFAULT_ICON = re.sub( 37 | "\r\n|\n|\r", 38 | "", 39 | """ 40 | 41 | 42 | 43 | 44 | 45 | Configure 46 | 47 | """, 48 | ) 49 | 50 | 51 | # Copy of MDI Icon "alert" 52 | NO_CONN_ICON = re.sub( 53 | "\r\n|\n|\r", 54 | "", 55 | """ 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | """, 67 | ) 68 | 69 | 70 | application: SDApplication = SDApplication( 71 | { 72 | "font": "Segoe UI", 73 | "language": "en", 74 | "platform": platform.system(), 75 | "platformVersion": platform.version(), 76 | "version": "0.0.1", 77 | } 78 | ) 79 | devices: List[SDDevice] = [] 80 | websocket_connections: List[web.WebSocketResponse] = [] 81 | 82 | streamdecks: List[StreamDeck] = DeviceManager().enumerate() 83 | 84 | # 85 | # Database 86 | # 87 | 88 | database_first = sqlite3.connect(DB_FILE) 89 | table_cursor = database_first.cursor() 90 | table_cursor.execute( 91 | """ 92 | CREATE TABLE IF NOT EXISTS buttons( 93 | key integer PRIMARY KEY, 94 | uuid text NOT NULL, 95 | device text, 96 | x integer, 97 | y integer, 98 | svg text 99 | );""" 100 | ) 101 | table_cursor.execute( 102 | """ 103 | CREATE TABLE IF NOT EXISTS button_states( 104 | key integer PRIMARY KEY, 105 | state integer, 106 | state_update text 107 | );""" 108 | ) 109 | table_cursor.execute("DELETE FROM button_states;") 110 | database_first.commit() 111 | table_cursor.close() 112 | database_first.close() 113 | 114 | 115 | def save_button(key: int, button: SDButton): 116 | """Save button to database.""" 117 | database = sqlite3.connect(DB_FILE) 118 | cursor = database.cursor() 119 | svg_bytes = button.svg.encode() 120 | base64_bytes = base64.b64encode(svg_bytes) 121 | base64_string = base64_bytes.decode() 122 | 123 | # Check if exists 124 | result = cursor.execute(f"SELECT uuid FROM buttons WHERE key={key}") 125 | matching_buttons = result.fetchall() 126 | if len(matching_buttons) > 0: 127 | # Perform update 128 | cursor.execute(f'UPDATE buttons SET svg="{base64_string}" WHERE key={key}') 129 | else: 130 | # Create new row 131 | cursor.execute( 132 | f'INSERT INTO buttons VALUES ({key}, "{button.uuid}", "{button.device}", {button.position.x_pos}, {button.position.y_pos}, "{base64_string}")' 133 | ) 134 | database.commit() 135 | print(f"Saved button {button.uuid} with key {key} to database") 136 | cursor.close() 137 | database.close() 138 | 139 | 140 | def get_button(key: int) -> any: 141 | """Get a button from the database.""" 142 | database = sqlite3.connect(DB_FILE) 143 | cursor = database.cursor() 144 | result = cursor.execute( 145 | f"SELECT key,uuid,device,x,y,svg FROM buttons WHERE key={key}" 146 | ) 147 | matching_buttons = result.fetchall() 148 | if len(matching_buttons) == 0: 149 | return None 150 | row = matching_buttons[0] 151 | base64_bytes = row[5].encode() 152 | svg_bytes = base64.b64decode(base64_bytes) 153 | svg_string = svg_bytes.decode() 154 | button = SDButton( 155 | { 156 | "uuid": row[1], 157 | "device": row[2], 158 | "position": {"x": row[3], "y": row[4]}, 159 | "svg": svg_string, 160 | } 161 | ) 162 | cursor.close() 163 | database.close() 164 | return button 165 | 166 | 167 | def get_button_by_uuid(uuid: str) -> any: 168 | """Get a button from the database.""" 169 | database = sqlite3.connect(DB_FILE) 170 | cursor = database.cursor() 171 | result = cursor.execute( 172 | f'SELECT key,uuid,device,x,y,svg FROM buttons WHERE uuid="{uuid}"' 173 | ) 174 | matching_buttons = result.fetchall() 175 | if len(matching_buttons) == 0: 176 | return None 177 | row = matching_buttons[0] 178 | base64_bytes = row[5].encode() 179 | svg_bytes = base64.b64decode(base64_bytes) 180 | svg_string = svg_bytes.decode() 181 | button = SDButton( 182 | { 183 | "uuid": row[1], 184 | "device": row[2], 185 | "position": {"x": row[3], "y": row[4]}, 186 | "svg": svg_string, 187 | } 188 | ) 189 | cursor.close() 190 | database.close() 191 | return button 192 | 193 | 194 | def get_button_key(uuid: str) -> int: 195 | """Get a button key from the database.""" 196 | database = sqlite3.connect(DB_FILE) 197 | cursor = database.cursor() 198 | result = cursor.execute(f'SELECT key FROM buttons WHERE uuid="{uuid}"') 199 | matching_buttons = result.fetchall() 200 | if len(matching_buttons) == 0: 201 | return -1 202 | row = matching_buttons[0] 203 | key = row[0] 204 | cursor.close() 205 | database.close() 206 | return key 207 | 208 | 209 | def get_buttons() -> Dict[str, SDButton]: 210 | """Load all buttons from the database.""" 211 | result: Dict[str, SDButton] = {} 212 | database = sqlite3.connect(DB_FILE) 213 | cursor = database.cursor() 214 | for row in cursor.execute("SELECT key,uuid,device,x,y,svg FROM buttons"): 215 | base64_bytes = row[5].encode() 216 | svg_bytes = base64.b64decode(base64_bytes) 217 | svg_string = svg_bytes.decode() 218 | result[row[0]] = SDButton( 219 | { 220 | "uuid": row[1], 221 | "device": row[2], 222 | "position": {"x": row[3], "y": row[4]}, 223 | "svg": svg_string, 224 | } 225 | ) 226 | cursor.close() 227 | database.close() 228 | print(f"Loaded {len(result)} buttons from DB") 229 | return result 230 | 231 | 232 | def write_button_state(key: int, state: bool, update: str): 233 | """Write button state to database.""" 234 | state_int = 0 235 | if state is True: 236 | state_int = 1 237 | 238 | database = sqlite3.connect(DB_FILE) 239 | cursor = database.cursor() 240 | 241 | # Check if exists 242 | result = cursor.execute(f"SELECT state FROM button_states WHERE key={key}") 243 | matching_states = result.fetchall() 244 | if len(matching_states) > 0: 245 | # Perform update 246 | cursor.execute( 247 | f'UPDATE button_states SET state={state_int}, state_update="{update}" WHERE key={key}' 248 | ) 249 | else: 250 | # Create new row 251 | cursor.execute( 252 | f'INSERT INTO button_states VALUES ({key}, {state_int}, "{update}")' 253 | ) 254 | database.commit() 255 | print(f"Saved button_state with key {key} to database") 256 | cursor.close() 257 | database.close() 258 | 259 | 260 | def get_button_state(key: int) -> any: 261 | """Load button_state from database.""" 262 | result = () 263 | database = sqlite3.connect(DB_FILE) 264 | cursor = database.cursor() 265 | result = cursor.execute( 266 | f"SELECT key,state,state_update FROM button_states WHERE key={key}" 267 | ) 268 | matching_states = result.fetchall() 269 | if len(matching_states) == 0: 270 | return None 271 | row = matching_states[0] 272 | state = False 273 | if row[1] == 1: 274 | state = True 275 | result = (state, row[2]) 276 | cursor.close() 277 | database.close() 278 | return result 279 | 280 | 281 | # 282 | # API 283 | # 284 | 285 | 286 | async def api_info_handler(_: web.Request): 287 | """Handle info requests.""" 288 | json_data = encode( 289 | {"devices": devices, "application": application, "buttons": get_buttons()}, 290 | unpicklable=False, 291 | ) 292 | if not isinstance(json_data, str): 293 | return web.Response(status=500, text="jsonpickle error") 294 | json_data = ( 295 | json_data.replace('"x_pos"', '"x"') 296 | .replace('"y_pos"', '"y"') 297 | .replace('"platform_version"', '"platformVersion"') 298 | ) 299 | return web.Response(text=json_data, content_type="application/json") 300 | 301 | 302 | async def api_icon_get_handler(request: web.Request): 303 | """Handle icon get requests.""" 304 | uuid = request.match_info["uuid"] 305 | button = get_button_by_uuid(uuid) 306 | if not isinstance(button, SDButton): 307 | return web.Response(status=404, text="Button not found") 308 | return web.Response(text=button.svg, content_type="image/svg+xml") 309 | 310 | 311 | async def api_icon_set_handler(request: web.Request): 312 | """Handle icon set requests.""" 313 | uuid = request.match_info["uuid"] 314 | if not request.has_body: 315 | return web.Response(status=422, text="No data in request") 316 | body = await request.text() 317 | if not body.startswith(" SDButtonPosition: 432 | """Get the position of a key.""" 433 | return SDButtonPosition({"x": int(key / deck.KEY_COLS), "y": key % deck.KEY_COLS}) 434 | 435 | 436 | async def long_press_callback(key: int): 437 | """Handle callback after long press seconds.""" 438 | 439 | button = get_button(key) 440 | 441 | now = datetime.now() 442 | 443 | # Check state of button 444 | db_button_state = get_button_state(key) 445 | 446 | if not isinstance(db_button_state, tuple): 447 | print("ERROR reading state") 448 | return 449 | 450 | last_update: str = db_button_state[1] 451 | last_update_datetime = datetime.strptime(last_update, DATETIME_FORMAT) 452 | diff = now - last_update_datetime 453 | 454 | if db_button_state[0] is True and diff.seconds >= LONG_PRESS_SECONDS: 455 | print("Long press detected") 456 | await websocket_broadcast(encode({"event": "longPress", "args": button.uuid})) 457 | 458 | 459 | async def on_key_change(_: StreamDeck, key: int, state: bool): 460 | """Handle key change callbacks.""" 461 | button = get_button(key) 462 | if not isinstance(button, SDButton): 463 | return 464 | 465 | if state is True: 466 | await websocket_broadcast(encode({"event": "keyDown", "args": button.uuid})) 467 | print("Waiting for button release") 468 | # Start timer 469 | Timer(LONG_PRESS_SECONDS, lambda: long_press_callback(key), False) 470 | else: 471 | await websocket_broadcast(encode({"event": "keyUp", "args": button.uuid})) 472 | 473 | now = datetime.now() 474 | 475 | db_button_state = get_button_state(key) 476 | 477 | if not isinstance(db_button_state, tuple): 478 | write_button_state(key, state, now.strftime(DATETIME_FORMAT)) 479 | return 480 | 481 | write_button_state(key, state, now.strftime(DATETIME_FORMAT)) 482 | 483 | last_state: bool = db_button_state[0] 484 | last_update: str = db_button_state[1] 485 | last_update_datetime = datetime.strptime(last_update, DATETIME_FORMAT) 486 | diff = now - last_update_datetime 487 | 488 | if last_state is True and state is False and diff.seconds < LONG_PRESS_SECONDS: 489 | print("Single Tap detected") 490 | await websocket_broadcast(encode({"event": "singleTap", "args": button.uuid})) 491 | 492 | 493 | def update_button_icon(uuid: str, svg: str): 494 | """Update a button icon.""" 495 | for deck in streamdecks: 496 | if not deck.is_visual(): 497 | continue 498 | 499 | if not deck.is_open(): 500 | deck.open() 501 | 502 | button = get_button_by_uuid(uuid) 503 | button_key = get_button_key(uuid) 504 | if isinstance(button, SDButton) and button_key >= 0: 505 | set_icon(deck, button_key, svg) 506 | button.svg = svg 507 | save_button(button_key, button) 508 | 509 | 510 | def set_icon(deck: StreamDeck, key: int, svg: str): 511 | """Draw an icon to the button.""" 512 | png_bytes = io.BytesIO() 513 | cairosvg.svg2png(svg.encode("utf-8"), write_to=png_bytes) 514 | 515 | icon = Image.open(png_bytes) 516 | image = PILHelper.create_scaled_image(deck, icon) 517 | 518 | deck.set_key_image(key, PILHelper.to_native_format(deck, image)) 519 | 520 | 521 | def init_all(): 522 | """Init Stream Deck devices.""" 523 | print(f"Found {len(streamdecks)} Stream Deck(s).") 524 | 525 | for deck in streamdecks: 526 | if not deck.is_visual(): 527 | continue 528 | 529 | deck.open() 530 | 531 | serial = deck.get_serial_number() 532 | 533 | devices.append( 534 | SDDevice( 535 | { 536 | "id": serial, 537 | "name": deck.deck_type(), 538 | "size": {"columns": deck.KEY_COLS, "rows": deck.KEY_ROWS}, 539 | "type": 20, 540 | } 541 | ) 542 | ) 543 | 544 | for key in range(deck.key_count()): 545 | # Only add if not already in dict 546 | button = get_button(key) 547 | if not isinstance(button, SDButton): 548 | position = get_position(deck, key) 549 | new_button = SDButton( 550 | { 551 | "uuid": hri.get_new_id().lower().replace(" ", "-"), 552 | "device": serial, 553 | "position": {"x": position.y_pos, "y": position.x_pos}, 554 | "svg": DEFAULT_ICON, 555 | } 556 | ) 557 | save_button(key, new_button) 558 | 559 | deck.reset() 560 | # Write svg to buttons 561 | for key, button in get_buttons().items(): 562 | set_icon(deck, key, button.svg) 563 | 564 | deck.set_key_callback_async(on_key_change) 565 | 566 | 567 | class Timer: 568 | """Timer class.""" 569 | 570 | def __init__(self, interval, callback, repeating=True): 571 | """Init timer.""" 572 | self._interval = interval 573 | self._callback = callback 574 | self._repeating = repeating 575 | self._task = asyncio.ensure_future(self._job()) 576 | 577 | async def _job(self): 578 | await asyncio.sleep(self._interval) 579 | await self._callback() 580 | if self._repeating: 581 | self._task = asyncio.ensure_future(self._job()) 582 | 583 | def cancel(self): 584 | """Cancel timer.""" 585 | self._task.cancel() 586 | 587 | 588 | def get_local_ip(): 589 | """Get local ip address.""" 590 | connection = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 591 | try: 592 | connection.connect(("192.255.255.255", 1)) 593 | address = connection.getsockname()[0] 594 | except socket.error: 595 | address = "127.0.0.1" 596 | finally: 597 | connection.close() 598 | return address 599 | 600 | 601 | def start_zeroconf(): 602 | """Start Zeroconf server.""" 603 | 604 | host = get_local_ip() 605 | 606 | print("Using host", host, "for Zeroconf") 607 | 608 | info = ServiceInfo( 609 | SD_ZEROCONF, 610 | f"Stream Deck API Server at {host}.{SD_ZEROCONF}", 611 | addresses=[socket.inet_aton(host)], 612 | port=PLUGIN_PORT, 613 | properties={"path": "/sd/info"}, 614 | server="pythonserver.local.", 615 | ) 616 | 617 | zeroconf = Zeroconf() 618 | 619 | print("Zeroconf starting") 620 | 621 | zeroconf.register_service(info) 622 | 623 | 624 | def start(): 625 | """Entrypoint.""" 626 | init_all() 627 | 628 | loop = asyncio.get_event_loop() 629 | executor = ProcessPoolExecutor(2) 630 | 631 | # Zeroconf server 632 | loop.run_in_executor(executor, start_zeroconf) 633 | 634 | # API server 635 | loop = asyncio.get_event_loop() 636 | loop.run_until_complete(start_server_async()) 637 | 638 | try: 639 | loop.run_forever() 640 | except KeyboardInterrupt: 641 | pass 642 | 643 | loop.close() 644 | -------------------------------------------------------------------------------- /streamdeckapi/tools.py: -------------------------------------------------------------------------------- 1 | """Stream Deck API Tools.""" 2 | 3 | from .types import SDInfo 4 | 5 | 6 | def get_model(info: SDInfo) -> str: 7 | """Get Stream Deck model.""" 8 | if len(info.devices) == 0: 9 | return "None" 10 | size = info.devices[0].size 11 | if size.columns == 3 and size.rows == 2: 12 | return "Stream Deck Mini" 13 | if size.columns == 5 and size.rows == 3: 14 | return "Stream Deck MK.2" 15 | if size.columns == 4 and size.rows == 2: 16 | return "Stream Deck +" 17 | if size.columns == 8 and size.rows == 4: 18 | return "Stream Deck XL" 19 | return "Unknown" 20 | -------------------------------------------------------------------------------- /streamdeckapi/types.py: -------------------------------------------------------------------------------- 1 | """Stream Deck API types.""" 2 | from typing import List, Dict 3 | 4 | 5 | class SDApplication: 6 | """Stream Deck Application Type.""" 7 | 8 | font: str 9 | language: str 10 | platform: str 11 | platform_version: str 12 | version: str 13 | 14 | def __init__(self, obj: dict) -> None: 15 | """Init Stream Deck Application object.""" 16 | self.font = obj["font"] 17 | self.language = obj["language"] 18 | self.platform = obj["platform"] 19 | self.platform_version = obj["platformVersion"] 20 | self.version = obj["version"] 21 | 22 | 23 | class SDSize: 24 | """Stream Deck Size Type.""" 25 | 26 | columns: int 27 | rows: int 28 | 29 | def __init__(self, obj: dict) -> None: 30 | """Init Stream Deck Size object.""" 31 | self.columns = obj["columns"] 32 | self.rows = obj["rows"] 33 | 34 | 35 | class SDDevice: 36 | """Stream Deck Device Type.""" 37 | 38 | id: str 39 | name: str 40 | type: int 41 | size: SDSize 42 | 43 | def __init__(self, obj: dict) -> None: 44 | """Init Stream Deck Device object.""" 45 | self.id = obj["id"] 46 | self.name = obj["name"] 47 | self.type = obj["type"] 48 | self.size = SDSize(obj["size"]) 49 | 50 | 51 | class SDButtonPosition: 52 | """Stream Deck Button Position Type.""" 53 | 54 | x_pos: int 55 | y_pos: int 56 | 57 | def __init__(self, obj: dict) -> None: 58 | """Init Stream Deck Button Position object.""" 59 | self.x_pos = obj["x"] 60 | self.y_pos = obj["y"] 61 | 62 | 63 | class SDButton: 64 | """Stream Deck Button Type.""" 65 | 66 | uuid: str 67 | device: str 68 | position: SDButtonPosition 69 | svg: str 70 | 71 | def __init__(self, obj: dict) -> None: 72 | """Init Stream Deck Button object.""" 73 | self.uuid = obj["uuid"] 74 | self.device = obj["device"] 75 | self.svg = obj["svg"] 76 | self.position = SDButtonPosition(obj["position"]) 77 | 78 | 79 | class SDInfo(dict): 80 | """Stream Deck Info Type.""" 81 | 82 | application: SDApplication 83 | 84 | def __init__(self, obj: dict) -> None: 85 | """Init Stream Deck Info object.""" 86 | self.devices: List[SDDevice] = [] 87 | self.buttons: Dict[str, SDButton] = {} 88 | 89 | dict.__init__(self, obj) 90 | self.application = SDApplication(obj["application"]) 91 | for device in obj["devices"]: 92 | self.devices.append(SDDevice(device)) 93 | for _id in obj["buttons"]: 94 | self.buttons.update({_id: SDButton(obj["buttons"][_id])}) 95 | 96 | 97 | class SDWebsocketMessage: 98 | """Stream Deck Websocket Message Type.""" 99 | 100 | event: str 101 | args: any 102 | 103 | def __init__(self, obj: dict) -> None: 104 | """Init Stream Deck Websocket Message object.""" 105 | self.event = obj["event"] 106 | if obj["args"] == {}: 107 | self.args = {} 108 | return 109 | if isinstance(obj["args"], str): 110 | self.args = obj["args"] 111 | return 112 | self.args = SDInfo(obj["args"]) 113 | -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | from streamdeckapi import SDWebsocketMessage, StreamDeckApi 4 | 5 | async def __main__(): 6 | deck = StreamDeckApi("localhost") 7 | info = await deck.get_info(False) 8 | 9 | if info is None: 10 | print("Error getting info") 11 | return 12 | 13 | print(json.dumps(info)) 14 | 15 | loop = asyncio.get_event_loop() 16 | loop.run_until_complete(__main__()) 17 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | # 2 | # Only used for development! 3 | # 4 | 5 | # Start a server 6 | #docker run -v /dev/hidraw7:/dev/hidraw7 -p 6153:6153 --privileged --detached ghcr.io/patrick762/streamdeckapi:main 7 | 8 | # Run python tests 9 | python test.py 10 | --------------------------------------------------------------------------------