diff --git a/.gitignore b/.gitignore
index 61f2dc9..2c35a6d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,6 @@
**/__pycache__/
+build
+*.egg-info
+*.db
+*.db-journal
+*.png
diff --git a/README.md b/README.md
index 4a68c88..31f96f9 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,40 @@
# 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.
## 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 following software is required:
+
+- 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.
+
+### 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`
+
+Reboot your system
+
+Start the server:
+`streamdeckapi-server`
diff --git a/setup.py b/setup.py
index 1f95206..bfd1227 100644
--- a/setup.py
+++ b/setup.py
@@ -7,8 +7,8 @@
with codecs.open(os.path.join(here, "README.md"), encoding="utf-8") as fh:
long_description = "\n" + fh.read()
-VERSION = '0.0.2'
-DESCRIPTION = 'Stream Deck API Library'
+VERSION = "0.0.3"
+DESCRIPTION = "Stream Deck API Library"
# Setting up
setup(
@@ -21,8 +21,21 @@
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",
+ "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",
+ "cairosvg==2.7.0",
+ "ssdpy==0.4.1",
+ ],
keywords=[],
+ entry_points={
+ "console_scripts": ["streamdeckapi-server = streamdeckapi.server:start"]
+ },
classifiers=[
"Development Status :: 1 - Planning",
"Intended Audience :: Developers",
@@ -30,5 +43,5 @@
"Operating System :: Unix",
"Operating System :: MacOS :: MacOS X",
"Operating System :: Microsoft :: Windows",
- ]
+ ],
)
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/api.py b/streamdeckapi/api.py
index 8ac56c7..9165457 100644
--- a/streamdeckapi/api.py
+++ b/streamdeckapi/api.py
@@ -9,11 +9,9 @@
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 @@ def host(self) -> str:
@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
@@ -92,7 +90,8 @@ def _post_request(url: str, data: str, headers) -> None | requests.Response:
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(
@@ -121,7 +120,8 @@ async def get_info(self, in_executor: bool = True) -> None | SDInfo:
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
@@ -180,7 +180,8 @@ def _on_message(self, msg: str):
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)
@@ -195,18 +196,17 @@ def _on_message(self, msg: str):
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."""
@@ -226,7 +226,8 @@ async def _websocket_loop(self):
)
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"
diff --git a/streamdeckapi/const.py b/streamdeckapi/const.py
new file mode 100644
index 0000000..98f75b3
--- /dev/null
+++ b/streamdeckapi/const.py
@@ -0,0 +1,10 @@
+"""Stream Deck API const."""
+
+PLUGIN_PORT = 6153
+PLUGIN_INFO = "/sd/info"
+PLUGIN_ICON = "/sd/icon"
+
+DB_FILE = "streamdeckapi.db"
+SD_SSDP = "urn:home-assistant-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
new file mode 100644
index 0000000..b39d71d
--- /dev/null
+++ b/streamdeckapi/server.py
@@ -0,0 +1,537 @@
+"""Stream Deck API Server."""
+
+import re
+import io
+import asyncio
+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
+from aiohttp import web
+from StreamDeck.DeviceManager import DeviceManager
+from StreamDeck.Devices.StreamDeck import StreamDeck
+from StreamDeck.ImageHelpers import PILHelper
+import cairosvg
+from PIL import Image
+from ssdpy import SSDPServer
+
+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
+
+
+DEFAULT_ICON = re.sub(
+ "\r\n|\n|\r",
+ "",
+ """
+
+ """,
+)
+
+
+application: SDApplication = SDApplication(
+ {
+ "font": "",
+ "language": "",
+ "platform": platform.system(),
+ "platformVersion": platform.version(),
+ "version": "0.0.1",
+ }
+)
+devices: list[SDDevice] = []
+websocket_connections: list[web.WebSocketResponse] = []
+
+streamdecks: list[StreamDeck] = DeviceManager().enumerate()
+
+#
+# Database
+#
+
+database_first = sqlite3.connect(DB_FILE)
+table_cursor = database_first.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.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()
+
+
+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)
+ 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()
+ database.close()
+
+
+def get_button(key: int) -> any:
+ """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}")
+ 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()
+ database.close()
+ return button
+
+
+def get_button_by_uuid(uuid: str) -> any:
+ """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}\"")
+ 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()
+ 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()
+ if len(matching_buttons) == 0:
+ return -1
+ 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()
+ 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()
+ 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) -> any:
+ """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
+#
+
+
+async def api_info_handler(_: web.Request):
+ """Handle info requests."""
+ json_data = encode(
+ {"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"')
+ )
+ return web.Response(text=json_data, content_type="application/json")
+
+
+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 not isinstance(button, SDButton):
+ 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):
+ """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()
+ if not body.startswith("