feat: initial implementation of StreamDock Home Assistant integration

This commit introduces a complete integration for Mirabox StreamDock
devices with Home Assistant, allowing users to control entities and
monitor states directly from the hardware.

Key features included:
- Support for multiple Mirabox models: N1, N3, N4, N4Pro, XL, M3, M18,
  K1Pro, and various 293 variants.
- Home Assistant WebSocket integration for real-time entity updates and
  service execution.
- Dynamic LCD key rendering with support for custom icons, labels, and
  entity-state aware colors.
- Input handling for physical buttons and rotary encoders (knobs).
- Multi-page navigation support with configurable cycle keys and knobs.
- Cross-platform transport layer using a custom HID library.
- Configuration system using YAML with page-based layouts.
- Linux udev rules for non-root USB access.
This commit is contained in:
Karl 2026-05-07 19:14:07 +01:00
commit 06f4c2768c
33 changed files with 6287 additions and 0 deletions

10
.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
venv/
__pycache__/
*.pyc
*.pyo
.DS_Store
*.egg-info/
dist/
build/
rotated_*.jpg
config.yaml

53
99-streamdock.rules Normal file
View File

@ -0,0 +1,53 @@
# StreamDock USB device permission configuration
# This file sets appropriate USB permissions for StreamDock devices, allowing ordinary users to access
# StreamDock 293 series
SUBSYSTEM=="usb", ATTR{idVendor}=="5500", ATTR{idProduct}=="1001", MODE="0666", GROUP="plugdev"
SUBSYSTEM=="usb", ATTR{idVendor}=="5548", ATTR{idProduct}=="6670", MODE="0666", GROUP="plugdev"
SUBSYSTEM=="usb", ATTR{idVendor}=="6603", ATTR{idProduct}=="1005", MODE="0666", GROUP="plugdev"
SUBSYSTEM=="usb", ATTR{idVendor}=="6603", ATTR{idProduct}=="1006", MODE="0666", GROUP="plugdev"
SUBSYSTEM=="usb", ATTR{idVendor}=="6603", ATTR{idProduct}=="1010", MODE="0666", GROUP="plugdev"
SUBSYSTEM=="usb", ATTR{idVendor}=="6603", ATTR{idProduct}=="1014", MODE="0666", GROUP="plugdev"
# StreamDock N3 series
SUBSYSTEM=="usb", ATTR{idVendor}=="6603", ATTR{idProduct}=="1002", MODE="0666", GROUP="plugdev"
SUBSYSTEM=="usb", ATTR{idVendor}=="6603", ATTR{idProduct}=="1003", MODE="0666", GROUP="plugdev"
SUBSYSTEM=="usb", ATTR{idVendor}=="6602", ATTR{idProduct}=="1000", MODE="0666", GROUP="plugdev"
SUBSYSTEM=="usb", ATTR{idVendor}=="6602", ATTR{idProduct}=="1002", MODE="0666", GROUP="plugdev"
SUBSYSTEM=="usb", ATTR{idVendor}=="6602", ATTR{idProduct}=="1003", MODE="0666", GROUP="plugdev"
SUBSYSTEM=="usb", ATTR{idVendor}=="6602", ATTR{idProduct}=="2929", MODE="0666", GROUP="plugdev"
SUBSYSTEM=="usb", ATTR{idVendor}=="1500", ATTR{idProduct}=="3001", MODE="0666", GROUP="plugdev"
# StreamDock N4 series
SUBSYSTEM=="usb", ATTR{idVendor}=="6602", ATTR{idProduct}=="1001", MODE="0666", GROUP="plugdev"
SUBSYSTEM=="usb", ATTR{idVendor}=="6603", ATTR{idProduct}=="1007", MODE="0666", GROUP="plugdev"
# StreamDock N1 series
SUBSYSTEM=="usb", ATTR{idVendor}=="6603", ATTR{idProduct}=="1011", MODE="0666", GROUP="plugdev"
SUBSYSTEM=="usb", ATTR{idVendor}=="6603", ATTR{idProduct}=="1000", MODE="0666", GROUP="plugdev"
# StreamDock N4Pro series
SUBSYSTEM=="usb", ATTR{idVendor}=="5548", ATTR{idProduct}=="1008", MODE="0666", GROUP="plugdev"
SUBSYSTEM=="usb", ATTR{idVendor}=="5548", ATTR{idProduct}=="1021", MODE="0666", GROUP="plugdev"
# StreamDock XL series
SUBSYSTEM=="usb", ATTR{idVendor}=="5548", ATTR{idProduct}=="1028", MODE="0666", GROUP="plugdev"
SUBSYSTEM=="usb", ATTR{idVendor}=="5548", ATTR{idProduct}=="1031", MODE="0666", GROUP="plugdev"
# StreamDock M18 series
SUBSYSTEM=="usb", ATTR{idVendor}=="6603", ATTR{idProduct}=="1009", MODE="0666", GROUP="plugdev"
SUBSYSTEM=="usb", ATTR{idVendor}=="6603", ATTR{idProduct}=="1012", MODE="0666", GROUP="plugdev"
# StreamDock M3 series
SUBSYSTEM=="usb", ATTR{idVendor}=="5548", ATTR{idProduct}=="1020", MODE="0666", GROUP="plugdev"
# StreamDock K1Pro series
SUBSYSTEM=="usb", ATTR{idVendor}=="6603", ATTR{idProduct}=="1015", MODE="0666", GROUP="plugdev"
SUBSYSTEM=="usb", ATTR{idVendor}=="6603", ATTR{idProduct}=="1019", MODE="0666", GROUP="plugdev"
# HID device permission configuration (for HID interface access)
KERNEL=="hidraw*", ATTRS{idVendor}=="5500", MODE="0666", GROUP="plugdev"
KERNEL=="hidraw*", ATTRS{idVendor}=="5548", MODE="0666", GROUP="plugdev"
KERNEL=="hidraw*", ATTRS{idVendor}=="6603", MODE="0666", GROUP="plugdev"
KERNEL=="hidraw*", ATTRS{idVendor}=="6602", MODE="0666", GROUP="plugdev"
KERNEL=="hidraw*", ATTRS{idVendor}=="1500", MODE="0666", GROUP="plugdev"

214
README.md Normal file
View File

@ -0,0 +1,214 @@
# StreamDock Home Assistant Integration
Displays Home Assistant entity states on Mirabox StreamDock LCD keys and triggers HA services on button/knob presses.
Tested on **StreamDock N3** (6 LCD keys + 3 knobs). Should work with other Mirabox StreamDock models (N1, N4, N4Pro, M3, XL, 293 variants, K1Pro).
## Requirements
- Python 3.11+
- A Mirabox StreamDock device
- Home Assistant with a long-lived access token
- Linux (x86_64 or aarch64), macOS, or Windows
### System packages (Debian/Raspberry Pi OS)
```bash
sudo apt install python3-venv fonts-dejavu-core
```
For USB device permissions (avoid running as root):
```bash
sudo cp 99-streamdock.rules /etc/udev/rules.d/
sudo udevadm control --reload-rules
sudo udevadm trigger
# Then unplug and re-plug the StreamDock
```
### Python packages
```bash
python3 -m venv venv
source venv/bin/activate
pip install Pillow pyyaml HomeAssistant-API pyudev
```
## Setup
1. Create your config from the example:
```bash
cp config.example.yaml config.yaml
```
2. Edit `config.yaml` with your HA connection details:
```yaml
homeassistant:
url: "ws://YOUR_HA_IP:8123/api/websocket"
token: "YOUR_LONG_LIVED_ACCESS_TOKEN"
```
Get a token from Home Assistant: **Settings → People → Your User → Security → Long-Lived Access Tokens**
3. Run:
```bash
source venv/bin/activate
python3 streamdock_ha.py
```
## Configuration Reference
Full `config.yaml` structure:
```yaml
homeassistant:
url: "ws://homeassistant.local:8123/api/websocket"
token: "YOUR_TOKEN"
navigation:
page_cycle: "button_8" # which button cycles pages (middle of 3 bottom buttons)
knob_left: "knob_1" # knob whose left-rotation goes to previous page
knob_right: "knob_2" # knob whose right-rotation goes to next page
knob_press_left: "knob_1" # knob whose press goes to previous page
knob_press_right: "knob_2"# knob whose press goes to next page
pages:
- name: "Lights"
keys:
1: # LCD key number (1-6 on N3)
entity: "light.living_room" # HA entity ID
service: "toggle" # HA service to call on press
icon: "lightbulb" # icon name (see below)
label: "Living" # short label shown on key
2:
entity: "switch.kitchen"
service: "toggle"
icon: "power"
label: "Kitchen"
3: # sensor with no service = display-only
entity: "sensor.temperature"
icon: "thermometer"
label: "Temp"
knobs: # per-page knob bindings
knob_3:
rotate_left:
entity: "script.volume_down"
service: "turn_on"
rotate_right:
entity: "script.volume_up"
service: "turn_on"
press:
entity: "script.mute"
service: "turn_on"
- name: "Climate"
keys:
1:
entity: "climate.bedroom"
service: "toggle"
icon: "thermostat"
label: "Bedroom"
```
### Key Numbers
| Keys | N3 | N1 | N4/N4Pro | XL |
|------|----|----|-----------|-----|
| LCD keys | 1-6 | 1-15 | 6-15 | 1-32 |
Bottom buttons (no LCD): keys 7, 8, 9 (N3). Key 8 is the middle one.
### Knob Names
| Knob | N3 position |
|------|------------|
| `knob_1` | Bottom-left |
| `knob_2` | Bottom-right |
| `knob_3` | Top |
### Navigation Button/ Knob References
Buttons can be referenced as `button_7`, `button_8`, `button_9`, `key_7`, etc., or just the number `7`, `8`, `9`.
Knobs: `knob_1`, `knob_2`, `knob_3`, `knob_4`, or aliases `left`, `right`, `top`, `bottom_left`, `bottom_right`.
### Available Icons
`lightbulb`, `power`, `thermometer`, `thermostat`, `play`, `film`, `door`, `lock`, `speaker`, `fan`, or `default`
Icons render with distinct "on" and "off" colors. For example, `lightbulb` shows yellow when on, gray when off. `door` shows green when open, red when closed.
### Entity State Detection
The app automatically detects on/off states from HA entity states:
- **On**: `on`, `playing`, `open`, `unlocked`, `home`, `heat`, `cool`, `auto`
- **Off**: everything else
Sensor values (temperature, humidity, etc.) are displayed as-is on the key.
## Running on Raspberry Pi
The included `libtransport_arm64.so` supports aarch64 Linux (Pi 3, 4, 5, Zero 2 W). The library loader auto-detects the architecture.
```bash
# On Raspberry Pi OS
sudo apt install python3-venv fonts-dejavu-core
python3 -m venv venv
source venv/bin/activate
pip install Pillow pyyaml HomeAssistant-API pyudev
# USB permissions
sudo cp 99-streamdock.rules /etc/udev/rules.d/
sudo udevadm control --reload-rules && sudo udevadm trigger
# Configure and run
cp config.example.yaml config.yaml
nano config.yaml
python3 streamdock_ha.py
```
### Autostart on Boot (systemd)
Create `/etc/systemd/system/streamdock-ha.service`:
```ini
[Unit]
Description=StreamDock Home Assistant
After=network.target
[Service]
Type=simple
User=karl
WorkingDirectory=/home/karl/streamdocklinux
ExecStart=/home/karl/streamdocklinux/venv/bin/python3 streamdock_ha.py
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
```
```bash
sudo systemctl enable streamdock-ha
sudo systemctl start streamdock-ha
```
## Architecture
```
streamdock_ha.py - Main app: HA WebSocket + StreamDock event loop
key_renderer.py - Dynamic key image generation (icons + state text)
config.py - YAML config loader
config.yaml - User configuration (gitignored)
config.example.yaml - Example configuration
99-streamdock.rules - udev rules for USB permissions
StreamDock/ - Mirabox StreamDock Device SDK (Python + C transport lib)
```
## License
This project includes the Mirabox StreamDock Device SDK. See `StreamDock/` for its license terms.

249
StreamDock/DeviceManager.py Normal file
View File

@ -0,0 +1,249 @@
import platform
import threading
import time
from typing import Optional
# import pywinusb.hid as hid
from .ProductIDs import USBVendorIDs, USBProductIDs, g_products
from .Transport.LibUSBHIDAPI import LibUSBHIDAPI
# Platform-specific imports
if platform.system() == "Linux":
import pyudev
elif platform.system() == "Windows":
try:
import wmi
import pythoncom
WINDOWS_SUPPORT = True
except ImportError:
print("Warning: wmi module not installed, using polling mode")
WINDOWS_SUPPORT = False
elif platform.system() == "Darwin":
# macOS specific imports can be added here if needed
pass
class DeviceManager:
streamdocks = list()
@staticmethod
def _get_transport(transport):
return LibUSBHIDAPI()
def __init__(self, transport=None):
self.transport = self._get_transport(transport)
def enumerate(self)->list:
# CRITICAL: Clear old list to avoid stale references
self.streamdocks.clear()
products = g_products
for vid, pid, class_type in products:
found_devices = self.transport.enumerate_devices(vendor_id = vid, product_id = pid)
# Create a dedicated LibUSBHIDAPI instance per device
# CRITICAL: Pass device info to transport for proper resource management
for d in found_devices:
# Create device_info structure from dict
device_info = LibUSBHIDAPI.create_device_info_from_dict(d)
device_transport = LibUSBHIDAPI(device_info)
self.streamdocks.append(class_type(device_transport, d))
return self.streamdocks
def listen(self):
"""
Listen for device hotplug events, cross-platform
"""
products = g_products
system = platform.system()
if system == "Linux":
self._listen_linux(products)
elif system == "Windows":
self._listen_windows(products)
elif system == "Darwin":
self._listen_macos(products)
else:
print(f"Unsupported operating system: {system}")
def _listen_linux(self, products):
"""Linux uses pyudev to listen for device events"""
context = pyudev.Context()
monitor = pyudev.Monitor.from_netlink(context)
monitor.filter_by(subsystem='usb')
for device in iter(monitor.poll, None):
self._handle_device_event(device.action, device, products)
def _listen_windows(self, products):
"""Windows uses WMI to listen for device events"""
if not WINDOWS_SUPPORT:
print("WMI unavailable, using polling mode")
self._fallback_polling(products)
return
try:
pythoncom.CoInitialize()
c = wmi.WMI()
# Listen for device connection events
watcher = c.Win32_DeviceChangeEvent.watch_for(
EventType=2 # Device connected
)
while True:
try:
event = watcher()
if event.EventType == 2: # Device connected
self._check_new_devices_windows(products)
elif event.EventType == 3: # Device disconnected
self._check_removed_devices_windows(products)
except Exception as e:
print(f"Windows device listener error: {e}")
time.sleep(1)
except Exception as e:
print(f"Windows WMI initialization failed: {e}")
# Fall back to polling mode
self._fallback_polling(products)
finally:
pythoncom.CoUninitialize()
def _listen_macos(self, products):
"""macOS uses polling to listen for device events"""
self._fallback_polling(products)
def _fallback_polling(self, products):
"""Fall back to polling mode for systems without real-time monitoring"""
# print("Using polling mode to monitor device changes...")
current_devices = set()
# Initialize current device list
for vid, pid, _ in products:
devices = self.transport.enumerate_devices(vendor_id=vid, product_id=pid)
for device in devices:
current_devices.add(device['path'])
while True:
try:
new_devices = set()
for vid, pid, _ in products:
devices = self.transport.enumerate_devices(vendor_id=vid, product_id=pid)
for device in devices:
new_devices.add(device['path'])
# Check for newly added devices
added_devices = new_devices - current_devices
for device_path in added_devices:
print(f"[add] path: {device_path}")
self._handle_device_addition(device_path, products)
# Check for removed devices
removed_devices = current_devices - new_devices
for device_path in removed_devices:
print(f"[remove] path: {device_path}")
self._handle_device_removal(device_path)
current_devices = new_devices
time.sleep(2) # Check every 2 seconds
except Exception as e:
print(f"Polling listener error: {e}")
time.sleep(5)
def _handle_device_event(self, action, device, products):
"""Handle device events (Linux)"""
if action not in ['add', 'remove']:
return
if action == 'remove':
for willRemoveDevice in self.streamdocks:
if device.device_path.find(willRemoveDevice.getPath()) != -1:
print("[remove] path: " + willRemoveDevice.getPath())
self.streamdocks.remove(willRemoveDevice)
break
vendor_id_str = device.get('ID_VENDOR_ID')
product_id_str = device.get('ID_MODEL_ID')
if not vendor_id_str or not product_id_str:
return
try:
vendor_id = int(vendor_id_str, 16)
product_id = int(product_id_str, 16)
except ValueError:
return
for vid, pid, class_type in products:
if vendor_id == vid and product_id == pid:
if action == 'add':
dev_path = device.device_path.split('/')[-1] + ":1.0"
full_path = dev_path
found_devices = self.transport.enumerate_devices(vendor_id=vid, product_id=pid)
for d in found_devices:
if d['path'].endswith(full_path):
print("[add] path:", d['path'])
newDevice = class_type(self.transport, d)
self.streamdocks.append(newDevice)
newDevice.open()
# your reconnect logic like the next two line
# newDevice.set_key_image(1, "../img/tiga64.png")
# newDevice.refresh()
break
def _check_new_devices_windows(self, products):
"""Check for new devices on Windows"""
for vid, pid, class_type in products:
found_devices = self.transport.enumerate_devices(vendor_id=vid, product_id=pid)
for device_info in found_devices:
device_path = device_info['path']
# Check whether the device already exists
exists = any(device.getPath() == device_path for device in self.streamdocks)
if not exists:
print(f"[add] path: {device_path}")
newDevice = class_type(self.transport, device_info)
self.streamdocks.append(newDevice)
newDevice.open()
# your reconnect logic like the next two line
# newDevice.set_key_image(1, "../img/tiga64.png")
# newDevice.refresh()
def _check_removed_devices_windows(self, products):
"""Check for removed devices on Windows"""
current_paths = set()
for vid, pid, _ in products:
found_devices = self.transport.enumerate_devices(vendor_id=vid, product_id=pid)
for device_info in found_devices:
current_paths.add(device_info['path'])
# Remove devices that no longer exist
devices_to_remove = []
for device in self.streamdocks:
if device.getPath() not in current_paths:
devices_to_remove.append(device)
for device in devices_to_remove:
print(f"[remove] path: {device.getPath()}")
self.streamdocks.remove(device)
def _handle_device_addition(self, device_path, products):
"""Handle device addition events (polling mode)"""
for vid, pid, class_type in products:
found_devices = self.transport.enumerate_devices(vendor_id=vid, product_id=pid)
for device_info in found_devices:
if device_info['path'] == device_path:
newDevice = class_type(self.transport, device_info)
self.streamdocks.append(newDevice)
newDevice.open()
# your reconnect logic like the next two line
# newDevice.set_key_image(1, "../img/tiga64.png")
# newDevice.refresh()
break
def _handle_device_removal(self, device_path):
"""Handle device removal events (polling mode)"""
devices_to_remove = []
for device in self.streamdocks:
if device.getPath() == device_path:
devices_to_remove.append(device)
for device in devices_to_remove:
self.streamdocks.remove(device)

225
StreamDock/Devices/K1Pro.py Normal file
View File

@ -0,0 +1,225 @@
from StreamDock.FeatrueOption import device_type
from .StreamDock import StreamDock
from ..InputTypes import InputEvent, ButtonKey, EventType, KnobId, Direction
from PIL import Image
import ctypes
import ctypes.util
import os, io
from ..ImageHelpers.PILHelper import *
import random
class K1Pro(StreamDock):
"""K1Pro device class - supports 6 keys and 3 knobs"""
KEY_COUNT = 6
KEY_MAP = False
# Image key mapping: logical key -> hardware key (for setting images)
_IMAGE_KEY_MAP = {
ButtonKey.KEY_1: 0x05,
ButtonKey.KEY_2: 0x03,
ButtonKey.KEY_3: 0x01,
ButtonKey.KEY_4: 0x06,
ButtonKey.KEY_5: 0x04,
ButtonKey.KEY_6: 0x02,
}
# Reverse mapping: hardware key -> logical key (for event decoding)
_HW_TO_LOGICAL_KEY = {v: k for k, v in _IMAGE_KEY_MAP.items()}
def __init__(self, transport1, devInfo):
super().__init__(transport1, devInfo)
def get_image_key(self, logical_key: ButtonKey) -> int:
"""
Convert logical key value to hardware key value (for setting images)
Args:
logical_key: Logical key enum
Returns:
int: Hardware key value
"""
if logical_key in self._IMAGE_KEY_MAP:
return self._IMAGE_KEY_MAP[logical_key]
raise ValueError(f"K1Pro: Unsupported key {logical_key}")
def decode_input_event(self, hardware_code: int, state: int) -> InputEvent:
"""
Decode hardware event codes into a unified InputEvent
Hardware code mapping:
- Keys: 0x05, 0x03, 0x01, 0x06, 0x04, 0x02
- Knob 1 press: 0x25
- Knob 2 press: 0x30
- Knob 3 press: 0x31
- Knob 1 rotation: 0x50 (left), 0x51 (right)
- Knob 2 rotation: 0x60 (left), 0x61 (right)
- Knob 3 rotation: 0x90 (left), 0x91 (right)
"""
# Handle state value: 0x02=release, 0x01=press
normalized_state = 1 if state == 0x01 else 0
# Regular button events
if hardware_code in self._HW_TO_LOGICAL_KEY:
return InputEvent(
event_type=EventType.BUTTON,
key=self._HW_TO_LOGICAL_KEY[hardware_code],
state=normalized_state,
)
# Knob press event
knob_press_map = {
0x25: KnobId.KNOB_1,
0x30: KnobId.KNOB_2,
0x31: KnobId.KNOB_3,
}
if hardware_code in knob_press_map:
return InputEvent(
event_type=EventType.KNOB_PRESS,
knob_id=knob_press_map[hardware_code],
state=normalized_state,
)
# Knob rotation event
knob_rotate_map = {
0x50: (KnobId.KNOB_1, Direction.LEFT),
0x51: (KnobId.KNOB_1, Direction.RIGHT),
0x60: (KnobId.KNOB_2, Direction.LEFT),
0x61: (KnobId.KNOB_2, Direction.RIGHT),
0x90: (KnobId.KNOB_3, Direction.LEFT),
0x91: (KnobId.KNOB_3, Direction.RIGHT),
}
if hardware_code in knob_rotate_map:
knob_id, direction = knob_rotate_map[hardware_code]
return InputEvent(
event_type=EventType.KNOB_ROTATE, knob_id=knob_id, direction=direction
)
# Unknown event
return InputEvent(event_type=EventType.UNKNOWN)
# Set device screen brightness
def set_brightness(self, percent):
return self.transport.setBrightness(percent)
def set_touchscreen_image(self, path):
"""Background setting not supported"""
return 0
# Set device key icon image 64 * 64
def set_key_image(self, key, path):
try:
if isinstance(key, int):
if key not in range(1, 7):
print(f"key '{key}' out of range. you should set (1 ~ 6)")
return -1
logical_key = ButtonKey(key)
else:
logical_key = key
if not os.path.exists(path):
print(f"Error: The image file '{path}' does not exist.")
return -1
# Get hardware key value
hardware_key = self.get_image_key(logical_key)
# open formatter
image = Image.open(path)
image = to_native_key_format(self, image)
temp_image_path = (
"rotated_key_image_" + str(random.randint(9999, 999999)) + ".jpg"
)
image.save(temp_image_path)
# encode send
path_bytes = temp_image_path.encode("utf-8")
c_path = ctypes.c_char_p(path_bytes)
res = self.transport.setKeyImgDualDevice(c_path, hardware_key)
os.remove(temp_image_path)
return res
except Exception as e:
print(f"Error: {e}")
return -1
# TODO
def set_key_imageData(self, key, path):
pass
# Get device serial number
def get_serial_number(self):
return self.serial_number
def key_image_format(self):
return {
"size": (64, 64),
"format": "JPEG",
"rotation": -90,
"flip": (False, False),
}
def touchscreen_image_format(self):
return {
"size": (800, 480),
"format": "JPEG",
"rotation": 180,
"flip": (False, False),
}
# Set device parameters
def set_device(self):
self.transport.set_report_size(513, 1025, 0)
self.transport.set_report_id(0x04)
self.feature_option.deviceType = device_type.k1pro
pass
def set_keyboard_backlight_brightness(self, brightness):
"""
Set the keyboard backlight brightness.
Args:
brightness: Brightness value (0-6)
"""
self.transport.set_keyboard_backlight_brightness(brightness)
def set_keyboard_lighting_effects(self, effect: int):
"""
Set the keyboard lighting effect.
0 is static lighting.
Args:
effect: Effect mode identifier (0-9)
"""
if(effect==0):
self.set_keyboard_lighting_speed(0)
self.transport.set_keyboard_lighting_effects(effect)
def set_keyboard_lighting_speed(self, speed: int):
"""
Set the keyboard lighting effect speed.
Args:
speed: Speed value for lighting effects (0-7)
"""
self.transport.set_keyboard_lighting_speed(speed)
def set_keyboard_rgb_backlight(self, red: int, green: int, blue: int):
"""
Set the keyboard RGB backlight color.
Args:
red: Red component (0-255)
green: Green component (0-255)
blue: Blue component (0-255)
"""
self.transport.set_keyboard_rgb_backlight(red, green, blue)
def keyboard_os_mode_switch(self, os_mode: int):
"""
Set the keyboard OS mode.
Args:
os_mode: OS mode identifier
"""
self.transport.keyboard_os_mode_switch(os_mode)

View File

@ -0,0 +1,600 @@
import platform
import threading
import time
from abc import ABC, ABCMeta, abstractmethod
import threading
from abc import ABC, ABCMeta, abstractmethod
import ctypes
import ctypes.util
import threading
import traceback
from typing import Optional
from ..FeatrueOption import FeatrueOption, device_type
from ..Transport.LibUSBHIDAPI import LibUSBHIDAPI
from ..InputTypes import InputEvent, ButtonKey
class TransportError(Exception):
"""Custom exception type for transport errors"""
def __init__(self, message, code=None):
super().__init__(message)
self.code = code # Optional error code
def __str__(self):
if self.code:
return f"[Error Code {self.code}] {super().__str__()}"
return super().__str__()
class StreamDock(ABC):
"""
Represents a physically attached StreamDock device.
"""
KEY_COUNT = 0
KEY_COLS = 0
KEY_ROWS = 0
KEY_PIXEL_WIDTH = 0
KEY_PIXEL_HEIGHT = 0
KEY_IMAGE_FORMAT = ""
KEY_FLIP = (False, False)
KEY_ROTATION = 0
KEY_MAP = False
TOUCHSCREEN_PIXEL_WIDTH = 0
TOUCHSCREEN_PIXEL_HEIGHT = 0
TOUCHSCREEN_IMAGE_FORMAT = ""
TOUCHSCREEN_FLIP = (False, False)
TOUCHSCREEN_ROTATION = 0
DIAL_COUNT = 0
DECK_TYPE = ""
DECK_VISUAL = False
DECK_TOUCH = False
transport: LibUSBHIDAPI
screenlicent = None
__metaclass__ = ABCMeta
__seconds = 300
feature_option: FeatrueOption
def __init__(self, transport1: LibUSBHIDAPI, devInfo):
self.transport = transport1
self.vendor_id = devInfo["vendor_id"]
self.product_id = devInfo["product_id"]
self.path = devInfo["path"]
self.serial_number = devInfo.get("serial_number", "")
self.firmware_version = ""
self.read_thread = None
self.run_read_thread = False
self.feature_option = FeatrueOption()
self.key_callback = None
# CRITICAL: Add lock to protect callback access in multi-threaded environment
self._callback_lock = threading.Lock()
# Heartbeat thread for keeping device alive
self.heartbeat_thread = None
self.run_heartbeat_thread = False
# self.update_lock = threading.RLock()
# self.screenlicent=threading.Timer(self.__seconds,self.screen_Off)
# self.screenlicent.start()
def __del__(self):
"""
Delete handler for the StreamDock, automatically closing the transport
if it is currently open and terminating the transport reader thread.
CRITICAL: This is called during garbage collection which may happen during
interpreter shutdown. We need to be extremely careful to avoid calling
C code during shutdown as it can cause segmentation faults.
"""
import sys
# CRITICAL: Don't call C code during interpreter shutdown
if sys.is_finalizing():
# During interpreter shutdown, skip cleanup to avoid segfault
# The OS will clean up resources when process exits
return
try:
# Stop the reader thread (safe operation)
self.run_read_thread = False
if self.read_thread and self.read_thread.is_alive():
self.read_thread.join(timeout=0.5) # Short timeout during __del__
except (TransportError, ValueError, RuntimeError):
pass
try:
# Stop the heartbeat thread (safe operation)
self.run_heartbeat_thread = False
if self.heartbeat_thread and self.heartbeat_thread.is_alive():
self.heartbeat_thread.join(timeout=0.5) # Short timeout during __del__
except (TransportError, ValueError, RuntimeError):
pass
try:
# Close transport - this may call C code but we checked is_finalizing above
self.close()
except TransportError:
pass
def __enter__(self):
"""
Enter handler for the StreamDock, taking the exclusive update lock on
the deck. This can be used in a `with` statement to ensure that only one
thread is currently updating the deck, even if it is doing multiple
operations (e.g. setting the image on multiple keys).
"""
# self.update_lock.acquire()
def __exit__(self, type, value, traceback):
"""
Exit handler for the StreamDock, releasing the exclusive update lock on
the deck.
"""
# self.update_lock.release()
# Open device
def open(self):
res1 = self.transport.open(bytes(self.path, "utf-8"))
self._setup_reader(self._read)
# Start heartbeat with delay to avoid Linux libusb deadlock
# The read thread needs time to initialize before heartbeat starts
time.sleep(0.1)
self._start_heartbeat()
# macOS need to get firmware version after opening device
if platform.system() == "Darwin":
self.firmware_version = self.transport.get_firmware_version()
return res1
# Initialize
def init(self):
self.set_device()
self.wakeScreen()
self.set_brightness(100)
self.clearAllIcon()
if platform.system() != "Darwin":
self.firmware_version = self.transport.get_firmware_version()
self.refresh()
# Set device parameters
@abstractmethod
def set_device(self):
pass
# Set device LED brightness
def set_led_brightness(self, percent):
if self.feature_option.hasRGBLed:
return self.transport.set_led_brightness(percent)
# Set device LED color
def set_led_color(self, r, g, b):
if self.feature_option.hasRGBLed:
return self.transport.set_led_color(self.feature_option.ledCounts, r, g, b)
# Reset device LED effects
def reset_led_effect(self):
if self.feature_option.hasRGBLed:
return self.transport.reset_led_color()
# Close device
def close(self):
"""
Close the device and release all resources.
CRITICAL: This method must be called before the object is destroyed to ensure
clean shutdown of the C library and prevent segmentation faults.
"""
# print(f"[DEBUG] Closing device: {self.path}")
# CRITICAL: Stop heartbeat thread first
self.run_heartbeat_thread = False
if self.heartbeat_thread and self.heartbeat_thread.is_alive():
try:
self.heartbeat_thread.join(timeout=2.0)
except Exception as e:
print(f"[WARNING] Error while waiting for heartbeat thread to exit: {e}", flush=True)
# CRITICAL: Stop reader thread first and wait for it to finish
self.run_read_thread = False
if self.read_thread and self.read_thread.is_alive():
try:
# Give thread time to exit naturally
self.read_thread.join(timeout=2.0) # Wait up to 2 seconds
if self.read_thread.is_alive():
print("[WARNING] Read thread did not exit in time", flush=True)
except Exception as e:
print(f"[WARNING] Error while waiting for read thread to exit: {e}", flush=True)
# Send disconnect command (may fail if device already disconnected)
try:
self.disconnected()
except Exception as e:
print(f"[WARNING] Error sending disconnect command: {e}", flush=True)
# CRITICAL: Close transport properly to release HID device
try:
self.transport.close()
except Exception as e:
print(f"[WARNING] Error closing transport: {e}", flush=True)
# Clear callback to break any circular references
with self._callback_lock:
self.key_callback = None
# print("[DEBUG] Device closed")
# Disconnect and clear all displays
def disconnected(self):
self.transport.disconnected()
# Clear a specific key icon
def clearIcon(self, index):
origin = index
if origin not in range(1, self.KEY_COUNT + 1):
print(f"key '{origin}' out of range. you should set (1 ~ {self.KEY_COUNT})")
return -1
logical_key = ButtonKey(origin) if isinstance(origin, int) else origin
hardware_key = self.get_image_key(logical_key)
self.transport.keyClear(hardware_key)
# Clear all key icons
def clearAllIcon(self):
self.transport.keyAllClear()
# Wake the screen
def wakeScreen(self):
self.transport.wakeScreen()
# Refresh the device display
def refresh(self):
self.transport.refresh()
# Get device path
def getPath(self):
return self.path
# Get device feedback data
def read(self):
"""
:argtypes: byte array to store info; recommended length 1024
"""
data = self.transport.read_(1024)
return data
# Continuously check for device feedback; recommended to run in a thread
def whileread(self):
"""
@deprecated Use the built-in async callback mechanism instead of calling this directly
"""
from ..InputTypes import EventType
while 1:
try:
data = self.read()
if data != None and len(data) >= 11:
try:
event = self.decode_input_event(data[9], data[10])
if event.event_type == EventType.BUTTON:
action = "pressed" if event.state == 1 else "released"
print(
f"Key {event.key.value if event.key else '?'} was {action}"
)
elif event.event_type == EventType.KNOB_ROTATE:
print(
f"Knob {event.knob_id.value if event.knob_id else '?'} rotated {event.direction.value if event.direction else '?'}"
)
elif event.event_type == EventType.KNOB_PRESS:
action = "pressed" if event.state == 1 else "released"
print(
f"Knob {event.knob_id.value if event.knob_id else '?'} was {action}"
)
elif event.event_type == EventType.SWIPE:
print(
f"Swipe gesture: {event.direction.value if event.direction else '?'}"
)
except Exception:
pass
# self.transport.deleteRead()
except Exception as e:
print("Error occurred:")
traceback.print_exc() # Print detailed exception info
break
# # Screen off
# def screen_Off(self):
# res=self.transport.screen_Off()
# self.reset_Countdown(self.__seconds)
# return res
# # Wake screen
# def screen_On(self):
# return self.transport.screen_On()
# # Set timer interval
# def set_seconds(self, data):
# self.__seconds = data
# self.reset_Countdown(self.__seconds)
# # Restart timer
# def reset_Countdown(self, data):
# if self.screenlicent is not None:
# self.screenlicent.cancel()
# if hasattr(self, 'screen_Off'):
# self.screenlicent = threading.Timer(data, self.screen_Off)
# self.screenlicent.start()
def get_serial_number(self):
"""Return the device serial number."""
return self.serial_number
@abstractmethod
def set_key_image(self, key, path) -> int | None:
pass
# @abstractmethod
# def set_key_imageData(self, key, image, width=126, height=126):
# pass
@abstractmethod
def set_brightness(self, percent):
pass
@abstractmethod
def set_touchscreen_image(self, path) -> int | None:
pass
def set_background_image(self, path) -> int | None:
self.set_touchscreen_image(path)
@abstractmethod
def get_image_key(self, logical_key: ButtonKey) -> int:
"""
Convert logical key value to hardware key value (for setting images)
Args:
logical_key: Logical key enum
Returns:
int: Hardware key value
"""
pass
@abstractmethod
def decode_input_event(self, hardware_code: int, state: int) -> InputEvent:
"""
Decode hardware event codes into a unified InputEvent
Args:
hardware_code: Hardware event code
state: State (0=release, 1=press)
Returns:
InputEvent: Decoded event object
"""
pass
def id(self):
"""
Retrieves the physical ID of the attached StreamDock. This can be used
to differentiate one StreamDock from another.
:rtype: str
:return: Identifier for the attached device.
"""
return self.getPath()
def _read(self):
try:
while self.run_read_thread:
try:
arr = self.read()
if arr is not None and len(arr) >= 10:
if arr[9] == 0xFF:
# Confirm write success
pass
else:
try:
# Use the device class event decoder
if self.feature_option.deviceType != device_type.k1pro:
event = self.decode_input_event(arr[9], arr[10])
else:
event = self.decode_input_event(arr[10], arr[11])
# Get callback reference with lock
with self._callback_lock:
callback = self.key_callback
# Call callback OUTSIDE of lock to avoid deadlocks
if callback is not None:
try:
# Callback signature: callback(device, event)
callback(self, event)
except Exception as callback_error:
print(
f"Key callback error: {callback_error}",
flush=True,
)
traceback.print_exc()
except Exception as decode_error:
print(f"Event decode error: {decode_error}", flush=True)
traceback.print_exc()
# else:
# print("read control", arr)
# Don't explicitly delete arr - let Python's GC handle it
# del arr causes issues with ctypes buffers on Linux
except Exception as e:
print(f"Error reading data: {e}", flush=True)
traceback.print_exc()
continue
except Exception as outer_error:
print(f"[FATAL] Read thread outer exception: {outer_error}", flush=True)
traceback.print_exc()
finally:
pass
def _heartbeat_worker(self):
"""
Worker method that sends heartbeat packets to the device every 10 seconds.
This keeps the device connection alive and prevents timeout.
"""
# Initial delay to allow device and read thread to stabilize
time.sleep(1.0)
try:
while self.run_heartbeat_thread:
try:
self.transport.heartbeat()
except Exception as e:
# Log but don't crash the thread on heartbeat errors
print(f"Heartbeat error: {e}", flush=True)
# Wait 10 seconds before next heartbeat
time.sleep(10)
except Exception as outer_error:
print(f"[FATAL] Heartbeat thread outer exception: {outer_error}", flush=True)
finally:
pass
def _setup_reader(self, callback):
"""
Sets up the internal transport reader thread with the given callback,
for asynchronous processing of HID events from the device. If the thread
already exists, it is terminated and restarted with the new callback
function.
:param function callback: Callback to run on the reader thread.
"""
if self.read_thread is not None:
self.run_read_thread = False
try:
self.read_thread.join()
# return
except RuntimeError:
pass
if callback is not None:
self.run_read_thread = True
self.read_thread = threading.Thread(target=callback)
self.read_thread.daemon = True
self.read_thread.start()
def _start_heartbeat(self):
"""
Starts the heartbeat thread that sends periodic heartbeat packets to the device.
"""
if self.heartbeat_thread is not None:
self.run_heartbeat_thread = False
try:
self.heartbeat_thread.join()
except RuntimeError:
pass
self.run_heartbeat_thread = True
self.heartbeat_thread = threading.Thread(target=self._heartbeat_worker)
self.heartbeat_thread.daemon = True
self.heartbeat_thread.start()
def set_key_callback(self, callback):
"""
Sets the callback function called each time a button on the StreamDock
changes state (either pressed, or released), or a knob is rotated/pressed,
or a swipe gesture is detected.
.. note:: This callback will be fired from an internal reader thread.
Ensure that the given callback function is thread-safe.
.. note:: Only one callback can be registered at one time.
.. seealso:: See :func:`~StreamDock.set_key_callback_async` method for
a version compatible with Python 3 `asyncio` asynchronous
functions.
:param function callback: Callback function with signature:
callback(device: StreamDock, event: InputEvent)
Example:
def on_input(device, event):
from StreamDock.InputTypes import EventType
if event.event_type == EventType.BUTTON:
print(f"Key {event.key.value} pressed")
elif event.event_type == EventType.KNOB_ROTATE:
print("Knob rotated")
"""
with self._callback_lock:
self.key_callback = callback
def set_key_callback_async(self, async_callback, loop=None):
"""
Sets the asynchronous callback function called each time a button on the
StreamDock changes state (either pressed, or released), or a knob is
rotated/pressed, or a swipe gesture is detected. The given callback
should be compatible with Python 3's `asyncio` routines.
.. note:: The asynchronous callback will be fired in a thread-safe
manner.
.. note:: This will override the callback (if any) set by
:func:`~StreamDock.set_key_callback`.
:param function async_callback: Asynchronous callback function with signature:
async_callback(device: StreamDock, event: InputEvent)
:param asyncio.loop loop: Asyncio loop to dispatch the callback into
"""
import asyncio
loop = loop or asyncio.get_event_loop()
def callback(*args):
asyncio.run_coroutine_threadsafe(async_callback(*args), loop)
self.set_key_callback(callback)
def set_touchscreen_callback(self, callback):
"""
Sets the callback function called each time there is an interaction
with a touchscreen on the StreamDock.
.. note:: This callback will be fired from an internal reader thread.
Ensure that the given callback function is thread-safe.
.. note:: Only one callback can be registered at one time.
.. seealso:: See :func:`~StreamDock.set_touchscreen_callback_async`
method for a version compatible with Python 3 `asyncio`
asynchronous functions.
:param function callback: Callback function to fire each time a button
state changes.
"""
self.touchscreen_callback = callback
def set_touchscreen_callback_async(self, async_callback, loop=None):
"""
Sets the asynchronous callback function called each time there is an
interaction with the touchscreen on the StreamDock. The given callback
should be compatible with Python 3's `asyncio` routines.
.. note:: The asynchronous callback will be fired in a thread-safe
manner.
.. note:: This will override the callback (if any) set by
:func:`~StreamDock.set_touchscreen_callback`.
:param function async_callback: Asynchronous callback function to fire
each time a button state changes.
:param asyncio.loop loop: Asyncio loop to dispatch the callback into
"""
import asyncio
loop = loop or asyncio.get_event_loop()
def callback(*args):
asyncio.run_coroutine_threadsafe(async_callback(*args), loop)
self.set_touchscreen_callback(callback)

View File

@ -0,0 +1,157 @@
from StreamDock.FeatrueOption import device_type
from .StreamDock import StreamDock
from ..InputTypes import InputEvent, ButtonKey, EventType
from PIL import Image
import ctypes
import ctypes.util
import os, io
from ..ImageHelpers.PILHelper import *
import random
class StreamDock293(StreamDock):
"""StreamDock293 device class - supports 15 keys"""
KEY_COUNT = 15
KEY_MAP = False
# Image key mapping: logical key -> hardware key (for setting images)
# The 293 device uses the base class KEY_MAPPING mapping
_IMAGE_KEY_MAP = {
ButtonKey.KEY_1: 11,
ButtonKey.KEY_2: 12,
ButtonKey.KEY_3: 13,
ButtonKey.KEY_4: 14,
ButtonKey.KEY_5: 15,
ButtonKey.KEY_6: 6,
ButtonKey.KEY_7: 7,
ButtonKey.KEY_8: 8,
ButtonKey.KEY_9: 9,
ButtonKey.KEY_10: 10,
ButtonKey.KEY_11: 1,
ButtonKey.KEY_12: 2,
ButtonKey.KEY_13: 3,
ButtonKey.KEY_14: 4,
ButtonKey.KEY_15: 5,
}
# Reverse mapping: hardware key -> logical key (for event decoding)
_HW_TO_LOGICAL_KEY = {v: k for k, v in _IMAGE_KEY_MAP.items()}
def __init__(self, transport1, devInfo):
super().__init__(transport1, devInfo)
def get_image_key(self, logical_key: ButtonKey) -> int:
"""
Convert logical key value to hardware key value (for setting images)
Args:
logical_key: Logical key enum
Returns:
int: Hardware key value
"""
if logical_key in self._IMAGE_KEY_MAP:
return self._IMAGE_KEY_MAP[logical_key]
raise ValueError(f"StreamDock293: Unsupported key {logical_key}")
def decode_input_event(self, hardware_code: int, state: int) -> InputEvent:
"""
Decode hardware event codes into a unified InputEvent
The 293 device supports only regular buttons; hardware code range 1-15
"""
# Handle state value: 0x02=release, 0x01=press
normalized_state = 1 if state == 0x01 else 0
# Regular button events (1-15)
if hardware_code in self._HW_TO_LOGICAL_KEY:
return InputEvent(
event_type=EventType.BUTTON,
key=self._HW_TO_LOGICAL_KEY[hardware_code],
state=normalized_state
)
# Unknown event
return InputEvent(event_type=EventType.UNKNOWN)
# Set device screen brightness
def set_brightness(self, percent):
return self.transport.setBrightness(percent)
# Set device background image 800 * 480
def set_touchscreen_image(self, path):
try:
if not os.path.exists(path):
print(f"Error: The image file '{path}' does not exist.")
return -1
image = Image.open(path)
image = to_native_touchscreen_format(self, image)
width, height = image.size
bgr_data = []
for y in range(height):
for x in range(width):
r,g,b = image.getpixel((x,y))
bgr_data.extend([b,g,r])
arr_type = ctypes.c_char * len(bgr_data)
arr_ctypes = arr_type(*bgr_data)
return self.transport.setBackgroundImg(ctypes.cast(arr_ctypes, ctypes.POINTER(ctypes.c_ubyte)),width * height * 3)
except Exception as e:
print(f"Error: {e}")
return -1
# Set device key icon image 100 * 100
def set_key_image(self, key, path):
try:
if isinstance(key, int):
if key not in range(1, 16):
print(f"key '{key}' out of range. you should set (1 ~ 15)")
return -1
logical_key = ButtonKey(key)
else:
logical_key = key
if not os.path.exists(path):
print(f"Error: The image file '{path}' does not exist.")
return -1
# Get hardware key value
hardware_key = self.get_image_key(logical_key)
image = Image.open(path)
rotated_image = to_native_key_format(self, image)
rotated_image.save("Temporary.jpg", "JPEG", subsampling=0, quality=100)
returnvalue = self.transport.setKeyImg(bytes("Temporary.jpg",'utf-8'), hardware_key)
os.remove("Temporary.jpg")
return returnvalue
except Exception as e:
print(f"Error: {e}")
return -1
# Get device firmware version
def get_serial_number(self):
return self.serial_number
def key_image_format(self):
return {
'size': (100, 100),
'format': "JPEG",
'rotation': 180,
'flip': (False, False)
}
def touchscreen_image_format(self):
return {
'size': (800, 480),
'format': "JPEG",
'rotation': 180,
'flip': (False, False)
}
# Set device parameters
def set_device(self):
self.transport.set_report_size(513, 513, 0)
self.feature_option.deviceType = device_type.dock_293
pass

View File

@ -0,0 +1,168 @@
from StreamDock.FeatrueOption import device_type
from .StreamDock import StreamDock
from ..InputTypes import InputEvent, ButtonKey, EventType
from PIL import Image
import ctypes
import ctypes.util
import os, io
from ..ImageHelpers.PILHelper import *
import random
class StreamDock293V3(StreamDock):
"""StreamDock293V3 device class - supports 15 keys"""
KEY_COUNT = 15
KEY_MAP = False
# Image key mapping: logical key -> hardware key (for setting images)
_IMAGE_KEY_MAP = {
ButtonKey.KEY_1: 11,
ButtonKey.KEY_2: 12,
ButtonKey.KEY_3: 13,
ButtonKey.KEY_4: 14,
ButtonKey.KEY_5: 15,
ButtonKey.KEY_6: 6,
ButtonKey.KEY_7: 7,
ButtonKey.KEY_8: 8,
ButtonKey.KEY_9: 9,
ButtonKey.KEY_10: 10,
ButtonKey.KEY_11: 1,
ButtonKey.KEY_12: 2,
ButtonKey.KEY_13: 3,
ButtonKey.KEY_14: 4,
ButtonKey.KEY_15: 5,
}
# Reverse mapping: hardware key -> logical key (for event decoding)
_HW_TO_LOGICAL_KEY = {v: k for k, v in _IMAGE_KEY_MAP.items()}
def __init__(self, transport1, devInfo):
super().__init__(transport1, devInfo)
def get_image_key(self, logical_key: ButtonKey) -> int:
"""
Convert logical key value to hardware key value (for setting images)
Args:
logical_key: Logical key enum
Returns:
int: Hardware key value
"""
if logical_key in self._IMAGE_KEY_MAP:
return self._IMAGE_KEY_MAP[logical_key]
raise ValueError(f"StreamDock293V3: Unsupported key {logical_key}")
def decode_input_event(self, hardware_code: int, state: int) -> InputEvent:
"""
Decode hardware event codes into a unified InputEvent
The 293V3 device supports only regular buttons; hardware code range 1-15
"""
# Handle state value: 0x02=release, 0x01=press
normalized_state = 1 if state == 0x01 else 0
# Regular button events (1-15)
if hardware_code in self._HW_TO_LOGICAL_KEY:
return InputEvent(
event_type=EventType.BUTTON,
key=self._HW_TO_LOGICAL_KEY[hardware_code],
state=normalized_state
)
# Unknown event
return InputEvent(event_type=EventType.UNKNOWN)
# Set device screen brightness
def set_brightness(self, percent):
return self.transport.setBrightness(percent)
# Set device background image 800 * 480
def set_touchscreen_image(self, path):
try:
if not os.path.exists(path):
print(f"Error: The image file '{path}' does not exist.")
return -1
# open formatter
image = Image.open(path)
image = to_native_touchscreen_format(self, image)
temp_image_path = "rotated_touchscreen_image_" + str(random.randint(9999, 999999)) + ".jpg"
image.save(temp_image_path)
# encode send
path_bytes = temp_image_path.encode('utf-8')
c_path = ctypes.c_char_p(path_bytes)
res = self.transport.setBackgroundImgDualDevice(c_path)
os.remove(temp_image_path)
return res
except Exception as e:
print(f"Error: {e}")
return -1
# Set device key icon image 112 * 112
def set_key_image(self, key, path):
try:
if isinstance(key, int):
if key not in range(1, 16):
print(f"key '{key}' out of range. you should set (1 ~ 15)")
return -1
logical_key = ButtonKey(key)
else:
logical_key = key
if not os.path.exists(path):
print(f"Error: The image file '{path}' does not exist.")
return -1
# Get hardware key value
hardware_key = self.get_image_key(logical_key)
# open formatter
image = Image.open(path)
image = to_native_key_format(self, image)
temp_image_path = "rotated_key_image_" + str(random.randint(9999, 999999)) + ".jpg"
image.save(temp_image_path)
# encode send
path_bytes = temp_image_path.encode('utf-8')
c_path = ctypes.c_char_p(path_bytes)
res = self.transport.setKeyImgDualDevice(c_path, hardware_key)
os.remove(temp_image_path)
return res
except Exception as e:
print(f"Error: {e}")
return -1
# TODO
def set_key_imageData(self, key, path):
pass
# Get device firmware version
def get_serial_number(self):
return self.serial_number
def key_image_format(self):
return {
'size': (112, 112),
'format': "JPEG",
'rotation': 180,
'flip': (False, False)
}
def touchscreen_image_format(self):
return {
'size': (800, 480),
'format': "JPEG",
'rotation': 180,
'flip': (False, False)
}
# Set device parameters
def set_device(self):
self.transport.set_report_size(513, 1025, 0)
self.feature_option.deviceType = device_type.dock_293v3
pass

View File

@ -0,0 +1,167 @@
# -*- coding: utf-8 -*-
from StreamDock.FeatrueOption import device_type
from .StreamDock import StreamDock
from ..InputTypes import InputEvent, ButtonKey, EventType
from PIL import Image
import ctypes
import ctypes.util
import os, io
from ..ImageHelpers.PILHelper import *
import random
class StreamDock293s(StreamDock):
"""StreamDock293s device class - supports 15 keys and 3 secondary screen keys"""
KEY_COUNT = 18
KEY_MAP = False
# Image key mapping: logical key -> hardware key (for setting images)
_IMAGE_KEY_MAP = {
ButtonKey.KEY_1: 13,
ButtonKey.KEY_2: 10,
ButtonKey.KEY_3: 7,
ButtonKey.KEY_4: 4,
ButtonKey.KEY_5: 1,
ButtonKey.KEY_6: 14,
ButtonKey.KEY_7: 11,
ButtonKey.KEY_8: 8,
ButtonKey.KEY_9: 5,
ButtonKey.KEY_10: 2,
ButtonKey.KEY_11: 15,
ButtonKey.KEY_12: 12,
ButtonKey.KEY_13: 9,
ButtonKey.KEY_14: 6,
ButtonKey.KEY_15: 3,
# Secondary screen keys 16-18
ButtonKey.KEY_16: 16,
ButtonKey.KEY_17: 17,
ButtonKey.KEY_18: 18,
}
# Reverse mapping: hardware key -> logical key (for event decoding)
_HW_TO_LOGICAL_KEY = {v: k for k, v in _IMAGE_KEY_MAP.items()}
def __init__(self, transport1, devInfo):
super().__init__(transport1, devInfo)
def get_image_key(self, logical_key: ButtonKey) -> int:
"""
Convert logical key value to hardware key value (for setting images)
Args:
logical_key: Logical key enum
Returns:
int: Hardware key value
"""
if logical_key in self._IMAGE_KEY_MAP:
return self._IMAGE_KEY_MAP[logical_key]
raise ValueError(f"StreamDock293s: Unsupported key {logical_key}")
def decode_input_event(self, hardware_code: int, state: int) -> InputEvent:
"""
Decode hardware event codes into a unified InputEvent
The 293s device supports only regular buttons; hardware code range 1-18
"""
# Handle state value: 0x02=release, 0x01=press
normalized_state = 1 if state == 0x01 else 0
# Regular button events (1-18)
if hardware_code in self._HW_TO_LOGICAL_KEY:
return InputEvent(
event_type=EventType.BUTTON,
key=self._HW_TO_LOGICAL_KEY[hardware_code],
state=normalized_state
)
# Unknown event
return InputEvent(event_type=EventType.UNKNOWN)
# Set device screen brightness
def set_brightness(self, percent):
return self.transport.setBrightness(percent)
# Set device background image 854 * 480
def set_touchscreen_image(self, image):
image = Image.open(image)
image = to_native_touchscreen_format(self, image)
width, height = image.size
bgr_data = []
for x in range(width):
for y in range(height):
r,g,b = image.getpixel((x,y))
bgr_data.extend([b,g,r])
arr_type = ctypes.c_char * len(bgr_data)
arr_ctypes = arr_type(*bgr_data)
return self.transport.setBackgroundImg(ctypes.cast(arr_ctypes, ctypes.POINTER(ctypes.c_ubyte)),width * height * 3)
# Set device key icon image 85 * 85
def set_key_image(self, key, path):
try:
if isinstance(key, int):
if key not in range(1, 19):
print(f"key '{key}' out of range. you should set (1 ~ 18)")
return -1
logical_key = ButtonKey(key)
else:
logical_key = key
if not os.path.exists(path):
print(f"Error: The image file '{path}' does not exist.")
return -1
# Get hardware key value
hardware_key = self.get_image_key(logical_key)
image = Image.open(path)
if hardware_key in range(1, 16):
# icon
rotated_image = to_native_key_format(self, image)
elif hardware_key in range(16, 19):
# second screen
rotated_image = to_native_seondscreen_format(self, image)
rotated_image.save("Temporary.jpg", "JPEG", subsampling=0, quality=100)
returnvalue = self.transport.setKeyImg(bytes("Temporary.jpg",'utf-8'), hardware_key)
os.remove("Temporary.jpg")
return returnvalue
except Exception as e:
print(f"Error: {e}")
return -1
def get_serial_number(self):
return self.serial_number
def key_image_format(self):
return {
'size': (85, 85),
'format': "JPEG",
'rotation': 90,
'flip': (False, False)
}
def secondscreen_image_format(self):
return {
'size': (80, 80),
'format': "JPEG",
'rotation': 90,
'flip': (False, False)
}
def touchscreen_image_format(self):
return {
'size': (854, 480),
'format': "JPEG",
'rotation': 0,
'flip': (True, False)
}
# Set device parameters
def set_device(self):
self.transport.set_report_size(513, 513, 0)
self.feature_option.deviceType = device_type.dock_293s
pass

View File

@ -0,0 +1,179 @@
# -*- coding: utf-8 -*-
from StreamDock.FeatrueOption import device_type
from .StreamDock import StreamDock
from ..InputTypes import InputEvent, ButtonKey, EventType
from PIL import Image
import ctypes
import ctypes.util
import os, io
from ..ImageHelpers.PILHelper import *
import random
class StreamDock293sV3(StreamDock):
"""StreamDock293sV3 device class - supports 15 keys and 3 secondary screen keys"""
KEY_COUNT = 18
KEY_MAP = False
# Image key mapping: logical key -> hardware key (for setting images)
_IMAGE_KEY_MAP = {
ButtonKey.KEY_1: 13,
ButtonKey.KEY_2: 10,
ButtonKey.KEY_3: 7,
ButtonKey.KEY_4: 4,
ButtonKey.KEY_5: 1,
ButtonKey.KEY_6: 14,
ButtonKey.KEY_7: 11,
ButtonKey.KEY_8: 8,
ButtonKey.KEY_9: 5,
ButtonKey.KEY_10: 2,
ButtonKey.KEY_11: 15,
ButtonKey.KEY_12: 12,
ButtonKey.KEY_13: 9,
ButtonKey.KEY_14: 6,
ButtonKey.KEY_15: 3,
# Secondary screen keys 16-18
ButtonKey.KEY_16: 16,
ButtonKey.KEY_17: 17,
ButtonKey.KEY_18: 18,
}
# Reverse mapping: hardware key -> logical key (for event decoding)
_HW_TO_LOGICAL_KEY = {v: k for k, v in _IMAGE_KEY_MAP.items()}
def __init__(self, transport1, devInfo):
super().__init__(transport1, devInfo)
def get_image_key(self, logical_key: ButtonKey) -> int:
"""
Convert logical key value to hardware key value (for setting images)
Args:
logical_key: Logical key enum
Returns:
int: Hardware key value
"""
if logical_key in self._IMAGE_KEY_MAP:
return self._IMAGE_KEY_MAP[logical_key]
raise ValueError(f"StreamDock293sV3: Unsupported key {logical_key}")
def decode_input_event(self, hardware_code: int, state: int) -> InputEvent:
"""
Decode hardware event codes into a unified InputEvent
The 293sV3 device supports only regular buttons; hardware code range 1-18
"""
# Handle state value: 0x02=release, 0x01=press
normalized_state = 1 if state == 0x01 else 0
# Regular button events (1-18)
if hardware_code in self._HW_TO_LOGICAL_KEY:
return InputEvent(
event_type=EventType.BUTTON,
key=self._HW_TO_LOGICAL_KEY[hardware_code],
state=normalized_state,
)
# Unknown event
return InputEvent(event_type=EventType.UNKNOWN)
# Set device screen brightness
def set_brightness(self, percent):
return self.transport.setBrightness(percent)
# Set device background image 854 * 480
def set_touchscreen_image(self, path):
try:
if not os.path.exists(path):
print(f"Error: The image file '{path}' does not exist.")
return -1
image = Image.open(path)
image = to_native_touchscreen_format(self, image)
temp_image_path = (
"rotated_touchscreen_image_"
+ str(random.randint(9999, 999999))
+ ".jpg"
)
image.save(temp_image_path)
# encode send
path_bytes = temp_image_path.encode("utf-8")
c_path = ctypes.c_char_p(path_bytes)
res = self.transport.setBackgroundImgDualDevice(c_path)
os.remove(temp_image_path)
return res
except Exception as e:
print(f"Error: {e}")
return -1
# Set device key icon image 85 * 85
def set_key_image(self, key, path):
try:
if isinstance(key, int):
if key not in range(1, 19):
print(f"key '{key}' out of range. you should set (1 ~ 18)")
return -1
logical_key = ButtonKey(key)
else:
logical_key = key
if not os.path.exists(path):
print(f"Error: The image file '{path}' does not exist.")
return -1
# Get hardware key value
hardware_key = self.get_image_key(logical_key)
image = Image.open(path)
if hardware_key in range(1, 16):
# icon
image = to_native_key_format(self, image)
elif hardware_key in range(16, 19):
# second screen
image = to_native_seondscreen_format(self, image)
image.save("Temporary.jpg", "JPEG", subsampling=0, quality=100)
returnvalue = self.transport.setKeyImg(
bytes("Temporary.jpg", "utf-8"), hardware_key
)
os.remove("Temporary.jpg")
return returnvalue
except Exception as e:
print(f"Error: {e}")
return -1
def get_serial_number(self):
return self.serial_number
def key_image_format(self):
return {
"size": (96, 96),
"format": "JPEG",
"rotation": 90,
"flip": (False, False),
}
def secondscreen_image_format(self):
return {
"size": (80, 80),
"format": "JPEG",
"rotation": 90,
"flip": (False, False),
}
def touchscreen_image_format(self):
return {
"size": (854, 480),
"format": "JPEG",
"rotation": 90,
"flip": (False, False),
}
# Set device parameters
def set_device(self):
self.transport.set_report_size(513, 1025, 0)
self.feature_option.deviceType = device_type.dock_293sv3
pass

View File

@ -0,0 +1,179 @@
from StreamDock.FeatrueOption import device_type
from .StreamDock import StreamDock
from ..InputTypes import InputEvent, ButtonKey, EventType
from PIL import Image
import ctypes
import ctypes.util
import os, io
from ..ImageHelpers.PILHelper import *
import random
class StreamDockM18(StreamDock):
"""StreamDockM18 device class - supports 15 keys"""
KEY_COUNT = 15
KEY_MAP = False
# Image key mapping: logical key -> hardware key (for setting images)
_IMAGE_KEY_MAP = {
ButtonKey.KEY_1: 11,
ButtonKey.KEY_2: 12,
ButtonKey.KEY_3: 13,
ButtonKey.KEY_4: 14,
ButtonKey.KEY_5: 15,
ButtonKey.KEY_6: 6,
ButtonKey.KEY_7: 7,
ButtonKey.KEY_8: 8,
ButtonKey.KEY_9: 9,
ButtonKey.KEY_10: 10,
ButtonKey.KEY_11: 1,
ButtonKey.KEY_12: 2,
ButtonKey.KEY_13: 3,
ButtonKey.KEY_14: 4,
ButtonKey.KEY_15: 5,
ButtonKey.KEY_16: 0x25,
ButtonKey.KEY_17: 0x30,
ButtonKey.KEY_18: 0x31,
}
# Reverse mapping: hardware key -> logical key (for event decoding)
_HW_TO_LOGICAL_KEY = {v: k for k, v in _IMAGE_KEY_MAP.items()}
def __init__(self, transport1, devInfo):
super().__init__(transport1, devInfo)
def get_image_key(self, logical_key: ButtonKey) -> int:
"""
Convert logical key value to hardware key value (for setting images)
Args:
logical_key: Logical key enum
Returns:
int: Hardware key value
"""
if logical_key in self._IMAGE_KEY_MAP:
return self._IMAGE_KEY_MAP[logical_key]
raise ValueError(f"StreamDockM18: Unsupported key {logical_key}")
def decode_input_event(self, hardware_code: int, state: int) -> InputEvent:
"""
Decode hardware event codes into a unified InputEvent
M18 supports only regular buttons; hardware code range 1-15
"""
# Handle state value: 0x02=release, 0x01=press
normalized_state = 1 if state == 0x01 else 0
# Regular button events (1-15)
if hardware_code in self._HW_TO_LOGICAL_KEY:
return InputEvent(
event_type=EventType.BUTTON,
key=self._HW_TO_LOGICAL_KEY[hardware_code],
state=normalized_state
)
# Unknown event
return InputEvent(event_type=EventType.UNKNOWN)
# Set device screen brightness
def set_brightness(self, percent):
return self.transport.setBrightness(percent)
# Set device background image 480 * 272
def set_touchscreen_image(self, path):
try:
if not os.path.exists(path):
print(f"Error: The image file '{path}' does not exist.")
return -1
# open formatter
image = Image.open(path)
image = to_native_touchscreen_format(self, image)
temp_image_path = (
"rotated_touchscreen_image_"
+ str(random.randint(9999, 999999))
+ ".jpg"
)
image.save(temp_image_path)
# encode send
path_bytes = temp_image_path.encode("utf-8")
c_path = ctypes.c_char_p(path_bytes)
res = self.transport.setBackgroundImgDualDevice(c_path)
os.remove(temp_image_path)
return res
except Exception as e:
print(f"Error: {e}")
return -1
# Set device key icon image 64 * 64
def set_key_image(self, key, path):
try:
if isinstance(key, int):
if key not in range(1, 16):
print(f"key '{key}' out of range. you should set (1 ~ 15)")
return -1
logical_key = ButtonKey(key)
else:
logical_key = key
if not os.path.exists(path):
print(f"Error: The image file '{path}' does not exist.")
return -1
# Get hardware key value
hardware_key = self.get_image_key(logical_key)
# open formatter
image = Image.open(path)
image = to_native_key_format(self, image)
temp_image_path = (
"rotated_key_image_" + str(random.randint(9999, 999999)) + ".jpg"
)
image.save(temp_image_path)
# encode send
path_bytes = temp_image_path.encode("utf-8")
c_path = ctypes.c_char_p(path_bytes)
res = self.transport.setKeyImgDualDevice(c_path, hardware_key)
os.remove(temp_image_path)
return res
except Exception as e:
print(f"Error: {e}")
return -1
# TODO
def set_key_imageData(self, key, path):
pass
# Get device firmware version
def get_serial_number(self):
return self.serial_number
def key_image_format(self):
return {
"size": (64, 64),
"format": "JPEG",
"rotation": 0,
"flip": (False, False),
}
def touchscreen_image_format(self):
return {
"size": (480, 272),
"format": "JPEG",
"rotation": 0,
"flip": (False, False),
}
# Set device parameters
def set_device(self):
self.transport.set_report_size(513, 1025, 0)
self.feature_option.hasRGBLed = True
self.feature_option.ledCounts = 24
self.feature_option.deviceType = device_type.dock_m18
pass

View File

@ -0,0 +1,230 @@
from StreamDock.FeatrueOption import device_type
from .StreamDock import StreamDock
from ..InputTypes import Direction, InputEvent, ButtonKey, EventType, KnobId
from PIL import Image
import ctypes
import ctypes.util
import os, io
from ..ImageHelpers.PILHelper import *
import random
class StreamDockM3(StreamDock):
"""StreamDockM3 device class - supports 15 keys"""
KEY_COUNT = 15
KEY_MAP = False
# Image key mapping: logical key -> hardware key (for setting images)
_IMAGE_KEY_MAP = {
ButtonKey.KEY_1: 11,
ButtonKey.KEY_2: 12,
ButtonKey.KEY_3: 13,
ButtonKey.KEY_4: 14,
ButtonKey.KEY_5: 15,
ButtonKey.KEY_6: 6,
ButtonKey.KEY_7: 7,
ButtonKey.KEY_8: 8,
ButtonKey.KEY_9: 9,
ButtonKey.KEY_10: 10,
ButtonKey.KEY_11: 1,
ButtonKey.KEY_12: 2,
ButtonKey.KEY_13: 3,
ButtonKey.KEY_14: 4,
ButtonKey.KEY_15: 5,
}
# Reverse mapping: hardware key -> logical key (for event decoding)
_HW_TO_LOGICAL_KEY = {v: k for k, v in _IMAGE_KEY_MAP.items()}
def __init__(self, transport1, devInfo):
super().__init__(transport1, devInfo)
def get_image_key(self, logical_key: ButtonKey) -> int:
"""
Convert logical key value to hardware key value (for setting images)
Args:
logical_key: Logical key enum
Returns:
int: Hardware key value
"""
if logical_key in self._IMAGE_KEY_MAP:
return self._IMAGE_KEY_MAP[logical_key]
raise ValueError(f"StreamDockM3: Unsupported key {logical_key}")
def decode_input_event(self, hardware_code: int, state: int) -> InputEvent:
"""
Decode hardware event codes into a unified InputEvent
M3 supports only regular buttons; hardware code range 1-15
"""
# Handle state value: 0x02=release, 0x01=press
normalized_state = 1 if state == 0x01 else 0
# Regular button events (1-15)
if hardware_code in self._HW_TO_LOGICAL_KEY:
return InputEvent(
event_type=EventType.BUTTON,
key=self._HW_TO_LOGICAL_KEY[hardware_code],
state=normalized_state
)
# Knob rotation event
knob_rotate_map = {
0x50: (KnobId.KNOB_1, Direction.LEFT),
0x51: (KnobId.KNOB_1, Direction.RIGHT),
0x90: (KnobId.KNOB_2, Direction.LEFT),
0x91: (KnobId.KNOB_2, Direction.RIGHT),
0xa0: (KnobId.KNOB_3, Direction.LEFT),
0xa1: (KnobId.KNOB_3, Direction.RIGHT),
}
if hardware_code in knob_rotate_map:
knob_id, direction = knob_rotate_map[hardware_code]
return InputEvent(
event_type=EventType.KNOB_ROTATE, knob_id=knob_id, direction=direction
)
# Knob press event
knob_press_map = {
0x35: KnobId.KNOB_1,
0x33: KnobId.KNOB_2,
0x37: KnobId.KNOB_3,
}
if hardware_code in knob_press_map:
return InputEvent(
event_type=EventType.KNOB_PRESS,
knob_id=knob_press_map[hardware_code],
state=normalized_state,
)
# Unknown event
return InputEvent(event_type=EventType.UNKNOWN)
# Set device screen brightness
def set_brightness(self, percent):
return self.transport.setBrightness(percent)
# Set device background image 480 * 272
def set_touchscreen_image(self, path):
try:
if not os.path.exists(path):
print(f"Error: The image file '{path}' does not exist.")
return -1
# open formatter
image = Image.open(path)
image = to_native_touchscreen_format(self, image)
temp_image_path = (
"rotated_touchscreen_image_"
+ str(random.randint(9999, 999999))
+ ".jpg"
)
image.save(temp_image_path)
# encode send
path_bytes = temp_image_path.encode("utf-8")
c_path = ctypes.c_char_p(path_bytes)
res = self.transport.setBackgroundImgDualDevice(c_path)
os.remove(temp_image_path)
return res
except Exception as e:
print(f"Error: {e}")
return -1
# Set device key icon image 64 * 64
def set_key_image(self, key, path):
try:
if isinstance(key, int):
if key not in range(1, 16):
print(f"key '{key}' out of range. you should set (1 ~ 15)")
return -1
logical_key = ButtonKey(key)
else:
logical_key = key
if not os.path.exists(path):
print(f"Error: The image file '{path}' does not exist.")
return -1
# Get hardware key value
hardware_key = self.get_image_key(logical_key)
# open formatter
image = Image.open(path)
image = to_native_key_format(self, image)
temp_image_path = (
"rotated_key_image_" + str(random.randint(9999, 999999)) + ".jpg"
)
image.save(temp_image_path)
# encode send
path_bytes = temp_image_path.encode("utf-8")
c_path = ctypes.c_char_p(path_bytes)
res = self.transport.setKeyImgDualDevice(c_path, hardware_key)
os.remove(temp_image_path)
return res
except Exception as e:
print(f"Error: {e}")
return -1
def set_frame_background(self, path):
try:
if not os.path.exists(path):
print(f"Error: The image file '{path}' does not exist.")
return -1
image = Image.open(path)
image = to_native_touchscreen_format(self, image)
temp_image_path = (
"rotated_touchscreen_image_"
+ str(random.randint(9999, 999999))
+ ".jpg"
)
image.save(temp_image_path, quality=80)
# encode send
path_bytes = temp_image_path.encode("utf-8")
c_path = ctypes.c_char_p(path_bytes)
res = self.transport.setBackgroundImgFrame(
c_path,
854,
480,
)
os.remove(temp_image_path)
return res
except Exception as e:
print(f"Error: {e}")
return -1
def set_key_imageData(self, key, path):
pass
def magnetic_calibration(self):
"""Perform magnetic calibration."""
self.transport.magnetic_calibration()
# Get device firmware version
def get_serial_number(self):
return self.serial_number
def key_image_format(self):
return {
"size": (96, 96),
"format": "JPEG",
"rotation": 90,
"flip": (False, False),
}
def touchscreen_image_format(self):
return {
"size": (854, 480),
"format": "JPEG",
"rotation": 90,
"flip": (False, False),
}
# Set device parameters
def set_device(self):
self.transport.set_report_size(513, 1025, 0)
self.feature_option.deviceType = device_type.dock_m3
pass

View File

@ -0,0 +1,323 @@
# -*- coding: utf-8 -*-
from StreamDock.FeatrueOption import device_type
from .StreamDock import StreamDock
from ..InputTypes import InputEvent, ButtonKey, EventType, KnobId, Direction
from PIL import Image
import ctypes
import ctypes.util
import os, io
from ..ImageHelpers.PILHelper import *
import random
from enum import Enum
def extract_last_number(code):
"""
Extract consecutive digits after the last dot in a string and convert to int
Args:
code: String like "N3.02.013" or "N3.02.013V2"
Returns:
int: Extracted number, or None if not found
"""
# Find the position of the last dot
last_dot = code.rfind(".")
if last_dot == -1:
return None
# Extract consecutive digits after the last dot
num_str = ""
for char in code[last_dot + 1 :]:
if char.isdigit():
num_str += char
else:
# Stop at the first non-digit character
break
# If digits were found, convert to int
if num_str:
return int(num_str)
else:
return None
class StreamDockN1(StreamDock):
"""StreamDockN1 device class - supports 20 inputs (15 main screen + 3 secondary screen + knob)"""
KEY_COUNT = 20
KEY_MAP = False
# N1 device keys map directly; no mapping needed
_IMAGE_KEY_MAP = {
ButtonKey.KEY_1: 1,
ButtonKey.KEY_2: 2,
ButtonKey.KEY_3: 3,
ButtonKey.KEY_4: 4,
ButtonKey.KEY_5: 5,
ButtonKey.KEY_6: 6,
ButtonKey.KEY_7: 7,
ButtonKey.KEY_8: 8,
ButtonKey.KEY_9: 9,
ButtonKey.KEY_10: 10,
ButtonKey.KEY_11: 11,
ButtonKey.KEY_12: 12,
ButtonKey.KEY_13: 13,
ButtonKey.KEY_14: 14,
ButtonKey.KEY_15: 15,
ButtonKey.KEY_16: 0x1E,
ButtonKey.KEY_17: 0x1F,
}
class DeviceMode(Enum):
KEYBOARD = 0
CALCULATOR = 1
DOCK = 2
class SkinMode(Enum):
KEYBOARD = 0x11
KEYBOARD_LOCK = 0x1F
CALCULATOR = 0xFF
class SkinStatus(Enum):
PRESS = 0
RELEASE = 1
# Reverse mapping: hardware key -> logical key (for event decoding)
_HW_TO_LOGICAL_KEY = {v: k for k, v in _IMAGE_KEY_MAP.items()}
def __init__(self, transport1, devInfo):
super().__init__(transport1, devInfo)
self.devInfo = devInfo
def open(self):
super().open()
self.transport.switchMode(2)
def get_image_key(self, logical_key: ButtonKey) -> int:
"""
Convert logical key value to hardware key value (for setting images)
N1 device keys map directly
Args:
logical_key: Logical key enum
Returns:
int: Hardware key value
"""
if logical_key in self._IMAGE_KEY_MAP:
return self._IMAGE_KEY_MAP[logical_key]
raise ValueError(f"StreamDockN1: Unsupported key {logical_key}")
def decode_input_event(self, hardware_code: int, state: int) -> InputEvent:
"""
Decode hardware event codes into a unified InputEvent
The N1 device supports regular button and knob events:
- Regular buttons 1-17: hardware codes 0x01-0x0F, 0x1E-0x1F
- Knob press 18: hardware code 0x23
- Knob rotation 19-20: hardware codes 0x32 (left), 0x33 (right)
"""
# Knob rotation event
knob_rotate_map = {
0x32: (KnobId.KNOB_1, Direction.LEFT),
0x33: (KnobId.KNOB_1, Direction.RIGHT),
}
if hardware_code in knob_rotate_map:
knob_id, direction = knob_rotate_map[hardware_code]
return InputEvent(
event_type=EventType.KNOB_ROTATE, knob_id=knob_id, direction=direction
)
# Handle state value: 0x02=release, 0x01=press
normalized_state = 1 if state == 0x01 else 0
# Knob press event
knob_press_map = {
0x23: KnobId.KNOB_1,
}
if hardware_code in knob_press_map:
return InputEvent(
event_type=EventType.KNOB_PRESS,
knob_id=knob_press_map[hardware_code],
state=normalized_state,
)
# Regular button events (1-17)
if hardware_code in self._HW_TO_LOGICAL_KEY:
return InputEvent(
event_type=EventType.BUTTON,
key=self._HW_TO_LOGICAL_KEY[hardware_code],
state=normalized_state,
)
# Unknown event
return InputEvent(event_type=EventType.UNKNOWN)
# Set device screen brightness
def set_brightness(self, percent):
return self.transport.setBrightness(percent)
# Set device background image 480 * 854
def set_touchscreen_image(self, path):
version_str = self.get_serial_number()
version_num = extract_last_number(version_str)
if version_num is None:
return -1
if version_num >= 13:
try:
if not os.path.exists(path):
print(f"Error: The image file '{path}' does not exist.")
return -1
# open formatter
image = Image.open(path)
image = to_native_touchscreen_format(self, image)
temp_image_path = (
"rotated_touchscreen_image_"
+ str(random.randint(9999, 999999))
+ ".jpg"
)
image.save(temp_image_path)
# encode send
path_bytes = temp_image_path.encode("utf-8")
c_path = ctypes.c_char_p(path_bytes)
res = self.transport.setBackgroundImgDualDevice(c_path)
os.remove(temp_image_path)
return res
except Exception as e:
print(f"Error: {e}")
return -1
# Set device key icon image 96 * 96
def set_key_image(self, key, path):
try:
if isinstance(key, int):
if key not in range(1, 19):
print(f"key '{key}' out of range. you should set (1 ~ 18)")
return -1
logical_key = ButtonKey(key)
else:
logical_key = key
if not os.path.exists(path):
print(f"Error: The image file '{path}' does not exist.")
return -1
# N1 device keys map directly
hardware_key = logical_key.value
image = Image.open(path)
if hardware_key in range(1, 16):
# icon
rotated_image = to_native_key_format(self, image)
elif hardware_key in range(16, 19):
# second screen
rotated_image = to_native_seondscreen_format(self, image)
else:
print(f"Error: Invalid hardware key '{hardware_key}'.")
return -1
rotated_image.save("Temporary.jpg", "JPEG", subsampling=0, quality=90)
returnvalue = self.transport.setKeyImgDualDevice(
bytes("Temporary.jpg", "utf-8"), hardware_key
)
os.remove("Temporary.jpg")
return returnvalue
except Exception as e:
print(f"Error: {e}")
return -1
def get_serial_number(self):
return self.serial_number
def switch_mode(self, mode: DeviceMode):
# 0:calculator, 1:dock
return self.transport.switchMode(mode.value)
def change_page(self, page):
return self.transport.changePage(page)
def set_n1_skin_bitmap(
self,
path,
skin_mode: SkinMode,
skin_page: int,
skin_status: SkinStatus,
key_index: int,
):
try:
if not os.path.exists(path):
print(f"Error: The image file '{path}' does not exist.")
return -1
if self.SkinMode.CALCULATOR == skin_mode:
if key_index < 1 or key_index > 18:
print(
f"Error: For CALCULATOR skin mode, key_index should be in range 1-18."
)
return -1
elif (
self.SkinMode.KEYBOARD == skin_mode
or self.SkinMode.KEYBOARD_LOCK == skin_mode
):
if key_index < 1 or key_index > 15:
print(
f"Error: For KEYBOARD skin mode, key_index should be in range 1-15."
)
return -1
if skin_page < 1 or skin_page > 5:
print(f"Error: skin_page should be in range 1-5.")
return -1
image = Image.open(path)
if self.SkinMode.KEYBOARD == skin_mode and key_index in range(16, 19):
image = to_native_seondscreen_format(self, image)
else:
image = to_native_key_format(self, image)
temp_image_path = (
"rotated_n1_skin_image_" + str(random.randint(9999, 999999)) + ".png"
)
image.save(temp_image_path)
# encode send
path_bytes = temp_image_path.encode("utf-8")
c_path = ctypes.c_char_p(path_bytes)
res = self.transport.setN1SkinBitMap(
c_path, skin_mode.value, skin_page, skin_status.value, key_index
)
os.remove(temp_image_path)
return res
except Exception as e:
print(f"Error: {e}")
return -1
def key_image_format(self):
return {
"size": (96, 96),
"format": "JPEG",
"rotation": 0,
"flip": (False, False),
}
def secondscreen_image_format(self):
return {
"size": (80, 80),
"format": "JPEG",
"rotation": 0,
"flip": (False, False),
}
def touchscreen_image_format(self):
return {
"size": (480, 854),
"format": "JPEG",
"rotation": 0,
"flip": (False, False),
}
# Set device parameters
def set_device(self):
self.transport.set_report_size(513, 1025, 0)
self.feature_option.deviceType = device_type.dock_n1
pass

View File

@ -0,0 +1,142 @@
from StreamDock.FeatrueOption import device_type
from .StreamDock import StreamDock
from ..InputTypes import InputEvent, ButtonKey, EventType, KnobId, Direction
from PIL import Image
import os
import io
from ..ImageHelpers.PILHelper import *
class StreamDockN3(StreamDock):
"""StreamDockN3 device class (N3V25 variant) - 6 LCD keys + 3 bottom buttons + 3 knobs"""
KEY_COUNT = 18
KEY_MAP = False
_IMAGE_KEY_MAP = {
ButtonKey.KEY_1: 1,
ButtonKey.KEY_2: 2,
ButtonKey.KEY_3: 3,
ButtonKey.KEY_4: 4,
ButtonKey.KEY_5: 5,
ButtonKey.KEY_6: 6,
ButtonKey.KEY_7: 0x25,
ButtonKey.KEY_8: 0x30,
ButtonKey.KEY_9: 0x31,
}
_HW_TO_LOGICAL_KEY = {v: k for k, v in _IMAGE_KEY_MAP.items()}
def __init__(self, transport1, devInfo):
super().__init__(transport1, devInfo)
def get_image_key(self, logical_key: ButtonKey) -> int:
if logical_key in self._IMAGE_KEY_MAP:
return self._IMAGE_KEY_MAP[logical_key]
raise ValueError(f"StreamDockN3: Unsupported key {logical_key}")
def decode_input_event(self, hardware_code: int, state: int) -> InputEvent:
normalized_state = 1 if state == 0x01 else 0
if hardware_code in self._HW_TO_LOGICAL_KEY:
return InputEvent(
event_type=EventType.BUTTON,
key=self._HW_TO_LOGICAL_KEY[hardware_code],
state=normalized_state,
)
knob_rotate_map = {
0x90: (KnobId.KNOB_1, Direction.LEFT),
0x91: (KnobId.KNOB_1, Direction.RIGHT),
0x60: (KnobId.KNOB_2, Direction.LEFT),
0x61: (KnobId.KNOB_2, Direction.RIGHT),
0x50: (KnobId.KNOB_3, Direction.LEFT),
0x51: (KnobId.KNOB_3, Direction.RIGHT),
}
if hardware_code in knob_rotate_map:
knob_id, direction = knob_rotate_map[hardware_code]
return InputEvent(
event_type=EventType.KNOB_ROTATE, knob_id=knob_id, direction=direction
)
knob_press_map = {
0x33: KnobId.KNOB_1,
0x34: KnobId.KNOB_2,
0x35: KnobId.KNOB_3,
}
if hardware_code in knob_press_map:
return InputEvent(
event_type=EventType.KNOB_PRESS,
knob_id=knob_press_map[hardware_code],
state=normalized_state,
)
return InputEvent(event_type=EventType.UNKNOWN)
def set_brightness(self, percent):
return self.transport.set_key_brightness(percent)
def set_touchscreen_image(self, path):
try:
if not os.path.exists(path):
print(f"Error: The image file '{path}' does not exist.")
return -1
image = Image.open(path)
image = to_native_touchscreen_format(self, image)
buf = io.BytesIO()
image.save(buf, format="JPEG", quality=85)
jpeg_data = buf.getvalue()
res = self.transport.set_background_image_stream(jpeg_data)
return res
except Exception as e:
print(f"Error: {e}")
return -1
def set_key_image(self, key, path):
try:
if isinstance(key, int):
if key not in range(1, 19):
print(f"key '{key}' out of range. you should set (1 ~ 18)")
return -1
logical_key = ButtonKey(key)
else:
logical_key = key
if not os.path.exists(path):
print(f"Error: The image file '{path}' does not exist.")
return -1
hardware_key = self.get_image_key(logical_key)
if hardware_key > 6:
return -1
image = Image.open(path)
image = to_native_key_format(self, image)
buf = io.BytesIO()
image.save(buf, format="JPEG", quality=90)
jpeg_data = buf.getvalue()
res = self.transport.set_key_image_stream(jpeg_data, hardware_key)
return res
except Exception as e:
print(f"Error: {e}")
return -1
def get_serial_number(self):
return self.serial_number
def key_image_format(self):
return {
"size": (64, 64),
"format": "JPEG",
"rotation": -90,
"flip": (False, False),
}
def touchscreen_image_format(self):
return {
"size": (320, 240),
"format": "JPEG",
"rotation": -90,
"flip": (False, False),
}
def set_device(self):
self.transport.set_report_size(513, 1025, 0)
self.feature_option.deviceType = device_type.dock_n3

View File

@ -0,0 +1,210 @@
from StreamDock.FeatrueOption import device_type
from .StreamDock import StreamDock
from ..InputTypes import InputEvent, ButtonKey, EventType
from PIL import Image
import ctypes
import ctypes.util
import os, io
from ..ImageHelpers.PILHelper import *
import random
class StreamDockN4(StreamDock):
"""StreamDockN4 device class - supports 14 keys (10 main screen + 4 secondary screen)"""
KEY_COUNT = 14
KEY_MAP = False
# Image key mapping: logical key -> hardware key (for setting images)
_IMAGE_KEY_MAP = {
ButtonKey.KEY_1: 11,
ButtonKey.KEY_2: 12,
ButtonKey.KEY_3: 13,
ButtonKey.KEY_4: 14,
ButtonKey.KEY_5: 15,
ButtonKey.KEY_6: 6,
ButtonKey.KEY_7: 7,
ButtonKey.KEY_8: 8,
ButtonKey.KEY_9: 9,
ButtonKey.KEY_10: 10,
ButtonKey.KEY_11: 1,
ButtonKey.KEY_12: 2,
ButtonKey.KEY_13: 3,
ButtonKey.KEY_14: 4,
}
# Reverse mapping: hardware key -> logical key (for event decoding)
_HW_TO_LOGICAL_KEY = {v: k for k, v in _IMAGE_KEY_MAP.items()}
def __init__(self, transport1, devInfo):
super().__init__(transport1, devInfo)
def get_image_key(self, logical_key: ButtonKey) -> int:
"""
Convert logical key value to hardware key value (for setting images)
Args:
logical_key: Logical key enum
Returns:
int: Hardware key value
"""
if logical_key in self._IMAGE_KEY_MAP:
return self._IMAGE_KEY_MAP[logical_key]
raise ValueError(f"StreamDockN4: Unsupported key {logical_key}")
def decode_input_event(self, hardware_code: int, state: int) -> InputEvent:
"""
Decode hardware event codes into a unified InputEvent
The N4 device supports only regular buttons; hardware code range 1-15
"""
# Handle state value: 0x02=release, 0x01=press
normalized_state = 1 if state == 0x01 else 0
# Regular button events (1-14)
if hardware_code in self._HW_TO_LOGICAL_KEY:
return InputEvent(
event_type=EventType.BUTTON,
key=self._HW_TO_LOGICAL_KEY[hardware_code],
state=normalized_state
)
# Unknown event
return InputEvent(event_type=EventType.UNKNOWN)
# Set device screen brightness
def set_brightness(self, percent):
return self.transport.setBrightness(percent)
# Set device background image 800 * 480
def set_touchscreen_image(self, path):
try:
if not os.path.exists(path):
print(f"Error: The image file '{path}' does not exist.")
return -1
# open formatter
image = Image.open(path)
image = to_native_touchscreen_format(self, image)
temp_image_path = "rotated_touchscreen_image_" + str(random.randint(9999, 999999)) + ".jpg"
image.save(temp_image_path)
# encode send
path_bytes = temp_image_path.encode('utf-8')
c_path = ctypes.c_char_p(path_bytes)
res = self.transport.setBackgroundImgDualDevice(c_path)
os.remove(temp_image_path)
return res
except Exception as e:
print(f"Error: {e}")
return -1
# Set device key icon image 112 * 112
def set_key_image(self, key, path):
try:
if isinstance(key, int):
if key not in range(1, 15):
print(f"key '{key}' out of range. you should set (1 ~ 14)")
return -1
logical_key = ButtonKey(key)
else:
logical_key = key
if not os.path.exists(path):
print(f"Error: The image file '{path}' does not exist.")
return -1
# Secondary screen keys 11-14
if logical_key.value in range(11, 15):
return self.set_seondscreen_image(logical_key.value, path)
# Get hardware key value
hardware_key = self.get_image_key(logical_key)
# open formatter
image = Image.open(path)
image = to_native_key_format(self, image)
temp_image_path = "rotated_key_image_" + str(random.randint(9999, 999999)) + ".jpg"
image.save(temp_image_path)
# encode send
path_bytes = temp_image_path.encode('utf-8')
c_path = ctypes.c_char_p(path_bytes)
res = self.transport.setKeyImgDualDevice(c_path, hardware_key)
os.remove(temp_image_path)
return res
except Exception as e:
print(f"Error: {e}")
return -1
# Set device secondary screen key icon image 176 * 112
def set_seondscreen_image(self, key, path):
try:
if key not in range(11, 15):
print(f"key '{key}' out of range. you should set (11 ~ 14)")
return -1
logical_key = ButtonKey(key)
hardware_key = self.get_image_key(logical_key)
if not os.path.exists(path):
print(f"Error: The image file '{path}' does not exist.")
return -1
# open formatter
image = Image.open(path)
image = to_native_seondscreen_format(self, image)
temp_image_path = "rotated_key_image_" + str(random.randint(9999, 999999)) + ".jpg"
image.save(temp_image_path)
# encode send
path_bytes = temp_image_path.encode('utf-8')
c_path = ctypes.c_char_p(path_bytes)
res = self.transport.setKeyImgDualDevice(c_path, hardware_key)
os.remove(temp_image_path)
return res
except Exception as e:
print(f"Error: {e}")
return -1
# TODO
def set_key_imageData(self, key, path):
pass
# Get device firmware version
def get_serial_number(self):
return self.serial_number
def key_image_format(self):
return {
'size': (112, 112),
'format': "JPEG",
'rotation': 180,
'flip': (False, False)
}
def secondscreen_image_format(self):
return {
'size': (176, 112),
'format': "JPEG",
'rotation': 180,
'flip': (False, False)
}
def touchscreen_image_format(self):
return {
'size': (800, 480),
'format': "JPEG",
'rotation': 180,
'flip': (False, False)
}
# Set device parameters
def set_device(self):
self.transport.set_report_size(513, 1025, 0)
self.feature_option.deviceType = device_type.dock_n4
pass

View File

@ -0,0 +1,306 @@
from StreamDock.FeatrueOption import device_type
from .StreamDock import StreamDock
from ..InputTypes import InputEvent, ButtonKey, EventType, KnobId, Direction
from PIL import Image
import ctypes
import ctypes.util
import os, io
from ..ImageHelpers.PILHelper import *
import random
class StreamDockN4Pro(StreamDock):
"""StreamDockN4Pro device class - supports 15 keys, 4 knobs, and swipe gestures"""
KEY_COUNT = 15
KEY_MAP = False
# Image key mapping: logical key -> hardware key (for setting images)
_IMAGE_KEY_MAP = {
# Main keys 1-10
ButtonKey.KEY_1: 11,
ButtonKey.KEY_2: 12,
ButtonKey.KEY_3: 13,
ButtonKey.KEY_4: 14,
ButtonKey.KEY_5: 15,
ButtonKey.KEY_6: 6,
ButtonKey.KEY_7: 7,
ButtonKey.KEY_8: 8,
ButtonKey.KEY_9: 9,
ButtonKey.KEY_10: 10,
# Secondary screen keys 11-14 (176x112)
ButtonKey.KEY_11: 1,
ButtonKey.KEY_12: 2,
ButtonKey.KEY_13: 3,
ButtonKey.KEY_14: 4,
ButtonKey.KEY_15: 5,
}
# Reverse mapping: hardware key -> logical key (for event decoding)
_HW_TO_LOGICAL_KEY = {v: k for k, v in _IMAGE_KEY_MAP.items()}
def __init__(self, transport1, devInfo):
super().__init__(transport1, devInfo)
def get_image_key(self, logical_key: ButtonKey) -> int:
"""
Convert logical key value to hardware key value (for setting images)
Args:
logical_key: Logical key enum
Returns:
int: Hardware key value
"""
if logical_key in self._IMAGE_KEY_MAP:
return self._IMAGE_KEY_MAP[logical_key]
raise ValueError(f"StreamDockN4Pro: Unsupported key {logical_key}")
def decode_input_event(self, hardware_code: int, state: int) -> InputEvent:
"""
Decode hardware event codes into a unified InputEvent
Hardware code mapping:
- Keys: 1-15
- Secondary screen keys: 0x40-0x43
- Knob rotation: 0xA0, 0xA1(Knob1), 0x50, 0x51(Knob2), 0x90, 0x91(Knob3), 0x70, 0x71(Knob4)
- Knob press: 0x37(Knob1), 0x35(Knob2), 0x33(Knob3), 0x36(Knob4)
- Swipe: 0x38 (left), 0x39 (right)
"""
# Handle state value: 0x02=release, 0x01=press
normalized_state = 1 if state == 0x01 else 0
# Regular button events (1-15)
if hardware_code in self._HW_TO_LOGICAL_KEY:
return InputEvent(
event_type=EventType.BUTTON,
key=self._HW_TO_LOGICAL_KEY[hardware_code],
state=normalized_state,
)
# Secondary screen key events
secondary_key_map = {
0x40: ButtonKey.KEY_11,
0x41: ButtonKey.KEY_12,
0x42: ButtonKey.KEY_13,
0x43: ButtonKey.KEY_14,
}
if hardware_code in secondary_key_map:
return InputEvent(
event_type=EventType.BUTTON,
key=secondary_key_map[hardware_code],
state=normalized_state,
)
# Knob rotation event
knob_rotate_map = {
0xA0: (KnobId.KNOB_1, Direction.LEFT),
0xA1: (KnobId.KNOB_1, Direction.RIGHT),
0x50: (KnobId.KNOB_2, Direction.LEFT),
0x51: (KnobId.KNOB_2, Direction.RIGHT),
0x90: (KnobId.KNOB_3, Direction.LEFT),
0x91: (KnobId.KNOB_3, Direction.RIGHT),
0x70: (KnobId.KNOB_4, Direction.LEFT),
0x71: (KnobId.KNOB_4, Direction.RIGHT),
}
if hardware_code in knob_rotate_map:
knob_id, direction = knob_rotate_map[hardware_code]
return InputEvent(
event_type=EventType.KNOB_ROTATE, knob_id=knob_id, direction=direction
)
# Knob press event
knob_press_map = {
0x37: KnobId.KNOB_1,
0x35: KnobId.KNOB_2,
0x33: KnobId.KNOB_3,
0x36: KnobId.KNOB_4,
}
if hardware_code in knob_press_map:
return InputEvent(
event_type=EventType.KNOB_PRESS,
knob_id=knob_press_map[hardware_code],
state=normalized_state,
)
# Swipe gesture
if hardware_code == 0x38:
return InputEvent(event_type=EventType.SWIPE, direction=Direction.LEFT)
if hardware_code == 0x39:
return InputEvent(event_type=EventType.SWIPE, direction=Direction.RIGHT)
# Unknown event
return InputEvent(event_type=EventType.UNKNOWN)
# Set device screen brightness
def set_brightness(self, percent):
return self.transport.setBrightness(percent)
# Set device background image 800 * 480
def set_touchscreen_image(self, path):
try:
if not os.path.exists(path):
print(f"Error: The image file '{path}' does not exist.")
return -1
image = Image.open(path)
image = to_native_touchscreen_format(self, image)
temp_image_path = (
"rotated_touchscreen_image_"
+ str(random.randint(9999, 999999))
+ ".jpg"
)
image.save(temp_image_path)
# encode send
path_bytes = temp_image_path.encode("utf-8")
c_path = ctypes.c_char_p(path_bytes)
res = self.transport.setBackgroundImgDualDevice(c_path)
os.remove(temp_image_path)
return res
except Exception as e:
print(f"Error: {e}")
return -1
def set_frame_background(self, path):
try:
if not os.path.exists(path):
print(f"Error: The image file '{path}' does not exist.")
return -1
image = Image.open(path)
image = to_native_touchscreen_format(self, image)
temp_image_path = (
"rotated_touchscreen_image_"
+ str(random.randint(9999, 999999))
+ ".jpg"
)
image.save(temp_image_path, quality=80)
# encode send
path_bytes = temp_image_path.encode("utf-8")
c_path = ctypes.c_char_p(path_bytes)
res = self.transport.setBackgroundImgFrame(
c_path,
800,
480,
)
os.remove(temp_image_path)
return res
except Exception as e:
print(f"Error: {e}")
return -1
# Set device key icon image 112 * 112
def set_key_image(self, key, path):
try:
if isinstance(key, int):
if key not in range(1, 16):
print(f"key '{key}' out of range. you should set (1 ~ 15)")
return -1
logical_key = ButtonKey(key)
else:
logical_key = key
if not os.path.exists(path):
print(f"Error: The image file '{path}' does not exist.")
return -1
# Secondary screen keys use a different image format
if logical_key.value in range(11, 15):
return self.set_seondscreen_image(logical_key.value, path)
# Get hardware key value
hardware_key = self.get_image_key(logical_key)
# open formatter
image = Image.open(path)
image = to_native_key_format(self, image)
temp_image_path = (
"rotated_key_image_" + str(random.randint(9999, 999999)) + ".jpg"
)
image.save(temp_image_path)
# encode send
path_bytes = temp_image_path.encode("utf-8")
c_path = ctypes.c_char_p(path_bytes)
res = self.transport.setKeyImgDualDevice(c_path, hardware_key)
os.remove(temp_image_path)
return res
except Exception as e:
print(f"Error: {e}")
return -1
# Set device secondary screen key icon image 176 * 112
def set_seondscreen_image(self, key, path):
try:
if key not in range(11, 15):
print(f"key '{key}' out of range. you should set (11 ~ 14)")
return -1
logical_key = ButtonKey(key)
hardware_key = self.get_image_key(logical_key)
if not os.path.exists(path):
print(f"Error: The image file '{path}' does not exist.")
return -1
# open formatter
image = Image.open(path)
image = to_native_seondscreen_format(self, image)
temp_image_path = (
"rotated_key_image_" + str(random.randint(9999, 999999)) + ".jpg"
)
image.save(temp_image_path)
# encode send
path_bytes = temp_image_path.encode("utf-8")
c_path = ctypes.c_char_p(path_bytes)
res = self.transport.setKeyImgDualDevice(c_path, hardware_key)
os.remove(temp_image_path)
return res
except Exception as e:
print(f"Error: {e}")
return -1
# TODO
def set_key_imageData(self, key, path):
pass
# Get device serial number
def get_serial_number(self):
return self.serial_number
def key_image_format(self):
return {
"size": (112, 112),
"format": "JPEG",
"rotation": 180,
"flip": (False, False),
}
def secondscreen_image_format(self):
return {
"size": (176, 112),
"format": "JPEG",
"rotation": 180,
"flip": (False, False),
}
def touchscreen_image_format(self):
return {
"size": (800, 480),
"format": "JPEG",
"rotation": 180,
"flip": (False, False),
}
# Set device parameters
def set_device(self):
self.transport.set_report_size(513, 1025, 0)
self.feature_option.hasRGBLed = True
self.feature_option.ledCounts = 4
self.feature_option.deviceType = device_type.dock_n4pro
pass

View File

@ -0,0 +1,242 @@
from StreamDock.FeatrueOption import device_type
from .StreamDock import StreamDock
from ..InputTypes import InputEvent, ButtonKey, EventType, KnobId, Direction
from PIL import Image
import ctypes
import ctypes.util
import os, io
from ..ImageHelpers.PILHelper import *
import random
class StreamDockXL(StreamDock):
"""StreamDockXL device class - supports 36 inputs (32 keys + 2 knobs)"""
KEY_COUNT = 36
KEY_MAP = False
# Image key mapping: logical key -> hardware key (for setting images)
_IMAGE_KEY_MAP = {
ButtonKey.KEY_1: 25,
ButtonKey.KEY_2: 26,
ButtonKey.KEY_3: 27,
ButtonKey.KEY_4: 28,
ButtonKey.KEY_5: 29,
ButtonKey.KEY_6: 30,
ButtonKey.KEY_7: 31,
ButtonKey.KEY_8: 32,
ButtonKey.KEY_9: 17,
ButtonKey.KEY_10: 18,
ButtonKey.KEY_11: 19,
ButtonKey.KEY_12: 20,
ButtonKey.KEY_13: 21,
ButtonKey.KEY_14: 22,
ButtonKey.KEY_15: 23,
ButtonKey.KEY_16: 24,
ButtonKey.KEY_17: 9,
ButtonKey.KEY_18: 10,
ButtonKey.KEY_19: 11,
ButtonKey.KEY_20: 12,
ButtonKey.KEY_21: 13,
ButtonKey.KEY_22: 14,
ButtonKey.KEY_23: 15,
ButtonKey.KEY_24: 16,
ButtonKey.KEY_25: 1,
ButtonKey.KEY_26: 2,
ButtonKey.KEY_27: 3,
ButtonKey.KEY_28: 4,
ButtonKey.KEY_29: 5,
ButtonKey.KEY_30: 6,
ButtonKey.KEY_31: 7,
ButtonKey.KEY_32: 8,
}
# Reverse mapping: hardware key -> logical key (for event decoding)
_HW_TO_LOGICAL_KEY = {v: k for k, v in _IMAGE_KEY_MAP.items()}
def __init__(self, transport1, devInfo):
super().__init__(transport1, devInfo)
def get_image_key(self, logical_key: ButtonKey) -> int:
"""
Convert logical key value to hardware key value (for setting images)
Args:
logical_key: Logical key enum
Returns:
int: Hardware key value
"""
if logical_key in self._IMAGE_KEY_MAP:
return self._IMAGE_KEY_MAP[logical_key]
raise ValueError(f"StreamDockXL: Unsupported key {logical_key}")
def decode_input_event(self, hardware_code: int, state: int) -> InputEvent:
"""
Decode hardware event codes into a unified InputEvent
XL supports regular button and knob events:
- Regular buttons 1-32: hardware codes 0x19-0x08
- Left knob up/down 33-34: hardware codes 0x21 (up), 0x23 (down)
- Right knob up/down 35-36: hardware codes 0x24 (up), 0x26 (down)
"""
knob_rotate_map = {
0x23: (KnobId.KNOB_1, Direction.LEFT),
0x21: (KnobId.KNOB_1, Direction.RIGHT),
0x24: (KnobId.KNOB_2, Direction.LEFT),
0x26: (KnobId.KNOB_2, Direction.RIGHT),
}
# Knob rotation event
if hardware_code in knob_rotate_map:
knob_id, direction = knob_rotate_map[hardware_code]
return InputEvent(
event_type=EventType.KNOB_ROTATE, knob_id=knob_id, direction=direction
)
# Handle state value: 0x02=release, 0x01=press
normalized_state = 1 if state == 0x01 else 0
# Regular button events (1-32)
if hardware_code in self._HW_TO_LOGICAL_KEY:
return InputEvent(
event_type=EventType.BUTTON,
key=self._HW_TO_LOGICAL_KEY[hardware_code],
state=normalized_state,
)
# Unknown event
return InputEvent(event_type=EventType.UNKNOWN)
def set_frame_background(self, path):
try:
if not os.path.exists(path):
print(f"Error: The image file '{path}' does not exist.")
return -1
image = Image.open(path)
image = to_native_touchscreen_format(self, image)
temp_image_path = (
"rotated_touchscreen_image_"
+ str(random.randint(9999, 999999))
+ ".jpg"
)
image.save(temp_image_path, quality=80)
# encode send
path_bytes = temp_image_path.encode("utf-8")
c_path = ctypes.c_char_p(path_bytes)
res = self.transport.setBackgroundImgFrame(
c_path,
1024,
600,
)
os.remove(temp_image_path)
return res
except Exception as e:
print(f"Error: {e}")
return -1
# Set device screen brightness
def set_brightness(self, percent):
return self.transport.setBrightness(percent)
# Set device background image 1024 * 600
def set_touchscreen_image(self, path):
try:
if not os.path.exists(path):
print(f"Error: The image file '{path}' does not exist.")
return -1
# open formatter
image = Image.open(path)
image = to_native_touchscreen_format(self, image)
temp_image_path = (
"rotated_touchscreen_image_"
+ str(random.randint(9999, 999999))
+ ".jpg"
)
image.save(temp_image_path)
# encode send
path_bytes = temp_image_path.encode("utf-8")
c_path = ctypes.c_char_p(path_bytes)
res = self.transport.setBackgroundImgDualDevice(c_path)
os.remove(temp_image_path)
return res
except Exception as e:
print(f"Error: {e}")
return -1
# Set device key icon image 80 * 80
def set_key_image(self, key, path):
try:
if isinstance(key, int):
if key not in range(1, 33):
print(f"key '{key}' out of range. you should set (1 ~ 32)")
return -1
logical_key = ButtonKey(key)
else:
logical_key = key
if not os.path.exists(path):
print(f"Error: The image file '{path}' does not exist.")
return -1
# Get hardware key value
hardware_key = self.get_image_key(logical_key)
# XL supports setting icons only for keys 1-32 (knob events do not require icons)
if hardware_key not in range(1, 33):
return -1
# open formatter
image = Image.open(path)
image = to_native_key_format(self, image)
temp_image_path = (
"rotated_key_image_" + str(random.randint(9999, 999999)) + ".jpg"
)
image.save(temp_image_path)
# encode send
path_bytes = temp_image_path.encode("utf-8")
c_path = ctypes.c_char_p(path_bytes)
res = self.transport.setKeyImgDualDevice(c_path, hardware_key)
os.remove(temp_image_path)
return res
except Exception as e:
print(f"Error: {e}")
return -1
# TODO
def set_key_imageData(self, key, path):
pass
# Get device firmware version
def get_serial_number(self):
return self.serial_number
def key_image_format(self):
return {
"size": (80, 80),
"format": "JPEG",
"rotation": 180,
"flip": (False, False),
}
def touchscreen_image_format(self):
return {
"size": (1024, 600),
"format": "JPEG",
"rotation": 180,
"flip": (False, False),
}
# Set device parameters
def set_device(self):
self.transport.set_report_size(513, 1025, 0)
self.feature_option.hasRGBLed = True
self.feature_option.ledCounts = 6
self.feature_option.deviceType = device_type.dock_xl
pass

View File

View File

@ -0,0 +1,24 @@
from enum import Enum
class device_type(Enum):
dock_universal = 0
dock_293 = 1
dock_293v3=2
dock_293s=3
dock_293sv3=4
dock_m3=5
dock_m18=6
dock_n1=7
dock_n3=8
dock_n4=9
dock_n4pro=10
dock_xl=11
k1pro=12
class FeatrueOption:
def __init__(self):
self.hasRGBLed = False
self.ledCounts = 0
self.supportConfig = False
self.supportBackgroundImage = True
self.deviceType = device_type.dock_universal

View File

@ -0,0 +1,89 @@
import io
from PIL import Image
def _create_image(image_format, background):
return Image.new("RGB", image_format['size'], background)
def _scale_image(image, image_format, margins=[0, 0, 0, 0], background='black'):
if len(margins) != 4:
raise ValueError("Margins should be given as an array of four integers.")
final_image = _create_image(image_format, background=background)
thumbnail_max_width = final_image.width - (margins[1] + margins[3])
thumbnail_max_height = final_image.height - (margins[0] + margins[2])
thumbnail = image.convert("RGBA")
thumbnail.thumbnail((thumbnail_max_width, thumbnail_max_height), Image.LANCZOS)
thumbnail_x = (margins[3] + (thumbnail_max_width - thumbnail.width) // 2)
thumbnail_y = (margins[0] + (thumbnail_max_height - thumbnail.height) // 2)
final_image.paste(thumbnail, (thumbnail_x, thumbnail_y), thumbnail)
return final_image
def _to_native_format(image, image_format):
if image_format["format"].lower() != "jpeg" and image_format["format"].lower() != "jpg":
raise ValueError(f"no support format: {image_format['format']}. only 'jpeg' or 'jpg' is supported")
_expand = True
if image.size[1] == image_format["size"][0] and image.size[0] == image_format["size"][1]:
_expand = False
# must rotate the picture first then resize the picture
if image_format["rotation"] == 90 or image_format["rotation"] == -90:
swapped_tuple = (image_format["size"][1], image_format["size"][0])
image_format["size"] = swapped_tuple
if image_format['rotation']:
image = image.rotate(image_format['rotation'], expand = _expand)
if image.size != image_format['size']:
image = image.resize(image_format["size"])
if image_format['flip'][0]:
image = image.transpose(Image.FLIP_LEFT_RIGHT)
if image_format['flip'][1]:
image = image.transpose(Image.FLIP_TOP_BOTTOM)
image = image.convert('RGB')
return image
def create_image(dock, background='black'):
return create_key_image(dock, background)
def create_key_image(dock, background='black'):
return _create_image(dock.key_image_format(), background)
def create_touchscreen_image(dock, background='black'):
return _create_image(dock.touchscreen_image_format(), background)
def create_scaled_image(dock, image, margins=[0, 0, 0, 0], background='black'):
return create_scaled_key_image(dock, image, margins, background)
def create_scaled_key_image(dock, image, margins=[0, 0, 0, 0], background='black'):
return _scale_image(image, dock.key_image_format(), margins, background)
def create_scaled_touchscreen_image(dock, image, margins=[0, 0, 0, 0], background='black'):
return _scale_image(image, dock.touchscreen_image_format(), margins, background)
def to_native_key_format(dock, image):
return _to_native_format(image, dock.key_image_format())
def to_native_seondscreen_format(dock, image):
return _to_native_format(image, dock.secondscreen_image_format())
def to_native_touchscreen_format(dock, image):
return _to_native_format(image, dock.touchscreen_image_format())

View File

109
StreamDock/InputTypes.py Normal file
View File

@ -0,0 +1,109 @@
"""
StreamDock input event type system
Provides unified input event definitions, including buttons, knobs, and swipe gestures.
"""
from enum import Enum, IntEnum
from dataclasses import dataclass
from typing import Optional
class EventType(Enum):
"""Event type enum"""
BUTTON = "button" # Button press/release
KNOB_ROTATE = "knob_rotate" # Knob rotation
KNOB_PRESS = "knob_press" # Knob press
SWIPE = "swipe" # Swipe gesture
UNKNOWN = "unknown"
class ButtonKey(IntEnum):
"""
Logical key values for regular buttons (used for setting images)
Devices can define their own key ranges, for example:
- Simple devices: KEY_1 ~ KEY_15
- XL devices: KEY_1 ~ KEY_32
"""
KEY_1 = 1
KEY_2 = 2
KEY_3 = 3
KEY_4 = 4
KEY_5 = 5
KEY_6 = 6
KEY_7 = 7
KEY_8 = 8
KEY_9 = 9
KEY_10 = 10
KEY_11 = 11
KEY_12 = 12
KEY_13 = 13
KEY_14 = 14
KEY_15 = 15
KEY_16 = 16
KEY_17 = 17
KEY_18 = 18
KEY_19 = 19
KEY_20 = 20
KEY_21 = 21
KEY_22 = 22
KEY_23 = 23
KEY_24 = 24
KEY_25 = 25
KEY_26 = 26
KEY_27 = 27
KEY_28 = 28
KEY_29 = 29
KEY_30 = 30
KEY_31 = 31
KEY_32 = 32
class KnobId(Enum):
"""Knob ID enum"""
KNOB_1 = "knob_1"
KNOB_2 = "knob_2"
KNOB_3 = "knob_3"
KNOB_4 = "knob_4"
class Direction(Enum):
"""Direction enum (for knob rotation and swipe gestures)"""
LEFT = "left"
RIGHT = "right"
@dataclass
class InputEvent:
"""
Unified input event class
All input events (buttons, knobs, swipes) are passed to callbacks through this class.
Attributes:
event_type: Event type
key: Button event: which key
knob_id: Knob event: which knob
direction: Direction: knob rotation direction or swipe direction
state: State: 0=release, 1=press
"""
event_type: EventType
key: Optional[ButtonKey] = None # Button event: which key
knob_id: Optional[KnobId] = None # Knob event: which knob
direction: Optional[Direction] = None # Direction: knob rotation direction or swipe direction
state: int = 0 # State: 0=release, 1=press
def __post_init__(self):
"""Data validation"""
if self.event_type == EventType.BUTTON:
if self.key is None:
raise ValueError("BUTTON event requires key")
elif self.event_type in (EventType.KNOB_ROTATE, EventType.KNOB_PRESS):
if self.knob_id is None:
raise ValueError("KNOB event requires knob_id")
if self.event_type == EventType.KNOB_ROTATE and self.direction is None:
raise ValueError("KNOB_ROTATE event requires direction")
elif self.event_type == EventType.SWIPE:
if self.direction is None:
raise ValueError("SWIPE event requires direction")

129
StreamDock/ProductIDs.py Normal file
View File

@ -0,0 +1,129 @@
class USBVendorIDs:
"""
USB Vendor IDs for known StreamDock devices.
"""
USB_VID_293 = 0x5500
USB_VID_293V3 = 0x6603
USB_VID_293V3EN = 0x6603
USB_VID_293s = 0x5548
USB_VID_293sV3 = 0x6603
USB_VIDN3 = 0x6603
USB_VIDN3V2 = 0xEEEF
USB_VIDN3V25 = 0x1500
USB_VIDN3E = 0x6602
USB_VIDN4 = 0x6602
USB_VIDN4EN = 0x6603
USB_VIDN1EN = 0x6603
USB_VIDN1 = 0x6603
USB_VID_N4PRO = 0x5548
USB_VID_N4PROEN = 0x5548
USB_VID_XL = 0x5548
USB_VID_XLEN = 0x5548
USB_VID_M18 = 0x6603
USB_VID_M18EN = 0x6603
# USB_VID_M18V2 = 0x6603
# USB_VID_M18V2EN = 0x6603
# USB_VID_M18V25 = 0x6603
# USB_VID_M18V25EN = 0x6603
# USB_VID_M18V3 = 0x6603
# USB_VID_M18V3EN = 0x6603
USB_VID_M3 = 0x5548
USB_VID_K1_PRO = 0x6603
USB_VID_K1_PROEU = 0x6603
class USBProductIDs:
"""
USB Product IDs for known StreamDock devices.
"""
USB_PID_STREAMDOCK_293 = 0x1001
USB_PID_STREAMDOCK_293V3 = 0x1005
USB_PID_STREAMDOCK_293V3EN = 0x1006
USB_PID_STREAMDOCK_293V25 = 0x1010
USB_PID_STREAMDOCK_293s = 0x6670
USB_PID_STREAMDOCK_293sV3 = 0x1014
USB_PID_STREAMDOCK_N3 = 0x1002
USB_PID_STREAMDOCK_N3EN = 0x1003
USB_PID_STREAMDOCK_N3V2 = 0x2929
USB_PID_STREAMDOCK_N3V25 = 0x3001
USB_PID_STREAMDOCK_N4 = 0x1001
USB_PID_STREAMDOCK_N4EN = 0x1007
USB_PID_STREAMDOCK_N1EN = 0x1000
USB_PID_STREAMDOCK_N1 = 0x1011
USB_PID_STREAMDOCK_N4PRO = 0x1008
USB_PID_STREAMDOCK_N4PROEN = 0x1021
USB_PID_STREAMDOCK_VSD_N4PRO = 0x1023
USB_PID_STREAMDOCK_XL = 0x1028
USB_PID_STREAMDOCK_XLEN = 0x1031
USB_PID_STREAMDOCK_M18 = 0x1009
USB_PID_STREAMDOCK_M18EN = 0x1012
# USB_PID_STREAMDOCK_M18V2 = 0x1009
# USB_PID_STREAMDOCK_M18V2EN = 0x1012
# USB_PID_STREAMDOCK_M18V25 = 0x1009
# USB_PID_STREAMDOCK_M18V25EN = 0x1012
# USB_PID_STREAMDOCK_M18V3 = 0x1009
# USB_PID_STREAMDOCK_M18V3EN = 0x1012
USB_PID_STREAMDOCK_M3 = 0x1020
USB_PID_K1_PRO = 0x1015
USB_PID_K1_PROEU = 0x1019
from .Devices.StreamDock293 import StreamDock293
from .Devices.StreamDock293V3 import StreamDock293V3
from .Devices.StreamDock293s import StreamDock293s
from .Devices.StreamDock293sV3 import StreamDock293sV3
from .Devices.StreamDockN3 import StreamDockN3
from .Devices.StreamDockN4 import StreamDockN4
from .Devices.StreamDockN1 import StreamDockN1
from .Devices.StreamDockN4Pro import StreamDockN4Pro
from .Devices.StreamDockXL import StreamDockXL
from .Devices.StreamDockM18 import StreamDockM18
from .Devices.StreamDockM3 import StreamDockM3
from .Devices.K1Pro import K1Pro
g_products = [
# 293 serial
(USBVendorIDs.USB_VID_293, USBProductIDs.USB_PID_STREAMDOCK_293, StreamDock293),
(USBVendorIDs.USB_VID_293V3,USBProductIDs.USB_PID_STREAMDOCK_293V3,StreamDock293V3),
(USBVendorIDs.USB_VID_293V3EN,USBProductIDs.USB_PID_STREAMDOCK_293V3EN,StreamDock293V3),
(USBVendorIDs.USB_VID_293V3,USBProductIDs.USB_PID_STREAMDOCK_293V25,StreamDock293V3),
(USBVendorIDs.USB_VID_293s, USBProductIDs.USB_PID_STREAMDOCK_293s, StreamDock293s),
(USBVendorIDs.USB_VID_293sV3, USBProductIDs.USB_PID_STREAMDOCK_293sV3, StreamDock293sV3),
# N3
(USBVendorIDs.USB_VIDN3, USBProductIDs.USB_PID_STREAMDOCK_N3, StreamDockN3),
(USBVendorIDs.USB_VIDN3, USBProductIDs.USB_PID_STREAMDOCK_N3EN, StreamDockN3),
(USBVendorIDs.USB_VIDN3E, USBProductIDs.USB_PID_STREAMDOCK_N3, StreamDockN3),
(USBVendorIDs.USB_VIDN3E, USBProductIDs.USB_PID_STREAMDOCK_N3EN, StreamDockN3),
(USBVendorIDs.USB_VIDN3E, USBProductIDs.USB_PID_STREAMDOCK_N3V2, StreamDockN3),
(USBVendorIDs.USB_VIDN3V25, USBProductIDs.USB_PID_STREAMDOCK_N3V25, StreamDockN3),
# N4
(USBVendorIDs.USB_VIDN4, USBProductIDs.USB_PID_STREAMDOCK_N4, StreamDockN4),
(USBVendorIDs.USB_VIDN4EN, USBProductIDs.USB_PID_STREAMDOCK_N4EN, StreamDockN4),
# N1
(USBVendorIDs.USB_VIDN1, USBProductIDs.USB_PID_STREAMDOCK_N1, StreamDockN1),
(USBVendorIDs.USB_VIDN1EN, USBProductIDs.USB_PID_STREAMDOCK_N1EN, StreamDockN1),
# N4PRO
(USBVendorIDs.USB_VID_N4PRO, USBProductIDs.USB_PID_STREAMDOCK_N4PRO, StreamDockN4Pro),
(USBVendorIDs.USB_VID_N4PROEN, USBProductIDs.USB_PID_STREAMDOCK_N4PROEN, StreamDockN4Pro),
(USBVendorIDs.USB_VID_N4PRO, USBProductIDs.USB_PID_STREAMDOCK_VSD_N4PRO, StreamDockN4Pro),
# XL
(USBVendorIDs.USB_VID_XL, USBProductIDs.USB_PID_STREAMDOCK_XL, StreamDockXL),
(USBVendorIDs.USB_VID_XLEN, USBProductIDs.USB_PID_STREAMDOCK_XLEN, StreamDockXL),
# M18/M18V2/M18V25/M18V3
(USBVendorIDs.USB_VID_M18, USBProductIDs.USB_PID_STREAMDOCK_M18, StreamDockM18),
(USBVendorIDs.USB_VID_M18EN, USBProductIDs.USB_PID_STREAMDOCK_M18EN, StreamDockM18),
# (USBVendorIDs.USB_VID_M18V2, USBProductIDs.USB_PID_STREAMDOCK_M18V2, StreamDockM18),
# (USBVendorIDs.USB_VID_M18V2EN, USBProductIDs.USB_PID_STREAMDOCK_M18V2EN, StreamDockM18),
# (USBVendorIDs.USB_VID_M18V25, USBProductIDs.USB_PID_STREAMDOCK_M18V25, StreamDockM18),
# (USBVendorIDs.USB_VID_M18V25EN, USBProductIDs.USB_PID_STREAMDOCK_M18V25EN, StreamDockM18),
# (USBVendorIDs.USB_VID_M18V3, USBProductIDs.USB_PID_STREAMDOCK_M18V3, StreamDockM18),
# (USBVendorIDs.USB_VID_M18V3EN, USBProductIDs.USB_PID_STREAMDOCK_M18V3EN, StreamDockM18),
# M3
(USBVendorIDs.USB_VID_M3, USBProductIDs.USB_PID_STREAMDOCK_M3, StreamDockM3),
# K1 Pro
(USBVendorIDs.USB_VID_K1_PRO, USBProductIDs.USB_PID_K1_PRO, K1Pro),
(USBVendorIDs.USB_VID_K1_PROEU, USBProductIDs.USB_PID_K1_PROEU, K1Pro),
]

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

0
StreamDock/__init__.py Normal file
View File

123
config.example.yaml Normal file
View File

@ -0,0 +1,123 @@
homeassistant:
url: "ws://homeassistant.local:8123/api/websocket"
token: "YOUR_LONG_LIVED_ACCESS_TOKEN_HERE"
navigation:
page_cycle: "button_8"
knob_left: "knob_1"
knob_right: "knob_2"
knob_press_left: "knob_1"
knob_press_right: "knob_2"
pages:
- name: "Lights"
keys:
1:
entity: "light.living_room"
service: "toggle"
icon: "lightbulb"
label: "Living"
2:
entity: "light.kitchen"
service: "toggle"
icon: "lightbulb"
label: "Kitchen"
3:
entity: "light.bedroom"
service: "toggle"
icon: "lightbulb"
label: "Bedroom"
4:
entity: "light.bathroom"
service: "toggle"
icon: "lightbulb"
label: "Bath"
5:
entity: "light.hallway"
service: "toggle"
icon: "lightbulb"
label: "Hallway"
6:
entity: "light.office"
service: "toggle"
icon: "lightbulb"
label: "Office"
knobs:
knob_3:
entity: "light.all_lights"
service: "toggle"
icon: "lightbulb"
- name: "Climate"
keys:
1:
entity: "climate.living_room"
service: "toggle"
icon: "thermostat"
label: "LR HVAC"
2:
entity: "sensor.living_room_temperature"
icon: "thermometer"
label: "LR Temp"
3:
entity: "climate.bedroom"
service: "toggle"
icon: "thermostat"
label: "BR HVAC"
4:
entity: "sensor.bedroom_temperature"
icon: "thermometer"
label: "BR Temp"
5:
entity: "sensor.humidity"
icon: "thermometer"
label: "Humid"
6:
entity: "switch.heater"
service: "toggle"
icon: "power"
label: "Heater"
- name: "Media"
keys:
1:
entity: "media_player.living_room"
service: "toggle"
icon: "play"
label: "Living"
2:
entity: "media_player.bedroom"
service: "toggle"
icon: "play"
label: "Bedroom"
3:
entity: "script.volume_up"
service: "turn_on"
icon: "speaker"
label: "Vol +"
4:
entity: "script.volume_down"
service: "turn_on"
icon: "speaker"
label: "Vol -"
5:
entity: "scene.movie_night"
service: "turn_on"
icon: "film"
label: "Movie"
6:
entity: "script.mute"
service: "turn_on"
icon: "speaker"
label: "Mute"
knobs:
knob_3:
rotate_left:
entity: "script.volume_down"
service: "turn_on"
rotate_right:
entity: "script.volume_up"
service: "turn_on"
press:
entity: "script.mute"
service: "turn_on"

30
config.py Normal file
View File

@ -0,0 +1,30 @@
import yaml
import os
CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.yaml")
_defaults = {
"homeassistant": {
"url": "ws://homeassistant.local:8123/api/websocket",
"token": "",
},
"pages": [],
}
def load_config(path=None):
p = path or CONFIG_PATH
with open(p, "r") as f:
cfg = yaml.safe_load(f)
if not cfg:
cfg = {}
for k, v in _defaults.items():
if k not in cfg:
cfg[k] = v
return cfg
def save_config(cfg, path=None):
p = path or CONFIG_PATH
with open(p, "w") as f:
yaml.dump(cfg, f, default_flow_style=False, sort_keys=False)

308
key_renderer.py Normal file
View File

@ -0,0 +1,308 @@
import os
import math
from PIL import Image, ImageDraw, ImageFont
_FONT_CACHE = {}
_FONT_SEARCH_PATHS = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
"/usr/share/fonts/TTF/DejaVuSans.ttf",
"/usr/share/fonts/truetype/freefont/FreeSansBold.ttf",
"/usr/share/fonts/truetype/freefont/FreeSans.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
]
def _get_font(size):
if size not in _FONT_CACHE:
for name in _FONT_SEARCH_PATHS:
if os.path.exists(name):
_FONT_CACHE[size] = ImageFont.truetype(name, size)
return _FONT_CACHE[size]
_FONT_CACHE[size] = ImageFont.load_default()
return _FONT_CACHE[size]
ICON_COLORS = {
"lightbulb": {"on": (255, 210, 50), "off": (80, 80, 80)},
"power": {"on": (50, 200, 50), "off": (80, 80, 80)},
"thermometer": {"on": (200, 60, 30), "off": (80, 80, 80)},
"thermostat": {"on": (50, 150, 220), "off": (80, 80, 80)},
"play": {"on": (50, 200, 50), "off": (80, 80, 80)},
"film": {"on": (160, 50, 200), "off": (80, 80, 80)},
"door": {"on": (50, 200, 50), "off": (200, 50, 50)},
"lock": {"on": (50, 200, 50), "off": (200, 50, 50)},
"speaker": {"on": (50, 150, 220), "off": (80, 80, 80)},
"fan": {"on": (50, 200, 220), "off": (80, 80, 80)},
"default": {"on": (50, 150, 220), "off": (80, 80, 80)},
}
def _is_on(state_str):
s = state_str.lower() if state_str else "off"
return s in ("on", "playing", "open", "unlocked", "home", "heat", "cool", "auto")
def _get_colors(icon, state_str):
is_on = _is_on(state_str)
icon_key = icon if icon in ICON_COLORS else "default"
key = "on" if is_on else "off"
return ICON_COLORS[icon_key][key], is_on
def _draw_icon(draw, icon, cx, cy, r, color):
if icon == "lightbulb":
_draw_lightbulb(draw, cx, cy, r, color)
elif icon == "power":
_draw_power(draw, cx, cy, r, color)
elif icon == "thermometer":
_draw_thermometer(draw, cx, cy, r, color)
elif icon == "play":
_draw_play(draw, cx, cy, r, color)
elif icon == "film":
_draw_film(draw, cx, cy, r, color)
elif icon == "door":
_draw_door(draw, cx, cy, r, color)
elif icon == "lock":
_draw_lock(draw, cx, cy, r, color)
elif icon == "fan":
_draw_fan(draw, cx, cy, r, color)
elif icon == "thermostat":
_draw_thermostat(draw, cx, cy, r, color)
elif icon == "speaker":
_draw_speaker(draw, cx, cy, r, color)
else:
draw.ellipse([cx - r, cy - r, cx + r, cy + r], fill=color)
def _draw_lightbulb(draw, cx, cy, r, color):
bulb_r = int(r * 0.6)
bulb_cy = cy - int(r * 0.15)
draw.ellipse(
[cx - bulb_r, bulb_cy - bulb_r, cx + bulb_r, bulb_cy + bulb_r], fill=color
)
base_w = int(r * 0.35)
base_h = int(r * 0.3)
base_top = bulb_cy + bulb_r - 2
draw.rectangle(
[cx - base_w, base_top, cx + base_w, base_top + base_h], fill=color
)
for i in range(3):
yy = base_top + int(base_h * 0.2) + i * int(base_h * 0.3)
draw.line([cx - base_w, yy, cx + base_w, yy], fill=(30, 30, 30), width=1)
def _draw_power(draw, cx, cy, r, color):
gap = int(r * 0.2)
arc_r = int(r * 0.7)
draw.line([cx, cy - r, cx, cy - gap], fill=color, width=max(2, int(r * 0.15)))
bbox = [cx - arc_r, cy - arc_r, cx + arc_r, cy + arc_r]
draw.arc(bbox, start=225, end=315, fill=color, width=max(2, int(r * 0.12)))
draw.arc(bbox, start=45, end=135, fill=color, width=max(2, int(r * 0.12)))
def _draw_thermometer(draw, cx, cy, r, color):
bulb_r = int(r * 0.35)
bulb_cy = cy + int(r * 0.35)
draw.ellipse(
[cx - bulb_r, bulb_cy - bulb_r, cx + bulb_r, bulb_cy + bulb_r], fill=color
)
tube_w = int(r * 0.18)
tube_top = cy - int(r * 0.85)
draw.rectangle(
[cx - tube_w, tube_top, cx + tube_w, bulb_cy - bulb_r + 2], fill=color
)
fill_h = int(r * 0.5)
fill_top = bulb_cy - fill_h
draw.rectangle(
[cx - tube_w + 1, fill_top, cx + tube_w - 1, bulb_cy], fill=(220, 60, 30)
)
def _draw_play(draw, cx, cy, r, color):
pts = [
(cx - int(r * 0.4), cy - int(r * 0.6)),
(cx - int(r * 0.4), cy + int(r * 0.6)),
(cx + int(r * 0.6), cy),
]
draw.polygon(pts, fill=color)
def _draw_film(draw, cx, cy, r, color):
w, h = int(r * 1.2), int(r * 0.8)
draw.rectangle([cx - w, cy - h, cx + w, cy + h], outline=color, width=2)
for yy in [cy - h, cy + h]:
for xx_off in [-w, w]:
draw.rectangle(
[cx + xx_off - 3, yy - 3, cx + xx_off + 3, yy + 3], fill=color
)
draw.rectangle(
[cx - int(w * 0.6), cy - int(h * 0.5), cx + int(w * 0.6), cy + int(h * 0.5)],
fill=color,
)
def _draw_door(draw, cx, cy, r, color):
w, h = int(r * 0.6), int(r * 0.9)
draw.rectangle(
[cx - w, cy - h, cx + w, cy + h], outline=color, width=max(2, int(r * 0.08))
)
knob_r = max(2, int(r * 0.08))
draw.ellipse([cx + w - knob_r * 4, cy - knob_r, cx + w - knob_r * 2, cy + knob_r], fill=color)
def _draw_lock(draw, cx, cy, r, color):
body_w, body_h = int(r * 0.7), int(r * 0.5)
body_top = cy - int(r * 0.05)
draw.rectangle(
[cx - body_w, body_top, cx + body_w, body_top + body_h], fill=color
)
arc_r = int(r * 0.45)
arc_cy = body_top - 2
draw.arc(
[cx - arc_r, arc_cy - arc_r, cx + arc_r, arc_cy + arc_r],
start=180,
end=360,
fill=color,
width=max(2, int(r * 0.1)),
)
def _draw_fan(draw, cx, cy, r, color):
blade_r = int(r * 0.75)
for angle_offset in [0, 90, 180, 270]:
a = math.radians(angle_offset)
x2 = cx + int(blade_r * math.cos(a))
y2 = cy + int(blade_r * math.sin(a))
perp_x = int(blade_r * 0.3 * math.cos(a + math.pi / 2))
perp_y = int(blade_r * 0.3 * math.sin(a + math.pi / 2))
pts = [(cx, cy), (x2 + perp_x, y2 + perp_y), (x2 - perp_x, y2 - perp_y)]
draw.polygon(pts, fill=color)
draw.ellipse([cx - int(r * 0.15), cy - int(r * 0.15), cx + int(r * 0.15), cy + int(r * 0.15)], fill=(40, 40, 40))
def _draw_thermostat(draw, cx, cy, r, color):
draw.ellipse(
[cx - int(r * 0.7), cy - int(r * 0.7), cx + int(r * 0.7), cy + int(r * 0.7)],
outline=color,
width=max(2, int(r * 0.08)),
)
hand_len = int(r * 0.5)
angle = math.radians(-45)
hx = cx + int(hand_len * math.cos(angle))
hy = cy + int(hand_len * math.sin(angle))
draw.line([cx, cy, hx, hy], fill=color, width=max(2, int(r * 0.1)))
draw.ellipse([cx - 3, cy - 3, cx + 3, cy + 3], fill=color)
def _draw_speaker(draw, cx, cy, r, color):
draw.polygon(
[
(cx - int(r * 0.5), cy - int(r * 0.25)),
(cx - int(r * 0.15), cy - int(r * 0.25)),
(cx - int(r * 0.15), cy - int(r * 0.6)),
(cx + int(r * 0.2), cy - int(r * 0.6)),
],
fill=color,
)
draw.polygon(
[
(cx - int(r * 0.5), cy + int(r * 0.25)),
(cx - int(r * 0.15), cy + int(r * 0.25)),
(cx - int(r * 0.15), cy + int(r * 0.6)),
(cx + int(r * 0.2), cy + int(r * 0.6)),
],
fill=color,
)
draw.rectangle(
[cx - int(r * 0.5), cy - int(r * 0.25), cx - int(r * 0.15), cy + int(r * 0.25)],
fill=color,
)
def render_key_image(key_config, state_str, size=64):
icon = key_config.get("icon", "default")
label = key_config.get("label", "")
bg_color, is_on = _get_colors(icon, state_str)
img = Image.new("RGB", (size, size), (20, 20, 20))
draw = ImageDraw.Draw(img)
if is_on:
glow = Image.new("RGB", (size, size), (0, 0, 0))
gd = ImageDraw.Draw(glow)
glow_r = int(size * 0.45)
gx, gy = size // 2, size // 2 - int(size * 0.05)
for i in range(glow_r, 0, -1):
alpha = int(40 * (i / glow_r))
c = tuple(min(255, v * alpha // 100) for v in bg_color)
gd.ellipse([gx - i, gy - i, gx + i, gy + i], fill=c)
img = Image.blend(img, glow, 0.6)
draw = ImageDraw.Draw(img)
icon_r = int(size * 0.28)
icon_cy = size // 2 - int(size * 0.1)
_draw_icon(draw, icon, size // 2, icon_cy, icon_r, bg_color)
if label:
font_size = max(8, int(size * 0.19))
font = _get_font(font_size)
text = label[:8]
bbox = draw.textbbox((0, 0), text, font=font)
tw = bbox[2] - bbox[0]
tx = (size - tw) // 2
ty = size - int(size * 0.28)
draw.text((tx, ty), text, fill=(220, 220, 220), font=font)
state_display = _format_state(state_str, key_config)
if state_display:
font_size = max(7, int(size * 0.16))
font = _get_font(font_size)
bbox = draw.textbbox((0, 0), state_display, font=font)
tw = bbox[2] - bbox[0]
tx = (size - tw) // 2
ty = int(size * 0.06)
draw.text((tx, ty), state_display, fill=(180, 180, 180), font=font)
return img
def _format_state(state_str, key_config):
if not state_str:
return ""
s = state_str.lower()
if s in ("on", "off", "unavailable", "unknown"):
return s.capitalize()
if s in ("playing", "paused", "idle", "standby"):
return s.capitalize()
if s in ("open", "closed"):
return s.capitalize()
if s in ("home", "away", "not_home"):
return s.capitalize()
if any(unit in state_str for unit in ["°", "%", "V", "A", "W", "kW", "mph", "km/h", "Hz"]):
return state_str
return state_str[:7]
def render_background(pages, active_page, page_num=1, total_pages=1, size=(320, 240)):
img = Image.new("RGB", size, (25, 25, 30))
draw = ImageDraw.Draw(img)
font = _get_font(max(10, int(size[1] * 0.07)))
small_font = _get_font(max(8, int(size[1] * 0.05)))
draw.text((10, 10), active_page, fill=(255, 255, 255), font=font)
if total_pages > 1:
dot_r = 3
dot_spacing = 14
total_width = total_pages * dot_spacing
start_x = (size[0] - total_width) // 2
page_label_y = size[1] - 20
for i in range(total_pages):
cx = start_x + i * dot_spacing + dot_spacing // 2
if i == page_num - 1:
draw.ellipse([cx - dot_r, page_label_y - dot_r, cx + dot_r, page_label_y + dot_r], fill=(255, 255, 255))
else:
draw.ellipse([cx - dot_r, page_label_y - dot_r, cx + dot_r, page_label_y + dot_r], fill=(80, 80, 80))
return img

423
streamdock_ha.py Normal file
View File

@ -0,0 +1,423 @@
import asyncio
import io
import logging
import sys
import signal
from homeassistant_api import AsyncWebsocketClient, State
from StreamDock.DeviceManager import DeviceManager
from StreamDock.ImageHelpers.PILHelper import to_native_key_format, to_native_touchscreen_format
from StreamDock.InputTypes import EventType, Direction, KnobId
from config import load_config
from key_renderer import render_key_image, render_background
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%H:%M:%S",
)
log = logging.getLogger("streamdock-ha")
def _parse_button_ref(ref):
if not ref:
return None
ref = str(ref).lower().strip()
mapping = {
"button_7": 7, "key_7": 7,
"button_8": 8, "key_8": 8,
"button_9": 9, "key_9": 9,
}
return mapping.get(ref, int(ref) if ref.isdigit() else None)
def _parse_knob_ref(ref):
if not ref:
return None
ref = str(ref).lower().strip().replace("-", "_")
mapping = {
"knob_1": KnobId.KNOB_1, "knob1": KnobId.KNOB_1,
"knob_2": KnobId.KNOB_2, "knob2": KnobId.KNOB_2,
"knob_3": KnobId.KNOB_3, "knob3": KnobId.KNOB_3,
"knob_4": KnobId.KNOB_4, "knob4": KnobId.KNOB_4,
"left": KnobId.KNOB_1, "bottom_left": KnobId.KNOB_1,
"right": KnobId.KNOB_2, "bottom_right": KnobId.KNOB_2,
"top": KnobId.KNOB_3,
}
if ref in mapping:
return mapping[ref]
for knob in KnobId:
if knob.value == ref:
return knob
return None
class StreamDockHA:
def __init__(self, config_path=None):
self.cfg = load_config(config_path)
self.ha_url = self.cfg["homeassistant"]["url"]
self.ha_token = self.cfg["homeassistant"]["token"]
self.pages = self.cfg.get("pages", [])
self.active_page_idx = 0
nav = self.cfg.get("navigation", {})
self.page_cycle_key = _parse_button_ref(nav.get("page_cycle", "button_8"))
self.nav_knob_left = _parse_knob_ref(nav.get("knob_left"))
self.nav_knob_right = _parse_knob_ref(nav.get("knob_right"))
self.nav_knob_press_left = _parse_knob_ref(nav.get("knob_press_left"))
self.nav_knob_press_right = _parse_knob_ref(nav.get("knob_press_right"))
self.device = None
self.ha_client = None
self.entity_states = {}
self._all_key_configs = []
self._all_knob_configs = []
self._running = False
def _build_all_configs(self):
self._all_key_configs = []
self._all_knob_configs = []
for page in self.pages:
page_keys = {}
raw_keys = page.get("keys", {})
for key_str, cfg in raw_keys.items():
key_id = int(key_str) if isinstance(key_str, str) else key_str
page_keys[key_id] = cfg
self._all_key_configs.append(page_keys)
page_knobs = {}
raw_knobs = page.get("knobs", {})
for knob_ref, knob_cfg in raw_knobs.items():
knob_id = _parse_knob_ref(knob_ref)
if knob_id:
page_knobs[knob_id] = knob_cfg
self._all_knob_configs.append(page_knobs)
@property
def active_keys(self):
if not self._all_key_configs:
return {}
return self._all_key_configs[self.active_page_idx]
@property
def active_knobs(self):
if not self._all_knob_configs:
return {}
return self._all_knob_configs[self.active_page_idx]
def _all_entities(self):
entities = set()
for page_keys in self._all_key_configs:
for cfg in page_keys.values():
entity = cfg.get("entity")
if entity:
entities.add(entity)
for page_knobs in self._all_knob_configs:
for knob_cfg in page_knobs.values():
for action_key in ("press", "rotate_left", "rotate_right"):
action = knob_cfg.get(action_key, {})
if isinstance(action, dict):
entity = action.get("entity")
if entity:
entities.add(entity)
entity = knob_cfg.get("entity")
if entity:
entities.add(entity)
return entities
async def _fetch_initial_states(self):
if not self.ha_client:
return
entities = self._all_entities()
if not entities:
return
try:
states = await self.ha_client.get_states()
for state in states:
if state.entity_id in entities:
self.entity_states[state.entity_id] = state.state
log.info(f"Fetched states for {len(entities)} entities")
except Exception as e:
log.error(f"Failed to fetch initial states: {e}")
async def _render_page(self):
if not self.device:
return
await self._render_background()
for key_id, cfg in self.active_keys.items():
await self._update_key_image(key_id)
self.device.refresh()
log.info(f"Page rendered: {self.pages[self.active_page_idx].get('name', '')}")
async def _render_background(self):
if not self.device:
return
active_name = self.pages[self.active_page_idx].get("name", "")
total = len(self.pages)
bg = render_background(
self.pages, active_name,
page_num=self.active_page_idx + 1,
total_pages=total,
)
bg_processed = to_native_touchscreen_format(self.device, bg)
buf = io.BytesIO()
bg_processed.save(buf, format="JPEG", quality=85)
self.device.transport.set_background_image_stream(buf.getvalue())
async def _update_key_image(self, key_id):
cfg = self.active_keys.get(key_id)
if not cfg:
return
entity = cfg.get("entity")
state = self.entity_states.get(entity, "unknown") if entity else "off"
try:
img = render_key_image(cfg, state, size=64)
img_processed = to_native_key_format(self.device, img)
buf = io.BytesIO()
img_processed.save(buf, format="JPEG", quality=90)
jpeg_data = buf.getvalue()
self.device.transport.set_key_image_stream(jpeg_data, key_id)
except Exception as e:
log.error(f"Failed to update key {key_id}: {e}")
async def _cycle_page(self, direction=1):
if len(self.pages) <= 1:
return
old_idx = self.active_page_idx
self.active_page_idx = (self.active_page_idx + direction) % len(self.pages)
if self.active_page_idx == old_idx:
return
page_name = self.pages[self.active_page_idx].get("name", "")
log.info(f"Page: {page_name} ({self.active_page_idx + 1}/{len(self.pages)})")
self.device.clearAllIcon()
await self._render_page()
async def _handle_event(self, device, event):
if event.event_type == EventType.KNOB_ROTATE:
await self._handle_knob_rotate(event)
return
if event.event_type == EventType.KNOB_PRESS:
await self._handle_knob_press(event)
return
if event.event_type != EventType.BUTTON:
return
if event.state != 1:
return
key_id = event.key.value if event.key else None
if key_id is None:
return
if key_id == self.page_cycle_key:
await self._cycle_page(1)
return
cfg = self.active_keys.get(key_id)
if not cfg:
return
await self._call_service_from_cfg(cfg, f"Key {key_id}")
async def _handle_knob_rotate(self, event):
knob_id = event.knob_id
if not knob_id:
return
is_right = event.direction == Direction.RIGHT
if is_right and knob_id == self.nav_knob_right:
await self._cycle_page(1)
return
if not is_right and knob_id == self.nav_knob_left:
await self._cycle_page(-1)
return
knob_cfg = self.active_knobs.get(knob_id)
if not knob_cfg:
return
action_key = "rotate_right" if is_right else "rotate_left"
action = knob_cfg.get(action_key)
if not action:
return
await self._call_service_from_cfg(action, f"Knob {knob_id.value} {action_key}")
async def _handle_knob_press(self, event):
knob_id = event.knob_id
if not knob_id:
return
if event.state != 1:
return
if knob_id == self.nav_knob_press_left:
await self._cycle_page(-1)
return
if knob_id == self.nav_knob_press_right:
await self._cycle_page(1)
return
knob_cfg = self.active_knobs.get(knob_id)
if not knob_cfg:
return
press_action = knob_cfg.get("press")
if not press_action:
entity = knob_cfg.get("entity")
service = knob_cfg.get("service")
if entity and service:
await self._call_service(entity, service, f"Knob {knob_id.value} press")
return
await self._call_service_from_cfg(press_action, f"Knob {knob_id.value} press")
async def _call_service_from_cfg(self, cfg, label=""):
if isinstance(cfg, dict):
entity = cfg.get("entity")
service = cfg.get("service")
else:
return
if not entity or not service:
return
await self._call_service(entity, service, label)
async def _call_service(self, entity, service, label=""):
domain = entity.split(".")[0]
log.info(f"{label}: calling {domain}.{service} on {entity}")
try:
await self.ha_client.trigger_service(domain, service, entity_id=entity)
except Exception as e:
log.error(f"Failed to call service {domain}.{service}: {e}")
async def _listen_state_changes(self):
if not self.ha_client:
return
log.info("Listening for HA state changes...")
try:
async with self.ha_client.listen_events("state_changed") as events:
async for event in events:
if not self._running:
break
try:
data = event.data if hasattr(event, "data") else {}
entity_id = data.get("entity_id", "")
new_state = data.get("new_state")
if not entity_id or not new_state:
continue
if isinstance(new_state, State):
state_val = new_state.state
elif isinstance(new_state, dict):
state_val = new_state.get("state", "")
else:
state_val = str(new_state)
old_val = self.entity_states.get(entity_id)
self.entity_states[entity_id] = state_val
if old_val != state_val:
log.info(f"State changed: {entity_id} = {state_val}")
await self._update_visible_keys(entity_id)
except Exception as e:
log.error(f"Error processing state change: {e}")
except Exception as e:
log.error(f"HA event listener error: {e}")
async def _update_visible_keys(self, entity_id):
for key_id, cfg in self.active_keys.items():
if cfg.get("entity") == entity_id:
await self._update_key_image(key_id)
self.device.refresh()
async def _setup_device(self):
manager = DeviceManager()
devices = manager.enumerate()
if not devices:
log.error("No StreamDock device found")
return False
self.device = devices[0]
log.info(
f"Found {type(self.device).__name__} at {self.device.path} (serial: {self.device.serial_number})"
)
self.device.open()
self.device.init()
self.device.set_key_callback(self._make_sync_callback())
return True
def _make_sync_callback(self):
loop = asyncio.get_event_loop()
def callback(device, event):
asyncio.run_coroutine_threadsafe(self._handle_event(device, event), loop)
return callback
async def run(self):
if not self.pages:
log.error("No pages configured in config.yaml")
return
if not await self._setup_device():
return
self._build_all_configs()
async with AsyncWebsocketClient(self.ha_url, self.ha_token) as client:
self.ha_client = client
log.info("Connected to Home Assistant")
self._running = True
await self._fetch_initial_states()
await self._render_page()
listen_task = asyncio.create_task(self._listen_state_changes())
nav_info = []
if self.page_cycle_key:
nav_info.append(f"Key {self.page_cycle_key}=next page")
if self.nav_knob_left or self.nav_knob_right:
nav_info.append("knob rotation=prev/next page")
log.info(f"StreamDock-HA running ({len(self.pages)} pages). {'; '.join(nav_info)}. Ctrl+C to exit.")
try:
while self._running:
await asyncio.sleep(1)
except (KeyboardInterrupt, asyncio.CancelledError):
pass
finally:
self._running = False
listen_task.cancel()
try:
await listen_task
except asyncio.CancelledError:
pass
if self.device:
self.device.close()
log.info("Shutdown complete")
def shutdown(self):
self._running = False
def main():
config_path = None
for i, arg in enumerate(sys.argv):
if arg in ("-c", "--config") and i + 1 < len(sys.argv):
config_path = sys.argv[i + 1]
app = StreamDockHA(config_path)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, app.shutdown)
try:
loop.run_until_complete(app.run())
except KeyboardInterrupt:
app.shutdown()
loop.run_until_complete(app.run())
finally:
loop.close()
if __name__ == "__main__":
main()