From 9b8d3574c2d564f98268120df263e03666c7c12d Mon Sep 17 00:00:00 2001 From: Patrick762 Date: Sun, 23 Apr 2023 15:00:02 +0200 Subject: [PATCH 01/22] added server example --- .gitignore | 2 ++ README.md | 6 ++++++ setup.py | 8 ++++++-- streamdeckapi/api.py | 12 +++++------- streamdeckapi/const.py | 5 +++++ streamdeckapi/server.py | 11 +++++++++++ 6 files changed, 35 insertions(+), 9 deletions(-) create mode 100644 streamdeckapi/const.py create mode 100644 streamdeckapi/server.py diff --git a/.gitignore b/.gitignore index 61f2dc9..4f60f87 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ **/__pycache__/ +build +*.egg-info diff --git a/README.md b/README.md index 4a68c88..c051468 100644 --- a/README.md +++ b/README.md @@ -7,3 +7,9 @@ Only compatible with separate [Stream Deck Plugin](https://github.com/Patrick762 ## Dependencies - [websockets](https://pypi.org/project/websockets/) 11.0.2 + + +## Server +This library also contains a server to use the streamdeck with linux or without the official Stream Deck Software. + +For this to work, the LibUSB HIDAPI is required. [Installation instructions](https://python-elgato-streamdeck.readthedocs.io/en/stable/pages/backend_libusb_hidapi.html) diff --git a/setup.py b/setup.py index 1f95206..7585a7b 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ here = os.path.abspath(os.path.dirname(__file__)) with codecs.open(os.path.join(here, "README.md"), encoding="utf-8") as fh: long_description = "\n" + fh.read() -VERSION = '0.0.2' +VERSION = '0.0.3' DESCRIPTION = 'Stream Deck API Library' # Setting up @@ -21,8 +21,12 @@ setup( long_description=long_description, url="https://github.com/Patrick762/streamdeckapi", packages=find_packages(), - install_requires=["websockets==11.0.2"], + install_requires=["websockets==11.0.2", + "streamdeck==0.9.3", "pillow>=9.4.0,<10.0.0"], keywords=[], + entry_points={ + "console_scripts": ["streamdeckapi-server = streamdeckapi.server:start"] + }, classifiers=[ "Development Status :: 1 - Planning", "Intended Audience :: Developers", diff --git a/streamdeckapi/api.py b/streamdeckapi/api.py index 8ac56c7..8ffcc8d 100644 --- a/streamdeckapi/api.py +++ b/streamdeckapi/api.py @@ -9,11 +9,9 @@ import requests from websockets.client import connect from websockets.exceptions import WebSocketException -from .types import SDInfo, SDWebsocketMessage +from streamdeckapi.const import PLUGIN_ICON, PLUGIN_INFO, PLUGIN_PORT -_PLUGIN_PORT = 6153 -_PLUGIN_INFO = "/sd/info" -_PLUGIN_ICON = "/sd/icon" +from .types import SDInfo, SDWebsocketMessage _LOGGER = logging.getLogger(__name__) @@ -53,17 +51,17 @@ class StreamDeckApi: @property def _info_url(self) -> str: """URL to info endpoint.""" - return f"http://{self._host}:{_PLUGIN_PORT}{_PLUGIN_INFO}" + return f"http://{self._host}:{PLUGIN_PORT}{PLUGIN_INFO}" @property def _icon_url(self) -> str: """URL to icon endpoint.""" - return f"http://{self._host}:{_PLUGIN_PORT}{_PLUGIN_ICON}/" + return f"http://{self._host}:{PLUGIN_PORT}{PLUGIN_ICON}/" @property def _websocket_url(self) -> str: """URL to websocket.""" - return f"ws://{self._host}:{_PLUGIN_PORT}" + return f"ws://{self._host}:{PLUGIN_PORT}" # # API Methods diff --git a/streamdeckapi/const.py b/streamdeckapi/const.py new file mode 100644 index 0000000..984ca48 --- /dev/null +++ b/streamdeckapi/const.py @@ -0,0 +1,5 @@ +"""Stream Deck API const.""" + +PLUGIN_PORT = 6153 +PLUGIN_INFO = "/sd/info" +PLUGIN_ICON = "/sd/icon" diff --git a/streamdeckapi/server.py b/streamdeckapi/server.py new file mode 100644 index 0000000..a0a2b87 --- /dev/null +++ b/streamdeckapi/server.py @@ -0,0 +1,11 @@ +"""Stream Deck API Server.""" + +from StreamDeck.DeviceManager import DeviceManager +from streamdeckapi.const import PLUGIN_ICON, PLUGIN_INFO, PLUGIN_PORT + +def start(): + streamdecks = DeviceManager().enumerate() + + print("Found {} Stream Deck(s).\n".format(len(streamdecks))) + + print("Started Stream Deck API server on port", PLUGIN_PORT) From 4d9d1732745025f00d55b85feaeea465501f406b Mon Sep 17 00:00:00 2001 From: Patrick762 Date: Tue, 25 Apr 2023 19:25:29 +0200 Subject: [PATCH 02/22] added dependency --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 7585a7b..e1fbfe3 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,7 @@ setup( long_description=long_description, url="https://github.com/Patrick762/streamdeckapi", packages=find_packages(), - install_requires=["websockets==11.0.2", + install_requires=["requests==2.28.2", "websockets==11.0.2", "streamdeck==0.9.3", "pillow>=9.4.0,<10.0.0"], keywords=[], entry_points={ From dc13848f96ea5c10e47b671f142a1c6c74996802 Mon Sep 17 00:00:00 2001 From: Patrick762 Date: Tue, 25 Apr 2023 19:45:42 +0200 Subject: [PATCH 03/22] extended server example --- setup.py | 3 ++- streamdeckapi/const.py | 2 ++ streamdeckapi/server.py | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index e1fbfe3..d353365 100644 --- a/setup.py +++ b/setup.py @@ -2,12 +2,13 @@ from setuptools import setup, find_packages import codecs import os +from streamdeckapi.const import VERSION + here = os.path.abspath(os.path.dirname(__file__)) with codecs.open(os.path.join(here, "README.md"), encoding="utf-8") as fh: long_description = "\n" + fh.read() -VERSION = '0.0.3' DESCRIPTION = 'Stream Deck API Library' # Setting up diff --git a/streamdeckapi/const.py b/streamdeckapi/const.py index 984ca48..29b979c 100644 --- a/streamdeckapi/const.py +++ b/streamdeckapi/const.py @@ -1,5 +1,7 @@ """Stream Deck API const.""" +VERSION = '0.0.3' + PLUGIN_PORT = 6153 PLUGIN_INFO = "/sd/info" PLUGIN_ICON = "/sd/icon" diff --git a/streamdeckapi/server.py b/streamdeckapi/server.py index a0a2b87..1c9aa6a 100644 --- a/streamdeckapi/server.py +++ b/streamdeckapi/server.py @@ -3,9 +3,46 @@ from StreamDeck.DeviceManager import DeviceManager from streamdeckapi.const import PLUGIN_ICON, PLUGIN_INFO, PLUGIN_PORT +# Prints diagnostic information about a given StreamDeck. +def print_deck_info(index, deck): + image_format = deck.key_image_format() + + flip_description = { + (False, False): "not mirrored", + (True, False): "mirrored horizontally", + (False, True): "mirrored vertically", + (True, True): "mirrored horizontally/vertically", + } + + print("Deck {} - {}.".format(index, deck.deck_type())) + print("\t - ID: {}".format(deck.id())) + print("\t - Serial: '{}'".format(deck.get_serial_number())) + print("\t - Firmware Version: '{}'".format(deck.get_firmware_version())) + print("\t - Key Count: {} (in a {}x{} grid)".format( + deck.key_count(), + deck.key_layout()[0], + deck.key_layout()[1])) + if deck.is_visual(): + print("\t - Key Images: {}x{} pixels, {} format, rotated {} degrees, {}".format( + image_format['size'][0], + image_format['size'][1], + image_format['format'], + image_format['rotation'], + flip_description[image_format['flip']])) + else: + print("\t - No Visual Output") + def start(): streamdecks = DeviceManager().enumerate() print("Found {} Stream Deck(s).\n".format(len(streamdecks))) print("Started Stream Deck API server on port", PLUGIN_PORT) + + for index, deck in enumerate(streamdecks): + deck.open() + deck.reset() + + print_deck_info(index, deck) + + deck.close() From 22fe988591e673d5b3721de2fc3fc88b342b0027 Mon Sep 17 00:00:00 2001 From: Patrick762 Date: Wed, 3 May 2023 14:51:39 +0200 Subject: [PATCH 04/22] added basic api routes --- setup.py | 14 +++++-- streamdeckapi/server.py | 89 ++++++++++++++++++++++++----------------- 2 files changed, 63 insertions(+), 40 deletions(-) diff --git a/setup.py b/setup.py index d353365..47692fe 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ here = os.path.abspath(os.path.dirname(__file__)) with codecs.open(os.path.join(here, "README.md"), encoding="utf-8") as fh: long_description = "\n" + fh.read() -DESCRIPTION = 'Stream Deck API Library' +DESCRIPTION = "Stream Deck API Library" # Setting up setup( @@ -22,8 +22,14 @@ setup( long_description=long_description, url="https://github.com/Patrick762/streamdeckapi", packages=find_packages(), - install_requires=["requests==2.28.2", "websockets==11.0.2", - "streamdeck==0.9.3", "pillow>=9.4.0,<10.0.0"], + install_requires=[ + "requests==2.28.2", + "websockets==11.0.2", + "aiohttp==3.8.4", + "human-readable-ids==0.1.3", + "streamdeck==0.9.3", + "pillow>=9.4.0,<10.0.0", + ], keywords=[], entry_points={ "console_scripts": ["streamdeckapi-server = streamdeckapi.server:start"] @@ -35,5 +41,5 @@ setup( "Operating System :: Unix", "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows", - ] + ], ) diff --git a/streamdeckapi/server.py b/streamdeckapi/server.py index 1c9aa6a..9ef05d8 100644 --- a/streamdeckapi/server.py +++ b/streamdeckapi/server.py @@ -1,48 +1,65 @@ """Stream Deck API Server.""" -from StreamDeck.DeviceManager import DeviceManager +import aiohttp +import asyncio +from aiohttp import web, WSCloseCode + +# from StreamDeck.DeviceManager import DeviceManager from streamdeckapi.const import PLUGIN_ICON, PLUGIN_INFO, PLUGIN_PORT -# Prints diagnostic information about a given StreamDeck. -def print_deck_info(index, deck): - image_format = deck.key_image_format() - flip_description = { - (False, False): "not mirrored", - (True, False): "mirrored horizontally", - (False, True): "mirrored vertically", - (True, True): "mirrored horizontally/vertically", - } +async def api_info_handler(request: web.Request): + return web.Response(text="Info") - print("Deck {} - {}.".format(index, deck.deck_type())) - print("\t - ID: {}".format(deck.id())) - print("\t - Serial: '{}'".format(deck.get_serial_number())) - print("\t - Firmware Version: '{}'".format(deck.get_firmware_version())) - print("\t - Key Count: {} (in a {}x{} grid)".format( - deck.key_count(), - deck.key_layout()[0], - deck.key_layout()[1])) - if deck.is_visual(): - print("\t - Key Images: {}x{} pixels, {} format, rotated {} degrees, {}".format( - image_format['size'][0], - image_format['size'][1], - image_format['format'], - image_format['rotation'], - flip_description[image_format['flip']])) - else: - print("\t - No Visual Output") -def start(): - streamdecks = DeviceManager().enumerate() +async def api_icon_get_handler(request: web.Request): + btnId = request.match_info["btnId"] + return web.Response(text="Icon get") - print("Found {} Stream Deck(s).\n".format(len(streamdecks))) +async def api_icon_set_handler(request: web.Request): + btnId = request.match_info["btnId"] + body = await request.text() + print(body) + return web.Response(text="Icon set") + + +async def websocket_handler(request: web.Request): + ws = web.WebSocketResponse() + await ws.prepare(request) + async for msg in ws: + if msg.type == aiohttp.WSMsgType.TEXT: + if msg.data == "close": + await ws.close() + else: + await ws.send_str("some websocket message payload") + elif msg.type == aiohttp.WSMsgType.ERROR: + print("ws connection closed with exception %s" % ws.exception()) + return ws + + +def create_runner(): + app = web.Application() + app.add_routes( + [ + web.get("/", websocket_handler), + web.get(PLUGIN_INFO, api_info_handler), + web.get(PLUGIN_ICON + "/{btnId}", api_icon_get_handler), + web.post(PLUGIN_ICON + "/{btnId}", api_icon_set_handler), + ] + ) + return web.AppRunner(app) + + +async def start_server(host="0.0.0.0", port=PLUGIN_PORT): + runner = create_runner() + await runner.setup() + site = web.TCPSite(runner, host, port) + await site.start() print("Started Stream Deck API server on port", PLUGIN_PORT) - for index, deck in enumerate(streamdecks): - deck.open() - deck.reset() - print_deck_info(index, deck) - - deck.close() +def start(): + loop = asyncio.get_event_loop() + loop.run_until_complete(start_server()) + loop.run_forever() From 67b1962054f3dde50147d14bdf726bc9fe402293 Mon Sep 17 00:00:00 2001 From: Patrick762 Date: Wed, 3 May 2023 15:39:20 +0200 Subject: [PATCH 05/22] added handlers for api endpoints --- setup.py | 1 + streamdeckapi/server.py | 64 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/setup.py b/setup.py index 47692fe..707ba3e 100644 --- a/setup.py +++ b/setup.py @@ -27,6 +27,7 @@ setup( "websockets==11.0.2", "aiohttp==3.8.4", "human-readable-ids==0.1.3", + "jsonpickle==3.0.1", "streamdeck==0.9.3", "pillow>=9.4.0,<10.0.0", ], diff --git a/streamdeckapi/server.py b/streamdeckapi/server.py index 9ef05d8..44afd77 100644 --- a/streamdeckapi/server.py +++ b/streamdeckapi/server.py @@ -2,26 +2,82 @@ import aiohttp import asyncio -from aiohttp import web, WSCloseCode +import platform +from jsonpickle import encode +from aiohttp import web # from StreamDeck.DeviceManager import DeviceManager from streamdeckapi.const import PLUGIN_ICON, PLUGIN_INFO, PLUGIN_PORT +from streamdeckapi.types import SDApplication, SDButton, SDDevice + +application: SDApplication = SDApplication( + { + "font": "", + "language": "", + "platform": platform.system(), + "platformVersion": platform.version(), + "version": "0.0.1", + } +) +devices: list[SDDevice] = [] +buttons: dict[str, SDButton] = {} + +# Examples +devices.append( + SDDevice( + { + "id": "08B602C026FC8D1989FDF80EB8658612", + "name": "Stream Deck", + "size": {"columns": 5, "rows": 3}, + "type": 0, + } + ) +) +buttons["547686796543735"] = SDButton( + { + "uuid": "kind-sloth-97", + "device": "08B602C026FC8D1989FDF80EB8658612", + "position": {"x": 0, "y": 0}, + "svg": 'offPhilips Hue Huelight', + } +) async def api_info_handler(request: web.Request): - return web.Response(text="Info") + json_data = encode( + {"devices": devices, "application": application, "buttons": buttons}, + unpicklable=False, + ) + return web.Response(text=json_data, content_type="application/json") async def api_icon_get_handler(request: web.Request): btnId = request.match_info["btnId"] - return web.Response(text="Icon get") + for _, btn in buttons.items(): + if btn.uuid != btnId: + continue + return web.Response(text=btn.svg, content_type="image/svg+xml") + return web.Response(status=404, text="Button not found") async def api_icon_set_handler(request: web.Request): btnId = request.match_info["btnId"] + if not request.has_body: + return web.Response(status=422, text="No data in request") body = await request.text() print(body) - return web.Response(text="Icon set") + if not body.startswith(" Date: Wed, 3 May 2023 15:41:41 +0200 Subject: [PATCH 06/22] updated example button id --- streamdeckapi/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/streamdeckapi/server.py b/streamdeckapi/server.py index 44afd77..49189d7 100644 --- a/streamdeckapi/server.py +++ b/streamdeckapi/server.py @@ -33,7 +33,7 @@ devices.append( } ) ) -buttons["547686796543735"] = SDButton( +buttons["576e8e7fc6ac2a37fa436ed3dc76652b"] = SDButton( { "uuid": "kind-sloth-97", "device": "08B602C026FC8D1989FDF80EB8658612", From 78ce8ff512f569101cf425c0d34384d7224c9221 Mon Sep 17 00:00:00 2001 From: Patrick762 Date: Wed, 3 May 2023 17:22:13 +0200 Subject: [PATCH 07/22] added hardware handling --- streamdeckapi/server.py | 119 +++++++++++++++++++++++++++++++++------- 1 file changed, 98 insertions(+), 21 deletions(-) diff --git a/streamdeckapi/server.py b/streamdeckapi/server.py index 49189d7..2195be9 100644 --- a/streamdeckapi/server.py +++ b/streamdeckapi/server.py @@ -1,14 +1,33 @@ """Stream Deck API Server.""" +import re import aiohttp import asyncio import platform +import human_readable_ids as hri from jsonpickle import encode from aiohttp import web +from StreamDeck.DeviceManager import DeviceManager +from StreamDeck.Devices.StreamDeck import StreamDeck -# from StreamDeck.DeviceManager import DeviceManager from streamdeckapi.const import PLUGIN_ICON, PLUGIN_INFO, PLUGIN_PORT -from streamdeckapi.types import SDApplication, SDButton, SDDevice +from streamdeckapi.types import SDApplication, SDButton, SDButtonPosition, SDDevice + + +DEFAULT_ICON = re.sub( + "\r\n|\n|\r", + "", + """ + + + + + +Configure + +""", +) + application: SDApplication = SDApplication( { @@ -23,27 +42,29 @@ devices: list[SDDevice] = [] buttons: dict[str, SDButton] = {} # Examples -devices.append( - SDDevice( - { - "id": "08B602C026FC8D1989FDF80EB8658612", - "name": "Stream Deck", - "size": {"columns": 5, "rows": 3}, - "type": 0, - } - ) -) -buttons["576e8e7fc6ac2a37fa436ed3dc76652b"] = SDButton( - { - "uuid": "kind-sloth-97", - "device": "08B602C026FC8D1989FDF80EB8658612", - "position": {"x": 0, "y": 0}, - "svg": 'offPhilips Hue Huelight', - } -) +# devices.append( +# SDDevice( +# { +# "id": "08B602C026FC8D1989FDF80EB8658612", +# "name": "Stream Deck", +# "size": {"columns": 5, "rows": 3}, +# "type": 0, +# } +# ) +# ) +# buttons["576e8e7fc6ac2a37fa436ed3dc76652b"] = SDButton( +# { +# "uuid": "kind-sloth-97", +# "device": "08B602C026FC8D1989FDF80EB8658612", +# "position": {"x": 0, "y": 0}, +# "svg": 'offPhilips Hue Huelight', +# } +# ) -async def api_info_handler(request: web.Request): +async def api_info_handler( + request: web.Request, +): # FIXME: can result in unparseable json (different keys, f.ex. x - x_pos) json_data = encode( {"devices": devices, "application": application, "buttons": buttons}, unpicklable=False, @@ -115,7 +136,63 @@ async def start_server(host="0.0.0.0", port=PLUGIN_PORT): print("Started Stream Deck API server on port", PLUGIN_PORT) +def get_position(deck: StreamDeck, key: int) -> SDButtonPosition: + """Get the position of a key.""" + return SDButtonPosition({"x": int(key / deck.KEY_COLS), "y": key % deck.KEY_COLS}) + + +def on_key_change(deck: StreamDeck, key: int, state: bool): + """Handle key change callbacks.""" + position = get_position(deck, key) + print(f"Key at {position.x_pos}|{position.y_pos} is state {state}") + + +def init_all(): + """Init Stream Deck devices.""" + # TODO: Load buttons from storage and save asap + + streamdecks: list[StreamDeck] = DeviceManager().enumerate() + print("Found {} Stream Deck(s).\n".format(len(streamdecks))) + + for deck in streamdecks: + if not deck.is_visual(): + continue + + deck.open() + + serial = deck.get_serial_number() + + devices.append( + SDDevice( + { + "id": serial, + "name": deck.deck_type(), + "size": {"columns": deck.KEY_COLS, "rows": deck.KEY_ROWS}, + "type": 20, + } + ) + ) + + for key in range(deck.key_count()): + # FIXME: only add if not already in dict + position = get_position(deck, key) + buttons[key] = SDButton( + { + "uuid": hri.get_new_id().lower().replace(" ", "-"), + "device": serial, + "position": {"x": position.x_pos, "y": position.y_pos}, + "svg": DEFAULT_ICON, + } + ) + + # TODO: write svg to buttons + + deck.set_key_callback(on_key_change) + + def start(): + init_all() + loop = asyncio.get_event_loop() loop.run_until_complete(start_server()) loop.run_forever() From f808deec3d789962d8494ffdf27703b5d135b5fa Mon Sep 17 00:00:00 2001 From: Patrick762 Date: Thu, 4 May 2023 13:19:24 +0200 Subject: [PATCH 08/22] fixed api responses --- streamdeckapi/server.py | 28 +++++++--------------------- 1 file changed, 7 insertions(+), 21 deletions(-) diff --git a/streamdeckapi/server.py b/streamdeckapi/server.py index 2195be9..b32907c 100644 --- a/streamdeckapi/server.py +++ b/streamdeckapi/server.py @@ -41,34 +41,19 @@ application: SDApplication = SDApplication( devices: list[SDDevice] = [] buttons: dict[str, SDButton] = {} -# Examples -# devices.append( -# SDDevice( -# { -# "id": "08B602C026FC8D1989FDF80EB8658612", -# "name": "Stream Deck", -# "size": {"columns": 5, "rows": 3}, -# "type": 0, -# } -# ) -# ) -# buttons["576e8e7fc6ac2a37fa436ed3dc76652b"] = SDButton( -# { -# "uuid": "kind-sloth-97", -# "device": "08B602C026FC8D1989FDF80EB8658612", -# "position": {"x": 0, "y": 0}, -# "svg": 'offPhilips Hue Huelight', -# } -# ) - async def api_info_handler( request: web.Request, -): # FIXME: can result in unparseable json (different keys, f.ex. x - x_pos) +): # FIXME: unparseable json (x -> x_pos, y -> y_pos, platformVersion -> platform_version) json_data = encode( {"devices": devices, "application": application, "buttons": buttons}, unpicklable=False, ) + if not isinstance(json_data, str): + return web.Response(status=500, text="jsonpickle error") + json_data = json_data.replace('"x_pos"', '"x"').replace('"y_pos"', '"y"').replace( + '"platform_version"', '"platformVersion"' + ) return web.Response(text=json_data, content_type="application/json") @@ -185,6 +170,7 @@ def init_all(): } ) + deck.reset() # TODO: write svg to buttons deck.set_key_callback(on_key_change) From 85272e6c8e12e3c3b34095c1b533a77b0b1c368c Mon Sep 17 00:00:00 2001 From: Patrick762 Date: Sat, 20 May 2023 14:28:49 +0200 Subject: [PATCH 09/22] added sqlite database to save button configs --- .gitignore | 2 + README.md | 7 +- setup.py | 1 + streamdeckapi/server.py | 280 ++++++++++++++++++++++++++++++++-------- 4 files changed, 235 insertions(+), 55 deletions(-) diff --git a/.gitignore b/.gitignore index 4f60f87..7aa4d20 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ **/__pycache__/ build *.egg-info +*.db +*.db-journal diff --git a/README.md b/README.md index c051468..a125ff5 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,9 @@ Only compatible with separate [Stream Deck Plugin](https://github.com/Patrick762 ## Server -This library also contains a server to use the streamdeck with linux or without the official Stream Deck Software. +This library also contains a server to use the streamdeck with Linux or without the official Stream Deck Software. -For this to work, the LibUSB HIDAPI is required. [Installation instructions](https://python-elgato-streamdeck.readthedocs.io/en/stable/pages/backend_libusb_hidapi.html) +For this to work, the following software is required: + +- LibUSB HIDAPI [Installation instructions](https://python-elgato-streamdeck.readthedocs.io/en/stable/pages/backend_libusb_hidapi.html) +- cairo [Installation instructions for Windows](https://stackoverflow.com/a/73913080) diff --git a/setup.py b/setup.py index 707ba3e..7affb80 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,7 @@ setup( "jsonpickle==3.0.1", "streamdeck==0.9.3", "pillow>=9.4.0,<10.0.0", + "svglib==1.5.1", ], keywords=[], entry_points={ diff --git a/streamdeckapi/server.py b/streamdeckapi/server.py index b32907c..6a9791b 100644 --- a/streamdeckapi/server.py +++ b/streamdeckapi/server.py @@ -1,31 +1,41 @@ """Stream Deck API Server.""" import re -import aiohttp +import io import asyncio import platform +import sqlite3 +import base64 +import aiohttp import human_readable_ids as hri from jsonpickle import encode from aiohttp import web from StreamDeck.DeviceManager import DeviceManager from StreamDeck.Devices.StreamDeck import StreamDeck +from StreamDeck.ImageHelpers import PILHelper +from svglib.svglib import svg2rlg +from PIL import Image from streamdeckapi.const import PLUGIN_ICON, PLUGIN_INFO, PLUGIN_PORT from streamdeckapi.types import SDApplication, SDButton, SDButtonPosition, SDDevice +# TODO: MDI Icons not showing +# TODO: Text too small, positioning off +# TODO: Websocket broadcast not working yet + DEFAULT_ICON = re.sub( "\r\n|\n|\r", "", """ - - - - - -Configure - -""", + + + + + + Configure + + """, ) @@ -39,81 +49,211 @@ application: SDApplication = SDApplication( } ) devices: list[SDDevice] = [] -buttons: dict[str, SDButton] = {} + +# +# Database +# + +database = sqlite3.connect("streamdeckapi.db") +table_cursor = database.cursor() +table_cursor.execute(""" + CREATE TABLE IF NOT EXISTS buttons( + key integer PRIMARY KEY, + uuid text NOT NULL, + device text, + x integer, + y integer, + svg text + )""") +table_cursor.close() -async def api_info_handler( - request: web.Request, -): # FIXME: unparseable json (x -> x_pos, y -> y_pos, platformVersion -> platform_version) +def save_button(key: int, button: SDButton): + """Save button to database.""" + cursor = database.cursor() + svg_bytes = button.svg.encode() + base64_bytes = base64.b64encode(svg_bytes) + base64_string = base64_bytes.decode() + + # Check if exists + result = cursor.execute(f"SELECT uuid FROM buttons WHERE key={key}") + matching_buttons = result.fetchall() + if len(matching_buttons) > 0: + # Perform update + cursor.execute( + f"UPDATE buttons SET svg=\"{base64_string}\" WHERE key={key}") + else: + # Create new row + cursor.execute( + f"INSERT INTO buttons VALUES ({key}, \"{button.uuid}\", \"{button.device}\", {button.position.x_pos}, {button.position.y_pos}, \"{base64_string}\")") + database.commit() + print(f"Saved button {button.uuid} with key {key} to database") + cursor.close() + + +def get_button(key: int) -> SDButton | None: + """Get a button from the database.""" + cursor = database.cursor() + result = cursor.execute( + f"SELECT key,uuid,device,x,y,svg FROM buttons WHERE key={key}") + matching_buttons = result.fetchall() + if len(matching_buttons) == 0: + return None + row = matching_buttons[0] + base64_bytes = row[5].encode() + svg_bytes = base64.b64decode(base64_bytes) + svg_string = svg_bytes.decode() + button = SDButton({ + "uuid": row[1], + "device": row[2], + "position": {"x": row[3], "y": row[4]}, + "svg": svg_string, + }) + cursor.close() + return button + + +def get_button_by_uuid(uuid: str) -> SDButton | None: + """Get a button from the database.""" + cursor = database.cursor() + result = cursor.execute( + f"SELECT key,uuid,device,x,y,svg FROM buttons WHERE uuid=\"{uuid}\"") + matching_buttons = result.fetchall() + if len(matching_buttons) == 0: + return None + row = matching_buttons[0] + base64_bytes = row[5].encode() + svg_bytes = base64.b64decode(base64_bytes) + svg_string = svg_bytes.decode() + button = SDButton({ + "uuid": row[1], + "device": row[2], + "position": {"x": row[3], "y": row[4]}, + "svg": svg_string, + }) + cursor.close() + return button + + +def get_button_key(uuid: str) -> int: + """Get a button key from the database.""" + cursor = database.cursor() + result = cursor.execute(f"SELECT key FROM buttons WHERE uuid=\"{uuid}\"") + matching_buttons = result.fetchall() + if len(matching_buttons) == 0: + return -1 + row = matching_buttons[0] + key = row[0] + cursor.close() + return key + + +def get_buttons() -> dict[str, SDButton]: + """Load all buttons from the database.""" + result: dict[str, SDButton] = {} + cursor = database.cursor() + for row in cursor.execute("SELECT key,uuid,device,x,y,svg FROM buttons"): + base64_bytes = row[5].encode() + svg_bytes = base64.b64decode(base64_bytes) + svg_string = svg_bytes.decode() + result[row[0]] = SDButton({ + "uuid": row[1], + "device": row[2], + "position": {"x": row[3], "y": row[4]}, + "svg": svg_string, + }) + cursor.close() + print(f"Loaded {len(result)} buttons from DB") + return result + + +# +# API +# + + +async def api_info_handler(_: web.Request): + """Handle info requests.""" json_data = encode( - {"devices": devices, "application": application, "buttons": buttons}, + {"devices": devices, "application": application, "buttons": get_buttons()}, unpicklable=False, ) if not isinstance(json_data, str): return web.Response(status=500, text="jsonpickle error") - json_data = json_data.replace('"x_pos"', '"x"').replace('"y_pos"', '"y"').replace( - '"platform_version"', '"platformVersion"' + json_data = ( + json_data.replace('"x_pos"', '"x"') + .replace('"y_pos"', '"y"') + .replace('"platform_version"', '"platformVersion"') ) return web.Response(text=json_data, content_type="application/json") async def api_icon_get_handler(request: web.Request): - btnId = request.match_info["btnId"] - for _, btn in buttons.items(): - if btn.uuid != btnId: - continue - return web.Response(text=btn.svg, content_type="image/svg+xml") - return web.Response(status=404, text="Button not found") + """Handle icon get requests.""" + uuid = request.match_info["uuid"] + button = get_button_by_uuid(uuid) + if button is None: + return web.Response(status=404, text="Button not found") + return web.Response(text=button.svg, content_type="image/svg+xml") async def api_icon_set_handler(request: web.Request): - btnId = request.match_info["btnId"] + """Handle icon set requests.""" + uuid = request.match_info["uuid"] if not request.has_body: return web.Response(status=422, text="No data in request") body = await request.text() - print(body) if not body.startswith("= 0: + set_icon(deck, button_key, svg) + button.svg = svg + save_button(button_key, button) + + +def set_icon(deck: StreamDeck, key: int, svg: str): + """Draw an icon to the button.""" + svg_io = io.StringIO(svg) + drawing = svg2rlg(svg_io) + png_string = drawing.asString("png") + png_bytes = io.BytesIO(png_string) + + icon = Image.open(png_bytes) + image = PILHelper.create_scaled_image(deck, icon, margins=[0, 0, 20, 0]) + + deck.set_key_image(key, PILHelper.to_native_format(deck, image)) + + def init_all(): """Init Stream Deck devices.""" - # TODO: Load buttons from storage and save asap - streamdecks: list[StreamDeck] = DeviceManager().enumerate() - print("Found {} Stream Deck(s).\n".format(len(streamdecks))) + print(f"Found {len(streamdecks)} Stream Deck(s).") for deck in streamdecks: if not deck.is_visual(): @@ -159,24 +327,30 @@ def init_all(): ) for key in range(deck.key_count()): - # FIXME: only add if not already in dict - position = get_position(deck, key) - buttons[key] = SDButton( - { - "uuid": hri.get_new_id().lower().replace(" ", "-"), - "device": serial, - "position": {"x": position.x_pos, "y": position.y_pos}, - "svg": DEFAULT_ICON, - } - ) + # Only add if not already in dict + button = get_button(key) + if button is None: + position = get_position(deck, key) + new_button = SDButton( + { + "uuid": hri.get_new_id().lower().replace(" ", "-"), + "device": serial, + "position": {"x": position.x_pos, "y": position.y_pos}, + "svg": DEFAULT_ICON, + } + ) + save_button(key, new_button) deck.reset() - # TODO: write svg to buttons + # Write svg to buttons + for key, button in get_buttons().items(): + set_icon(deck, key, button.svg) deck.set_key_callback(on_key_change) def start(): + """Entrypoint.""" init_all() loop = asyncio.get_event_loop() From 78b7907bbbf566d285d955f61f229dc70d25482b Mon Sep 17 00:00:00 2001 From: Patrick762 Date: Sat, 20 May 2023 14:29:33 +0200 Subject: [PATCH 10/22] added todo --- streamdeckapi/server.py | 1 + 1 file changed, 1 insertion(+) diff --git a/streamdeckapi/server.py b/streamdeckapi/server.py index 6a9791b..9348903 100644 --- a/streamdeckapi/server.py +++ b/streamdeckapi/server.py @@ -22,6 +22,7 @@ from streamdeckapi.types import SDApplication, SDButton, SDButtonPosition, SDDev # TODO: MDI Icons not showing # TODO: Text too small, positioning off # TODO: Websocket broadcast not working yet +# TODO: SSDP server DEFAULT_ICON = re.sub( From f998b6f433f20e23a11a7582063e2e64b9f41a18 Mon Sep 17 00:00:00 2001 From: Patrick762 Date: Sun, 21 May 2023 13:51:46 +0200 Subject: [PATCH 11/22] replaced svglib with cairosvg, added ssdpy --- .gitignore | 1 + setup.py | 3 ++- streamdeckapi/const.py | 2 ++ streamdeckapi/server.py | 23 +++++++++++++---------- 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index 7aa4d20..2c35a6d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ build *.egg-info *.db *.db-journal +*.png diff --git a/setup.py b/setup.py index 7affb80..593e81b 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,8 @@ setup( "jsonpickle==3.0.1", "streamdeck==0.9.3", "pillow>=9.4.0,<10.0.0", - "svglib==1.5.1", + "cairosvg==2.7.0", + "ssdpy==0.4.1", ], keywords=[], entry_points={ diff --git a/streamdeckapi/const.py b/streamdeckapi/const.py index 29b979c..3791e68 100644 --- a/streamdeckapi/const.py +++ b/streamdeckapi/const.py @@ -5,3 +5,5 @@ VERSION = '0.0.3' PLUGIN_PORT = 6153 PLUGIN_INFO = "/sd/info" PLUGIN_ICON = "/sd/icon" + +SD_SSDP = "urn:home-assistant.io:device:stream-deck" diff --git a/streamdeckapi/server.py b/streamdeckapi/server.py index 9348903..26860e6 100644 --- a/streamdeckapi/server.py +++ b/streamdeckapi/server.py @@ -13,16 +13,14 @@ from aiohttp import web from StreamDeck.DeviceManager import DeviceManager from StreamDeck.Devices.StreamDeck import StreamDeck from StreamDeck.ImageHelpers import PILHelper -from svglib.svglib import svg2rlg +import cairosvg from PIL import Image +from ssdpy import SSDPServer -from streamdeckapi.const import PLUGIN_ICON, PLUGIN_INFO, PLUGIN_PORT +from streamdeckapi.const import PLUGIN_ICON, PLUGIN_INFO, PLUGIN_PORT, SD_SSDP from streamdeckapi.types import SDApplication, SDButton, SDButtonPosition, SDDevice -# TODO: MDI Icons not showing -# TODO: Text too small, positioning off # TODO: Websocket broadcast not working yet -# TODO: SSDP server DEFAULT_ICON = re.sub( @@ -292,13 +290,14 @@ def update_button_icon(uuid: str, svg: str): def set_icon(deck: StreamDeck, key: int, svg: str): """Draw an icon to the button.""" - svg_io = io.StringIO(svg) - drawing = svg2rlg(svg_io) - png_string = drawing.asString("png") - png_bytes = io.BytesIO(png_string) + png_bytes = io.BytesIO() + cairosvg.svg2png(svg.encode("utf-8"), write_to=png_bytes) + + # Debug + cairosvg.svg2png(svg.encode("utf-8"), write_to=f"icon_{key}.png") icon = Image.open(png_bytes) - image = PILHelper.create_scaled_image(deck, icon, margins=[0, 0, 20, 0]) + image = PILHelper.create_scaled_image(deck, icon) deck.set_key_image(key, PILHelper.to_native_format(deck, image)) @@ -357,3 +356,7 @@ def start(): loop = asyncio.get_event_loop() loop.run_until_complete(start_server()) loop.run_forever() + + # TODO: SSDP server + server = SSDPServer(SD_SSDP) + server.serve_forever() From aad95602de71a70b590616af1dd1d1a1286dd12d Mon Sep 17 00:00:00 2001 From: Patrick762 Date: Sun, 21 May 2023 15:01:06 +0200 Subject: [PATCH 12/22] added button state management --- README.md | 2 + streamdeckapi/const.py | 3 + streamdeckapi/server.py | 130 +++++++++++++++++++++++++++++++++++++--- 3 files changed, 126 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index a125ff5..2e3d885 100644 --- a/README.md +++ b/README.md @@ -16,3 +16,5 @@ For this to work, the following software is required: - LibUSB HIDAPI [Installation instructions](https://python-elgato-streamdeck.readthedocs.io/en/stable/pages/backend_libusb_hidapi.html) - cairo [Installation instructions for Windows](https://stackoverflow.com/a/73913080) + +The event `doubleTap` is not working with this server software. diff --git a/streamdeckapi/const.py b/streamdeckapi/const.py index 3791e68..ceda11c 100644 --- a/streamdeckapi/const.py +++ b/streamdeckapi/const.py @@ -6,4 +6,7 @@ PLUGIN_PORT = 6153 PLUGIN_INFO = "/sd/info" PLUGIN_ICON = "/sd/icon" +DB_FILE = "streamdeckapi.db" SD_SSDP = "urn:home-assistant.io:device:stream-deck" +DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S.%f" +LONG_PRESS_SECONDS = 2 diff --git a/streamdeckapi/server.py b/streamdeckapi/server.py index 26860e6..aac23bb 100644 --- a/streamdeckapi/server.py +++ b/streamdeckapi/server.py @@ -6,6 +6,7 @@ import asyncio import platform import sqlite3 import base64 +from datetime import datetime import aiohttp import human_readable_ids as hri from jsonpickle import encode @@ -17,11 +18,17 @@ import cairosvg from PIL import Image from ssdpy import SSDPServer -from streamdeckapi.const import PLUGIN_ICON, PLUGIN_INFO, PLUGIN_PORT, SD_SSDP +from streamdeckapi.const import ( + DATETIME_FORMAT, + DB_FILE, + LONG_PRESS_SECONDS, + PLUGIN_ICON, + PLUGIN_INFO, + PLUGIN_PORT, + SD_SSDP +) from streamdeckapi.types import SDApplication, SDButton, SDButtonPosition, SDDevice -# TODO: Websocket broadcast not working yet - DEFAULT_ICON = re.sub( "\r\n|\n|\r", @@ -53,8 +60,8 @@ devices: list[SDDevice] = [] # Database # -database = sqlite3.connect("streamdeckapi.db") -table_cursor = database.cursor() +database_first = sqlite3.connect(DB_FILE) +table_cursor = database_first.cursor() table_cursor.execute(""" CREATE TABLE IF NOT EXISTS buttons( key integer PRIMARY KEY, @@ -64,11 +71,19 @@ table_cursor.execute(""" y integer, svg text )""") +table_cursor.execute(""" + CREATE TABLE IF NOT EXISTS button_states( + key integer PRIMARY KEY, + state integer, + state_update text + )""") table_cursor.close() +database_first.close() def save_button(key: int, button: SDButton): """Save button to database.""" + database = sqlite3.connect(DB_FILE) cursor = database.cursor() svg_bytes = button.svg.encode() base64_bytes = base64.b64encode(svg_bytes) @@ -88,10 +103,12 @@ def save_button(key: int, button: SDButton): database.commit() print(f"Saved button {button.uuid} with key {key} to database") cursor.close() + database.close() def get_button(key: int) -> SDButton | None: """Get a button from the database.""" + database = sqlite3.connect(DB_FILE) cursor = database.cursor() result = cursor.execute( f"SELECT key,uuid,device,x,y,svg FROM buttons WHERE key={key}") @@ -109,11 +126,13 @@ def get_button(key: int) -> SDButton | None: "svg": svg_string, }) cursor.close() + database.close() return button def get_button_by_uuid(uuid: str) -> SDButton | None: """Get a button from the database.""" + database = sqlite3.connect(DB_FILE) cursor = database.cursor() result = cursor.execute( f"SELECT key,uuid,device,x,y,svg FROM buttons WHERE uuid=\"{uuid}\"") @@ -131,11 +150,13 @@ def get_button_by_uuid(uuid: str) -> SDButton | None: "svg": svg_string, }) cursor.close() + database.close() return button def get_button_key(uuid: str) -> int: """Get a button key from the database.""" + database = sqlite3.connect(DB_FILE) cursor = database.cursor() result = cursor.execute(f"SELECT key FROM buttons WHERE uuid=\"{uuid}\"") matching_buttons = result.fetchall() @@ -144,12 +165,14 @@ def get_button_key(uuid: str) -> int: row = matching_buttons[0] key = row[0] cursor.close() + database.close() return key def get_buttons() -> dict[str, SDButton]: """Load all buttons from the database.""" result: dict[str, SDButton] = {} + database = sqlite3.connect(DB_FILE) cursor = database.cursor() for row in cursor.execute("SELECT key,uuid,device,x,y,svg FROM buttons"): base64_bytes = row[5].encode() @@ -162,10 +185,57 @@ def get_buttons() -> dict[str, SDButton]: "svg": svg_string, }) cursor.close() + database.close() print(f"Loaded {len(result)} buttons from DB") return result +def write_button_state(key: int, state: bool, update: str): + """Write button state to database.""" + state_int = 0 + if state is True: + state_int = 1 + + database = sqlite3.connect(DB_FILE) + cursor = database.cursor() + + # Check if exists + result = cursor.execute(f"SELECT state FROM button_states WHERE key={key}") + matching_states = result.fetchall() + if len(matching_states) > 0: + # Perform update + cursor.execute( + f"UPDATE button_states SET state={state_int}, state_update=\"{update}\" WHERE key={key}") + else: + # Create new row + cursor.execute( + f"INSERT INTO button_states VALUES ({key}, {state_int}, \"{update}\")") + database.commit() + print(f"Saved button_state with key {key} to database") + cursor.close() + database.close() + + +def get_button_state(key: int) -> tuple | None: + """Load button_state from database.""" + result = () + database = sqlite3.connect(DB_FILE) + cursor = database.cursor() + result = cursor.execute( + f"SELECT key,state,state_update FROM button_states WHERE key={key}") + matching_states = result.fetchall() + if len(matching_states) == 0: + return None + row = matching_states[0] + state = False + if row[1] == 1: + state = True + result = (state, row[2]) + cursor.close() + database.close() + return result + + # # API # @@ -232,6 +302,12 @@ async def websocket_handler(request: web.Request): return web_socket +# TODO: Websocket broadcast not working yet +def websocket_broadcast(message: str): + """Send a message to each websocket client.""" + print(f"BROADCAST: {message}") + + # # Functions # @@ -265,10 +341,46 @@ def get_position(deck: StreamDeck, key: int) -> SDButtonPosition: return SDButtonPosition({"x": int(key / deck.KEY_COLS), "y": key % deck.KEY_COLS}) -def on_key_change(deck: StreamDeck, key: int, state: bool): +def on_key_change(_: StreamDeck, key: int, state: bool): """Handle key change callbacks.""" - position = get_position(deck, key) - print(f"Key at {position.x_pos}|{position.y_pos} is state {state}") + button = get_button(key) + if button is None: + return + + if state is True: + websocket_broadcast(encode( + {"event": "keyDown", "args": button.uuid})) + else: + websocket_broadcast(encode( + {"event": "keyUp", "args": button.uuid})) + + now = datetime.now() + + db_button_state = get_button_state(key) + + if db_button_state is None: + write_button_state(key, state, now.strftime(DATETIME_FORMAT)) + return + + last_state: bool = db_button_state[0] + last_update: str = db_button_state[1] + last_update_datetime = datetime.strptime(last_update, DATETIME_FORMAT) + diff = now - last_update_datetime + + if last_state is True and state is False and diff.seconds < LONG_PRESS_SECONDS: + websocket_broadcast( + encode({"event": "singleTap", "args": button.uuid})) + write_button_state(key, state, now.strftime(DATETIME_FORMAT)) + return + + # TODO: Work with timer instead + if last_state is True and state is False and diff.seconds >= LONG_PRESS_SECONDS: + websocket_broadcast( + encode({"event": "longPress", "args": button.uuid})) + write_button_state(key, state, now.strftime(DATETIME_FORMAT)) + return + + write_button_state(key, state, now.strftime(DATETIME_FORMAT)) def update_button_icon(uuid: str, svg: str): @@ -292,7 +404,7 @@ def set_icon(deck: StreamDeck, key: int, svg: str): """Draw an icon to the button.""" png_bytes = io.BytesIO() cairosvg.svg2png(svg.encode("utf-8"), write_to=png_bytes) - + # Debug cairosvg.svg2png(svg.encode("utf-8"), write_to=f"icon_{key}.png") From 8f3ccecb34e635698d59938e4c0f3892d8bdf0de Mon Sep 17 00:00:00 2001 From: Patrick762 Date: Sun, 21 May 2023 15:21:38 +0200 Subject: [PATCH 13/22] added websocket broadcast --- streamdeckapi/server.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/streamdeckapi/server.py b/streamdeckapi/server.py index aac23bb..7cc0b01 100644 --- a/streamdeckapi/server.py +++ b/streamdeckapi/server.py @@ -55,6 +55,7 @@ application: SDApplication = SDApplication( } ) devices: list[SDDevice] = [] +websocket_connections: list[web.WebSocketResponse] = [] # # Database @@ -70,13 +71,15 @@ table_cursor.execute(""" x integer, y integer, svg text - )""") + );""") table_cursor.execute(""" CREATE TABLE IF NOT EXISTS button_states( key integer PRIMARY KEY, state integer, state_update text - )""") + );""") +table_cursor.execute("DELETE FROM button_states;") +database_first.commit() table_cursor.close() database_first.close() @@ -290,8 +293,14 @@ async def websocket_handler(request: web.Request): """Handle websocket.""" web_socket = web.WebSocketResponse() await web_socket.prepare(request) + + await web_socket.send_str(encode({"event": "connected", "args": {}})) + + websocket_connections.append(web_socket) + async for msg in web_socket: if msg.type == aiohttp.WSMsgType.TEXT: + print(msg.data) if msg.data == "close": await web_socket.close() else: @@ -299,13 +308,16 @@ async def websocket_handler(request: web.Request): elif msg.type == aiohttp.WSMsgType.ERROR: print( f"Websocket connection closed with exception {web_socket.exception()}") + + websocket_connections.pop(web_socket) return web_socket -# TODO: Websocket broadcast not working yet def websocket_broadcast(message: str): """Send a message to each websocket client.""" print(f"BROADCAST: {message}") + for connection in websocket_connections: + asyncio.run(connection.send_str(message)) # @@ -472,3 +484,5 @@ def start(): # TODO: SSDP server server = SSDPServer(SD_SSDP) server.serve_forever() + + # TODO: 10 second broadcast with status From f083cdca9ee2e0f93cec121d25c8143856a596b0 Mon Sep 17 00:00:00 2001 From: Patrick762 Date: Mon, 22 May 2023 15:11:05 +0200 Subject: [PATCH 14/22] added ssdp background process --- streamdeckapi/server.py | 45 ++++++++++++++++++++++++----------------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/streamdeckapi/server.py b/streamdeckapi/server.py index 7cc0b01..4d1a51e 100644 --- a/streamdeckapi/server.py +++ b/streamdeckapi/server.py @@ -7,6 +7,7 @@ import platform import sqlite3 import base64 from datetime import datetime +from multiprocessing import Process import aiohttp import human_readable_ids as hri from jsonpickle import encode @@ -303,21 +304,19 @@ async def websocket_handler(request: web.Request): print(msg.data) if msg.data == "close": await web_socket.close() - else: - await web_socket.send_str("some websocket message payload") elif msg.type == aiohttp.WSMsgType.ERROR: print( f"Websocket connection closed with exception {web_socket.exception()}") - websocket_connections.pop(web_socket) + websocket_connections.remove(web_socket) return web_socket -def websocket_broadcast(message: str): +async def websocket_broadcast(message: str): """Send a message to each websocket client.""" - print(f"BROADCAST: {message}") + print(f"BROADCAST to {len(websocket_connections)} clients: {message}") for connection in websocket_connections: - asyncio.run(connection.send_str(message)) + await connection.send_str(message) # @@ -339,7 +338,7 @@ def create_runner(): return web.AppRunner(app) -async def start_server(host="0.0.0.0", port=PLUGIN_PORT): +async def start_server_async(host: str = "0.0.0.0", port: int = PLUGIN_PORT): """Start API server.""" runner = create_runner() await runner.setup() @@ -353,17 +352,17 @@ def get_position(deck: StreamDeck, key: int) -> SDButtonPosition: return SDButtonPosition({"x": int(key / deck.KEY_COLS), "y": key % deck.KEY_COLS}) -def on_key_change(_: StreamDeck, key: int, state: bool): +async def on_key_change(_: StreamDeck, key: int, state: bool): """Handle key change callbacks.""" button = get_button(key) if button is None: return if state is True: - websocket_broadcast(encode( + await websocket_broadcast(encode( {"event": "keyDown", "args": button.uuid})) else: - websocket_broadcast(encode( + await websocket_broadcast(encode( {"event": "keyUp", "args": button.uuid})) now = datetime.now() @@ -380,14 +379,14 @@ def on_key_change(_: StreamDeck, key: int, state: bool): diff = now - last_update_datetime if last_state is True and state is False and diff.seconds < LONG_PRESS_SECONDS: - websocket_broadcast( + await websocket_broadcast( encode({"event": "singleTap", "args": button.uuid})) write_button_state(key, state, now.strftime(DATETIME_FORMAT)) return # TODO: Work with timer instead if last_state is True and state is False and diff.seconds >= LONG_PRESS_SECONDS: - websocket_broadcast( + await websocket_broadcast( encode({"event": "longPress", "args": button.uuid})) write_button_state(key, state, now.strftime(DATETIME_FORMAT)) return @@ -470,19 +469,27 @@ def init_all(): for key, button in get_buttons().items(): set_icon(deck, key, button.svg) - deck.set_key_callback(on_key_change) + deck.set_key_callback_async(on_key_change) + + +def start_ssdp_server(): + """Start SSDP server.""" + print("Starting SSDP server ...") + server = SSDPServer(SD_SSDP) + server.serve_forever() def start(): """Entrypoint.""" init_all() + # SSDP server + ssdp_server = Process(target=start_ssdp_server) + ssdp_server.start() + + # API server loop = asyncio.get_event_loop() - loop.run_until_complete(start_server()) + loop.run_until_complete(start_server_async()) loop.run_forever() - # TODO: SSDP server - server = SSDPServer(SD_SSDP) - server.serve_forever() - - # TODO: 10 second broadcast with status + ssdp_server.join() From 5ab848f2d58ffab056ae3898f8525a32689203f1 Mon Sep 17 00:00:00 2001 From: Patrick762 Date: Mon, 22 May 2023 15:25:23 +0200 Subject: [PATCH 15/22] changed ssdp urn --- streamdeckapi/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/streamdeckapi/const.py b/streamdeckapi/const.py index ceda11c..7a34eec 100644 --- a/streamdeckapi/const.py +++ b/streamdeckapi/const.py @@ -7,6 +7,6 @@ PLUGIN_INFO = "/sd/info" PLUGIN_ICON = "/sd/icon" DB_FILE = "streamdeckapi.db" -SD_SSDP = "urn:home-assistant.io:device:stream-deck" +SD_SSDP = "urn:home-assistant-device:stream-deck" DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S.%f" LONG_PRESS_SECONDS = 2 From 06296413a53b6d3adea9a9d934eb2b801e3cf0a7 Mon Sep 17 00:00:00 2001 From: Patrick762 Date: Mon, 22 May 2023 19:36:35 +0200 Subject: [PATCH 16/22] fixed pip install error --- README.md | 2 +- setup.py | 3 +-- streamdeckapi/const.py | 2 -- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 2e3d885..34153ff 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ This library also contains a server to use the streamdeck with Linux or without For this to work, the following software is required: -- LibUSB HIDAPI [Installation instructions](https://python-elgato-streamdeck.readthedocs.io/en/stable/pages/backend_libusb_hidapi.html) +- 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) - cairo [Installation instructions for Windows](https://stackoverflow.com/a/73913080) The event `doubleTap` is not working with this server software. diff --git a/setup.py b/setup.py index 593e81b..bfd1227 100644 --- a/setup.py +++ b/setup.py @@ -2,13 +2,12 @@ from setuptools import setup, find_packages import codecs import os -from streamdeckapi.const import VERSION - here = os.path.abspath(os.path.dirname(__file__)) with codecs.open(os.path.join(here, "README.md"), encoding="utf-8") as fh: long_description = "\n" + fh.read() +VERSION = "0.0.3" DESCRIPTION = "Stream Deck API Library" # Setting up diff --git a/streamdeckapi/const.py b/streamdeckapi/const.py index 7a34eec..98f75b3 100644 --- a/streamdeckapi/const.py +++ b/streamdeckapi/const.py @@ -1,7 +1,5 @@ """Stream Deck API const.""" -VERSION = '0.0.3' - PLUGIN_PORT = 6153 PLUGIN_INFO = "/sd/info" PLUGIN_ICON = "/sd/icon" From 152c0fd19c23fafb2723b12b4494011845abb0cc Mon Sep 17 00:00:00 2001 From: Patrick762 Date: Mon, 22 May 2023 19:46:25 +0200 Subject: [PATCH 17/22] replaced match() with if --- streamdeckapi/api.py | 35 +++++++++++++++++++---------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/streamdeckapi/api.py b/streamdeckapi/api.py index 8ffcc8d..9165457 100644 --- a/streamdeckapi/api.py +++ b/streamdeckapi/api.py @@ -90,7 +90,8 @@ class StreamDeckApi: try: res = requests.post(url, data, headers=headers, timeout=5) except requests.RequestException: - _LOGGER.debug("Error sending data to Stream Deck Plugin (exception)") + _LOGGER.debug( + "Error sending data to Stream Deck Plugin (exception)") return None if res.status_code != 200: _LOGGER.debug( @@ -119,7 +120,8 @@ class StreamDeckApi: try: info = SDInfo(rjson) except KeyError: - _LOGGER.debug("Error parsing response from %s to SDInfo", self._info_url) + _LOGGER.debug( + "Error parsing response from %s to SDInfo", self._info_url) return None return info @@ -178,7 +180,8 @@ class StreamDeckApi: try: datajson = json.loads(msg) except json.JSONDecodeError: - _LOGGER.debug("Method _on_message: Websocket message couldn't get parsed") + _LOGGER.debug( + "Method _on_message: Websocket message couldn't get parsed") return try: data = SDWebsocketMessage(datajson) @@ -193,18 +196,17 @@ class StreamDeckApi: if self._on_ws_message is not None: self._on_ws_message(data) - match data.event: - case "keyDown": - self._on_button_change(data.args, True) - case "keyUp": - self._on_button_change(data.args, False) - case "status": - self._on_ws_status_update(data.args) - case _: - _LOGGER.debug( - "Method _on_message: Unknown event from Stream Deck Plugin received (Event: %s)", - data.event, - ) + if data.event == "keyDown": + self._on_button_change(data.args, True) + elif data.event == "keyUp": + self._on_button_change(data.args, False) + elif data.event == "status": + self._on_ws_status_update(data.args) + else: + _LOGGER.debug( + "Method _on_message: Unknown event from Stream Deck Plugin received (Event: %s)", + data.event, + ) async def _websocket_loop(self): """Start the websocket client loop.""" @@ -224,7 +226,8 @@ class StreamDeckApi: ) self._on_message(data) await websocket.close() - _LOGGER.debug("Method _websocket_loop: Websocket closed") + _LOGGER.debug( + "Method _websocket_loop: Websocket closed") except WebSocketException: _LOGGER.debug( "Method _websocket_loop: Websocket client crashed. Restarting it" From b9f323244e4ff950687b805fbe371f1093c9ffca Mon Sep 17 00:00:00 2001 From: Patrick762 Date: Mon, 22 May 2023 19:55:03 +0200 Subject: [PATCH 18/22] added requirements --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 34153ff..f7ebc0a 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,9 @@ Stream Deck API Library for Home Assistant Stream Deck Integration Only compatible with separate [Stream Deck Plugin](https://github.com/Patrick762/streamdeckapi-plugin) +## Requirements +- Python 3.10 or higher + ## Dependencies - [websockets](https://pypi.org/project/websockets/) 11.0.2 From 93db43ea545e57ab1aca87738dd3bb491c60d119 Mon Sep 17 00:00:00 2001 From: Patrick762 Date: Wed, 24 May 2023 15:51:08 +0200 Subject: [PATCH 19/22] added 10s status broadcast interval --- README.md | 2 +- streamdeckapi/server.py | 44 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f7ebc0a..3e7ba35 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ # streamdeckapi Stream Deck API Library for Home Assistant Stream Deck Integration -Only compatible with separate [Stream Deck Plugin](https://github.com/Patrick762/streamdeckapi-plugin) +Only compatible with separate [Stream Deck Plugin](https://github.com/Patrick762/streamdeckapi-plugin) or the bundled server. ## Requirements - Python 3.10 or higher diff --git a/streamdeckapi/server.py b/streamdeckapi/server.py index 4d1a51e..2f4e992 100644 --- a/streamdeckapi/server.py +++ b/streamdeckapi/server.py @@ -319,6 +319,30 @@ async def websocket_broadcast(message: str): await connection.send_str(message) +async def broadcast_status(): + """Broadcast the current status of the streamdeck.""" + + # Collect data + data = { + "event": "status", + "args": { + "devices": devices, + "application": application, + "buttons": get_buttons() + } + } + + data_str = encode(data, unpicklable=False) + data_str = ( + data_str.replace('"x_pos"', '"x"') + .replace('"y_pos"', '"y"') + .replace('"platform_version"', '"platformVersion"') + ) + + # Broadcast + await websocket_broadcast(data_str) + + # # Functions # @@ -346,6 +370,8 @@ async def start_server_async(host: str = "0.0.0.0", port: int = PLUGIN_PORT): await site.start() print("Started Stream Deck API server on port", PLUGIN_PORT) + Timer(10, broadcast_status) + def get_position(deck: StreamDeck, key: int) -> SDButtonPosition: """Get the position of a key.""" @@ -479,6 +505,24 @@ def start_ssdp_server(): server.serve_forever() +class Timer: + """Timer class.""" + def __init__(self, interval, callback): + """Init timer.""" + self._interval = interval + self._callback = callback + self._task = asyncio.ensure_future(self._job()) + + async def _job(self): + await asyncio.sleep(self._interval) + await self._callback() + self._task = asyncio.ensure_future(self._job()) + + def cancel(self): + """Cancel timer.""" + self._task.cancel() + + def start(): """Entrypoint.""" init_all() From 2f801927754069e3577195978e5c1edaf9812986 Mon Sep 17 00:00:00 2001 From: Patrick762 Date: Wed, 24 May 2023 17:06:40 +0200 Subject: [PATCH 20/22] fixed python 3.9 compatibility --- README.md | 25 +++++++++++++++++++++---- streamdeckapi/__init__.py | 4 ---- streamdeckapi/server.py | 18 +++++++++--------- streamdeckapi/types.py | 2 +- 4 files changed, 31 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 3e7ba35..6b35641 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,9 @@ Stream Deck API Library for Home Assistant Stream Deck Integration Only compatible with separate [Stream Deck Plugin](https://github.com/Patrick762/streamdeckapi-plugin) or the bundled server. -## Requirements -- Python 3.10 or higher - ## Dependencies - [websockets](https://pypi.org/project/websockets/) 11.0.2 - ## Server This library also contains a server to use the streamdeck with Linux or without the official Stream Deck Software. @@ -21,3 +17,24 @@ For this to work, the following software is required: - cairo [Installation instructions for Windows](https://stackoverflow.com/a/73913080) The event `doubleTap` is not working with this server software. + +### Installation on Linux / Raspberry Pi + +Install requirements: +`sudo apt install -y libudev-dev libusb-1.0-0-dev libhidapi-libusb0 libjpeg-dev zlib1g-dev libopenjp2-7 libtiff5` + +Allow all users non-root access to Stream Deck Devices: +```bash +sudo tee /etc/udev/rules.d/10-streamdeck.rules << EOF +SUBSYSTEMS=="usb", ATTRS{idVendor}=="0fd9", GROUP="users", TAG+="uaccess" +EOF +``` + +Reload access rules: +`sudo udevadm control --reload-rules` + +Install the package: +`pip install streamdeckapi` + +Start the server: +`streamdeckapi-server` diff --git a/streamdeckapi/__init__.py b/streamdeckapi/__init__.py index 9bb9f9a..f412138 100644 --- a/streamdeckapi/__init__.py +++ b/streamdeckapi/__init__.py @@ -1,5 +1 @@ """Stream Deck API.""" - -from streamdeckapi.api import StreamDeckApi -from streamdeckapi.types import SDInfo, SDWebsocketMessage, SDSize, SDApplication, SDButton, SDButtonPosition, SDDevice -from streamdeckapi.tools import get_model diff --git a/streamdeckapi/server.py b/streamdeckapi/server.py index 2f4e992..1f2c140 100644 --- a/streamdeckapi/server.py +++ b/streamdeckapi/server.py @@ -110,7 +110,7 @@ def save_button(key: int, button: SDButton): database.close() -def get_button(key: int) -> SDButton | None: +def get_button(key: int) -> any: """Get a button from the database.""" database = sqlite3.connect(DB_FILE) cursor = database.cursor() @@ -134,7 +134,7 @@ def get_button(key: int) -> SDButton | None: return button -def get_button_by_uuid(uuid: str) -> SDButton | None: +def get_button_by_uuid(uuid: str) -> any: """Get a button from the database.""" database = sqlite3.connect(DB_FILE) cursor = database.cursor() @@ -220,7 +220,7 @@ def write_button_state(key: int, state: bool, update: str): database.close() -def get_button_state(key: int) -> tuple | None: +def get_button_state(key: int) -> any: """Load button_state from database.""" result = () database = sqlite3.connect(DB_FILE) @@ -265,7 +265,7 @@ async def api_icon_get_handler(request: web.Request): """Handle icon get requests.""" uuid = request.match_info["uuid"] button = get_button_by_uuid(uuid) - if button is None: + if not isinstance(button, SDButton): return web.Response(status=404, text="Button not found") return web.Response(text=button.svg, content_type="image/svg+xml") @@ -279,7 +279,7 @@ async def api_icon_set_handler(request: web.Request): if not body.startswith(" SDButtonPosition: async def on_key_change(_: StreamDeck, key: int, state: bool): """Handle key change callbacks.""" button = get_button(key) - if button is None: + if not isinstance(button, SDButton): return if state is True: @@ -395,7 +395,7 @@ async def on_key_change(_: StreamDeck, key: int, state: bool): db_button_state = get_button_state(key) - if db_button_state is None: + if not isinstance(db_button_state, tuple): write_button_state(key, state, now.strftime(DATETIME_FORMAT)) return @@ -431,7 +431,7 @@ def update_button_icon(uuid: str, svg: str): button = get_button_by_uuid(uuid) button_key = get_button_key(uuid) - if button is not None and button_key >= 0: + if isinstance(button, SDButton) and button_key >= 0: set_icon(deck, button_key, svg) button.svg = svg save_button(button_key, button) @@ -478,7 +478,7 @@ def init_all(): for key in range(deck.key_count()): # Only add if not already in dict button = get_button(key) - if button is None: + if not isinstance(button, SDButton): position = get_position(deck, key) new_button = SDButton( { diff --git a/streamdeckapi/types.py b/streamdeckapi/types.py index 34d103b..284564f 100644 --- a/streamdeckapi/types.py +++ b/streamdeckapi/types.py @@ -96,7 +96,7 @@ class SDWebsocketMessage: """Stream Deck Websocket Message Type.""" event: str - args: SDInfo | str | dict + args: any def __init__(self, obj: dict) -> None: """Init Stream Deck Websocket Message object.""" From e4cf962b40bf31cdfd5de0d960cc97f2795ad4d7 Mon Sep 17 00:00:00 2001 From: Patrick762 Date: Wed, 24 May 2023 17:15:22 +0200 Subject: [PATCH 21/22] removed websocket message from console output --- streamdeckapi/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/streamdeckapi/server.py b/streamdeckapi/server.py index 1f2c140..d05364b 100644 --- a/streamdeckapi/server.py +++ b/streamdeckapi/server.py @@ -314,7 +314,7 @@ async def websocket_handler(request: web.Request): async def websocket_broadcast(message: str): """Send a message to each websocket client.""" - print(f"BROADCAST to {len(websocket_connections)} clients: {message}") + print(f"Broadcast to {len(websocket_connections)} clients") for connection in websocket_connections: await connection.send_str(message) From 0dd111becb5f430360afda4752193c10f0a24494 Mon Sep 17 00:00:00 2001 From: Patrick762 Date: Wed, 24 May 2023 17:48:29 +0200 Subject: [PATCH 22/22] added check if usb port already opened --- README.md | 2 ++ streamdeckapi/server.py | 10 ++++------ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 6b35641..31f96f9 100644 --- a/README.md +++ b/README.md @@ -36,5 +36,7 @@ Reload access rules: Install the package: `pip install streamdeckapi` +Reboot your system + Start the server: `streamdeckapi-server` diff --git a/streamdeckapi/server.py b/streamdeckapi/server.py index d05364b..b39d71d 100644 --- a/streamdeckapi/server.py +++ b/streamdeckapi/server.py @@ -58,6 +58,8 @@ application: SDApplication = SDApplication( devices: list[SDDevice] = [] websocket_connections: list[web.WebSocketResponse] = [] +streamdecks: list[StreamDeck] = DeviceManager().enumerate() + # # Database # @@ -422,12 +424,12 @@ async def on_key_change(_: StreamDeck, key: int, state: bool): def update_button_icon(uuid: str, svg: str): """Update a button icon.""" - streamdecks: list[StreamDeck] = DeviceManager().enumerate() for deck in streamdecks: if not deck.is_visual(): continue - deck.open() + if not deck.is_open(): + deck.open() button = get_button_by_uuid(uuid) button_key = get_button_key(uuid) @@ -442,9 +444,6 @@ def set_icon(deck: StreamDeck, key: int, svg: str): png_bytes = io.BytesIO() cairosvg.svg2png(svg.encode("utf-8"), write_to=png_bytes) - # Debug - cairosvg.svg2png(svg.encode("utf-8"), write_to=f"icon_{key}.png") - icon = Image.open(png_bytes) image = PILHelper.create_scaled_image(deck, icon) @@ -453,7 +452,6 @@ def set_icon(deck: StreamDeck, key: int, svg: str): def init_all(): """Init Stream Deck devices.""" - streamdecks: list[StreamDeck] = DeviceManager().enumerate() print(f"Found {len(streamdecks)} Stream Deck(s).") for deck in streamdecks: