commit 06f4c2768c9c3b71c0cc314dde5c7910bbab2e24 Author: Karl Date: Thu May 7 19:14:07 2026 +0100 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5028fe6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +venv/ +__pycache__/ +*.pyc +*.pyo +.DS_Store +*.egg-info/ +dist/ +build/ +rotated_*.jpg +config.yaml \ No newline at end of file diff --git a/99-streamdock.rules b/99-streamdock.rules new file mode 100644 index 0000000..7d545a2 --- /dev/null +++ b/99-streamdock.rules @@ -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" \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..4aae48d --- /dev/null +++ b/README.md @@ -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. \ No newline at end of file diff --git a/StreamDock/DeviceManager.py b/StreamDock/DeviceManager.py new file mode 100644 index 0000000..80bdbf8 --- /dev/null +++ b/StreamDock/DeviceManager.py @@ -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) + diff --git a/StreamDock/Devices/K1Pro.py b/StreamDock/Devices/K1Pro.py new file mode 100644 index 0000000..0eb6345 --- /dev/null +++ b/StreamDock/Devices/K1Pro.py @@ -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) diff --git a/StreamDock/Devices/StreamDock.py b/StreamDock/Devices/StreamDock.py new file mode 100644 index 0000000..623a1d6 --- /dev/null +++ b/StreamDock/Devices/StreamDock.py @@ -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) diff --git a/StreamDock/Devices/StreamDock293.py b/StreamDock/Devices/StreamDock293.py new file mode 100644 index 0000000..581e2e2 --- /dev/null +++ b/StreamDock/Devices/StreamDock293.py @@ -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 diff --git a/StreamDock/Devices/StreamDock293V3.py b/StreamDock/Devices/StreamDock293V3.py new file mode 100644 index 0000000..e05fbb3 --- /dev/null +++ b/StreamDock/Devices/StreamDock293V3.py @@ -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 diff --git a/StreamDock/Devices/StreamDock293s.py b/StreamDock/Devices/StreamDock293s.py new file mode 100644 index 0000000..d83ff46 --- /dev/null +++ b/StreamDock/Devices/StreamDock293s.py @@ -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 diff --git a/StreamDock/Devices/StreamDock293sV3.py b/StreamDock/Devices/StreamDock293sV3.py new file mode 100644 index 0000000..1a9c9c8 --- /dev/null +++ b/StreamDock/Devices/StreamDock293sV3.py @@ -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 diff --git a/StreamDock/Devices/StreamDockM18.py b/StreamDock/Devices/StreamDockM18.py new file mode 100644 index 0000000..e419558 --- /dev/null +++ b/StreamDock/Devices/StreamDockM18.py @@ -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 diff --git a/StreamDock/Devices/StreamDockM3.py b/StreamDock/Devices/StreamDockM3.py new file mode 100644 index 0000000..6d40c82 --- /dev/null +++ b/StreamDock/Devices/StreamDockM3.py @@ -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 diff --git a/StreamDock/Devices/StreamDockN1.py b/StreamDock/Devices/StreamDockN1.py new file mode 100644 index 0000000..d7b6cd2 --- /dev/null +++ b/StreamDock/Devices/StreamDockN1.py @@ -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 diff --git a/StreamDock/Devices/StreamDockN3.py b/StreamDock/Devices/StreamDockN3.py new file mode 100644 index 0000000..f9ba72d --- /dev/null +++ b/StreamDock/Devices/StreamDockN3.py @@ -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 \ No newline at end of file diff --git a/StreamDock/Devices/StreamDockN4.py b/StreamDock/Devices/StreamDockN4.py new file mode 100644 index 0000000..bda4f45 --- /dev/null +++ b/StreamDock/Devices/StreamDockN4.py @@ -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 diff --git a/StreamDock/Devices/StreamDockN4Pro.py b/StreamDock/Devices/StreamDockN4Pro.py new file mode 100644 index 0000000..a292ea4 --- /dev/null +++ b/StreamDock/Devices/StreamDockN4Pro.py @@ -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 diff --git a/StreamDock/Devices/StreamDockXL.py b/StreamDock/Devices/StreamDockXL.py new file mode 100644 index 0000000..4e6fb57 --- /dev/null +++ b/StreamDock/Devices/StreamDockXL.py @@ -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 diff --git a/StreamDock/Devices/__init__.py b/StreamDock/Devices/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/StreamDock/FeatrueOption.py b/StreamDock/FeatrueOption.py new file mode 100644 index 0000000..358ac1e --- /dev/null +++ b/StreamDock/FeatrueOption.py @@ -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 diff --git a/StreamDock/ImageHelpers/PILHelper.py b/StreamDock/ImageHelpers/PILHelper.py new file mode 100644 index 0000000..426aa10 --- /dev/null +++ b/StreamDock/ImageHelpers/PILHelper.py @@ -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()) diff --git a/StreamDock/ImageHelpers/__init__.py b/StreamDock/ImageHelpers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/StreamDock/InputTypes.py b/StreamDock/InputTypes.py new file mode 100644 index 0000000..69c116c --- /dev/null +++ b/StreamDock/InputTypes.py @@ -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") diff --git a/StreamDock/ProductIDs.py b/StreamDock/ProductIDs.py new file mode 100644 index 0000000..cc126c4 --- /dev/null +++ b/StreamDock/ProductIDs.py @@ -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), + +] diff --git a/StreamDock/Transport/LibUSBHIDAPI.py b/StreamDock/Transport/LibUSBHIDAPI.py new file mode 100644 index 0000000..9782936 --- /dev/null +++ b/StreamDock/Transport/LibUSBHIDAPI.py @@ -0,0 +1,1398 @@ +""" +LibUSBHIDAPI - Python wrapper for StreamDock Transport C library + +This module provides a Python interface to the StreamDock Transport library, +encapsulating HID device operations such as: +- Device initialization and management +- I/O operations (read/write) +- Image transfer (key and background images) +- LED control +- Device configuration and control + +The wrapper follows RAII-style resource management patterns and provides +a clean, Pythonic interface to the underlying C API. +""" + +import os +import ctypes +import platform +from ctypes import ( + POINTER, + c_size_t, + c_uint8, + c_void_p, + c_char_p, + c_int, + c_ulong, + c_ubyte, + c_uint16, + c_uint32, + c_int32, + c_wchar_p, + c_char, +) +import re +from typing import Optional, List, Tuple + + +def _get_glibc_version() -> Tuple[int, int]: + """ + Get the system's glibc version. + + Returns: + Tuple[int, int]: (major_version, minor_version) + """ + try: + import ctypes.util + + # Try to get glibc version via libc + libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True) + # gnu_get_libc_version() returns a string like "2.39" + gnu_get_libc_version = libc.gnu_get_libc_version + gnu_get_libc_version.restype = c_char_p + version_str = gnu_get_libc_version().decode("utf-8") + parts = version_str.split(".") + if len(parts) >= 2: + return (int(parts[0]), int(parts[1])) + except Exception: + pass + return (2, 0) # Default to a low version if detection fails + + +def _get_dll_name() -> str: + """ + Determine the appropriate transport library name based on platform and architecture. + + For Linux, searches for libraries with glibc version suffixes (e.g., libtransport_glibc2.39.so) + and selects the best match for the system's glibc version. + + Returns: + str: The library filename to load + + Raises: + RuntimeError: If the platform/architecture combination is not supported + """ + search_library_names = { + "Windows": {"x86_64": "transport.dll"}, + "Darwin": {"x86_64": "libtransport.dylib", "arm64": "libtransport_arm64.dylib"}, + } + + platform_name = platform.system() + machine_type = platform.machine().lower() + + if platform_name == "Windows": + return search_library_names["Windows"]["x86_64"] + elif platform_name == "Darwin": + if "x86_64" in machine_type or "amd64" in machine_type: + return search_library_names["Darwin"]["x86_64"] + elif "arm64" in machine_type: + return search_library_names["Darwin"]["arm64"] + elif platform_name == "Linux": + # For Linux, search for glibc-versioned libraries + dll_dir = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "TransportDLL" + ) + + # Determine architecture prefix + if "aarch64" in machine_type or "arm64" in machine_type: + arch_prefix = "arm64" + fallback_name = "libtransport_arm64.so" + elif "x86_64" in machine_type or "amd64" in machine_type: + arch_prefix = "" + fallback_name = "libtransport.so" + else: + raise RuntimeError(f"Unsupported architecture on Linux: {machine_type}") + + # Pattern for glibc-versioned libraries: + # libtransport[_arm64]_glibcX.XX.so or libtransport_glibcX.XX.so + pattern = re.compile(rf"^libtransport(_{arch_prefix})?_glibc(\d+)\.(\d+)\.so$") + + # Search for matching libraries + candidates = [] + if os.path.exists(dll_dir): + for filename in os.listdir(dll_dir): + match = pattern.match(filename) + if match: + major = int(match.group(2)) + minor = int(match.group(3)) + candidates.append((major, minor, filename)) + + if candidates: + # Get system glibc version + sys_glibc = _get_glibc_version() + + # Find the best match: highest version that doesn't exceed system version + best_match = None + for major, minor, filename in sorted( + candidates, key=lambda x: (x[0], x[1]) + ): + if major < sys_glibc[0] or ( + major == sys_glibc[0] and minor <= sys_glibc[1] + ): + best_match = filename + elif best_match is None: + # If no compatible version found, use the lowest version as fallback + best_match = filename + break + + if best_match: + return best_match + + # Fallback to old naming convention + return fallback_name + + raise RuntimeError( + f"Unsupported platform/architecture: {platform_name} / {machine_type}" + ) + + +# Load the transport library +_dll_name = _get_dll_name() +_dll_path = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "TransportDLL", _dll_name +) +_transport_lib = ctypes.CDLL(_dll_path) + + +class _HidDeviceInfo(ctypes.Structure): + """ + Structure definition for the hid_device_info structure defined + in the HIDAPI library. + """ + + pass + + +_HidDeviceInfo._fields_ = [ + ("path", ctypes.c_char_p), + ("vendor_id", ctypes.c_ushort), + ("product_id", ctypes.c_ushort), + ("serial_number", ctypes.c_wchar_p), + ("release_number", ctypes.c_ushort), + ("manufacturer_string", ctypes.c_wchar_p), + ("product_string", ctypes.c_wchar_p), + ("usage_page", ctypes.c_ushort), + ("usage", ctypes.c_ushort), + ("interface_number", ctypes.c_int), + ("next", ctypes.POINTER(_HidDeviceInfo)), +] + + +# Define C function signatures +_transport_lib.transport_create.restype = c_uint32 # TransportResult +_transport_lib.transport_create.argtypes = [POINTER(_HidDeviceInfo), POINTER(c_void_p)] + +_transport_lib.transport_destroy.restype = c_uint32 # TransportResult +_transport_lib.transport_destroy.argtypes = [c_void_p] + +_transport_lib.transport_get_firmware_version.restype = c_uint32 +_transport_lib.transport_get_firmware_version.argtypes = [c_void_p, c_char_p, c_size_t] + +_transport_lib.transport_clear_task_queue.restype = c_uint32 +_transport_lib.transport_clear_task_queue.argtypes = [c_void_p] + +_transport_lib.transport_can_write.restype = c_uint32 +_transport_lib.transport_can_write.argtypes = [c_void_p, POINTER(c_int)] + +_transport_lib.transport_read.restype = c_uint32 # TransportResult +_transport_lib.transport_read.argtypes = [ + c_void_p, + POINTER(c_uint8), + POINTER(c_size_t), + c_int32, +] + +_transport_lib.transport_wakeup_screen.restype = c_uint32 +_transport_lib.transport_wakeup_screen.argtypes = [c_void_p] + +_transport_lib.transport_magnetic_calibration.restype = c_uint32 +_transport_lib.transport_magnetic_calibration.argtypes = [c_void_p] + +_transport_lib.transport_set_key_brightness.restype = c_uint32 +_transport_lib.transport_set_key_brightness.argtypes = [c_void_p, c_uint8] + +_transport_lib.transport_clear_all_keys.restype = c_uint32 +_transport_lib.transport_clear_all_keys.argtypes = [c_void_p] + +_transport_lib.transport_clear_key.restype = c_uint32 +_transport_lib.transport_clear_key.argtypes = [c_void_p, c_uint8] + +_transport_lib.transport_refresh.restype = c_uint32 +_transport_lib.transport_refresh.argtypes = [c_void_p] + +_transport_lib.transport_sleep.restype = c_uint32 +_transport_lib.transport_sleep.argtypes = [c_void_p] + +_transport_lib.transport_disconnected.restype = c_uint32 +_transport_lib.transport_disconnected.argtypes = [c_void_p] + +_transport_lib.transport_heartbeat.restype = c_uint32 +_transport_lib.transport_heartbeat.argtypes = [c_void_p] + +_transport_lib.transport_set_background_bitmap.restype = c_uint32 +_transport_lib.transport_set_background_bitmap.argtypes = [ + c_void_p, + c_char_p, + c_size_t, + c_uint32, +] + +_transport_lib.transport_set_key_image_stream.restype = c_uint32 +_transport_lib.transport_set_key_image_stream.argtypes = [ + c_void_p, + c_char_p, + c_size_t, + c_uint8, +] + +_transport_lib.transport_set_background_image_stream.restype = c_uint32 +_transport_lib.transport_set_background_image_stream.argtypes = [ + c_void_p, + c_char_p, + c_size_t, + c_uint32, +] + +_transport_lib.transport_set_background_frame_stream.restype = c_uint32 +_transport_lib.transport_set_background_frame_stream.argtypes = [ + c_void_p, + c_char_p, + c_size_t, + c_uint16, + c_uint16, + c_uint16, + c_uint16, + c_uint8, +] + +_transport_lib.transport_clear_background_frame_stream.restype = c_uint32 +_transport_lib.transport_clear_background_frame_stream.argtypes = [c_void_p, c_uint8] + +_transport_lib.transport_set_led_brightness.restype = c_uint32 +_transport_lib.transport_set_led_brightness.argtypes = [c_void_p, c_uint8] + +_transport_lib.transport_set_led_color.restype = c_uint32 +_transport_lib.transport_set_led_color.argtypes = [ + c_void_p, + c_uint16, + c_uint8, + c_uint8, + c_uint8, +] + +_transport_lib.transport_reset_led_color.restype = c_uint32 +_transport_lib.transport_reset_led_color.argtypes = [c_void_p] + +_transport_lib.transport_set_device_config.restype = c_uint32 +_transport_lib.transport_set_device_config.argtypes = [ + c_void_p, + POINTER(c_uint8), + c_size_t, +] + +_transport_lib.transport_change_mode.restype = c_uint32 +_transport_lib.transport_change_mode.argtypes = [c_void_p, c_uint8] + +_transport_lib.transport_change_page.restype = c_uint32 +_transport_lib.transport_change_page.argtypes = [c_void_p, c_uint8] + +_transport_lib.transport_set_n1_skin_bitmap.restype = c_uint32 +_transport_lib.transport_set_n1_skin_bitmap.argtypes = [ + c_void_p, + c_char_p, + c_size_t, + c_uint8, + c_uint8, + c_uint8, + c_uint8, + c_int32, +] + +_transport_lib.transport_set_reportID.restype = c_uint32 +_transport_lib.transport_set_reportID.argtypes = [c_void_p, c_uint8] + +_transport_lib.transport_reportID.restype = c_uint32 +_transport_lib.transport_reportID.argtypes = [c_void_p, POINTER(c_uint8)] + +_transport_lib.transport_set_reportSize.restype = c_uint32 +_transport_lib.transport_set_reportSize.argtypes = [ + c_void_p, + c_uint16, + c_uint16, + c_uint16, +] + +_transport_lib.transport_raw_hid_last_error.restype = c_uint32 +_transport_lib.transport_raw_hid_last_error.argtypes = [ + c_void_p, + ctypes.c_void_p, + POINTER(c_size_t), +] + +_transport_lib.transport_disable_output.restype = c_uint32 +_transport_lib.transport_disable_output.argtypes = [ctypes.c_int8] + +# ========== Keyboard Lighting Functions ========== +_transport_lib.transport_set_keyboard_backlight_brightness.restype = c_uint32 +_transport_lib.transport_set_keyboard_backlight_brightness.argtypes = [ + c_void_p, + c_uint8, +] + +_transport_lib.transport_set_keyboard_lighting_effects.restype = c_uint32 +_transport_lib.transport_set_keyboard_lighting_effects.argtypes = [c_void_p, c_uint8] + +_transport_lib.transport_set_keyboard_lighting_speed.restype = c_uint32 +_transport_lib.transport_set_keyboard_lighting_speed.argtypes = [c_void_p, c_uint8] + +_transport_lib.transport_set_keyboard_rgb_backlight.restype = c_uint32 +_transport_lib.transport_set_keyboard_rgb_backlight.argtypes = [ + c_void_p, + c_uint8, + c_uint8, + c_uint8, +] + +_transport_lib.transport_keyboard_os_mode_switch.restype = c_uint32 +_transport_lib.transport_keyboard_os_mode_switch.argtypes = [c_void_p, c_uint8] + +# Add missing function signature +_transport_lib.transport_get_last_error_info.restype = c_uint32 # TransportResult +_transport_lib.transport_get_last_error_info.argtypes = [ + c_void_p, + c_void_p, +] # TransportErrorInfo* + +# Load hidapi functions directly from the transport library +# This prevents conflicts with Python's hidapi package +try: + _transport_lib.transport_hid_enumerate.restype = POINTER(_HidDeviceInfo) + _transport_lib.transport_hid_enumerate.argtypes = [c_uint16, c_uint16] + + _transport_lib.transport_hid_free_enumeration.restype = None + _transport_lib.transport_hid_free_enumeration.argtypes = [POINTER(_HidDeviceInfo)] + + _HID_API_AVAILABLE = True +except AttributeError: + _HID_API_AVAILABLE = False + print("Warning: hidapi functions not available in transport library") + + +class LibUSBHIDAPI: + """ + Python wrapper for the StreamDock Transport C library. + + This class provides a high-level, Pythonic interface to the underlying C transport library, + managing HID device operations with RAII-style resource management. + + Features: + - Device initialization and cleanup + - I/O operations (read/write) + - Image transfer (key images, background images, bitmap streams) + - LED control (brightness, color) + - Device configuration and mode switching + - Firmware version retrieval + + Example: + device_info = get_device_info() # from hidapi + device = LibUSBHIDAPI(device_info) + device.set_led_color(1, 255, 0, 0) # Set first LED to red + device.set_key_brightness(50) + firmware = device.get_firmware_version() + """ + + def __init__(self, device_info: Optional[_HidDeviceInfo] = None): + """ + Initialize the transport wrapper. + + Args: + device_info: HID device information structure. If None, creates an uninitialized handle. + """ + self._handle = None + self._input_report_size = 0 + self._output_report_size = 0 + self._feature_report_size = 0 + # CRITICAL: Store device_info properly for resource management + self._device_info = device_info + self._is_open = False + + # Don't create handle immediately, wait for open() call + # This maintains compatibility with the existing StreamDock API + + def __del__(self): + """ + Destructor - automatically releases the transport handle. + + CRITICAL: This is called during garbage collection which may happen + during interpreter shutdown. We need to be extremely careful about + calling C code here as it can cause segmentation faults. + """ + # Only destroy if we have a handle and Python interpreter is still running + if self._handle: + try: + # Check if Python interpreter is shutting down + import sys + + if sys.is_finalizing(): + # During interpreter shutdown, skip C calls to avoid segfault + # The OS will clean up resources when process exits + return + _transport_lib.transport_destroy(self._handle) + except (AttributeError, TypeError, ValueError): + # C library may already be unloaded or corrupted state + # Silently skip to avoid cascading failures during shutdown + pass + finally: + self._handle = None + + def __enter__(self): + """Context manager support.""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager cleanup.""" + self.__del__() + return False + + # ========== Device Information and Status ========== + + def get_firmware_version(self) -> str: + """ + Get the firmware version string from the device. + + Returns: + str: Firmware version string + """ + if not self._handle: + return "" + + buffer_size = 64 + buffer = ctypes.create_string_buffer(buffer_size) + result = _transport_lib.transport_get_firmware_version( + self._handle, buffer, buffer_size + ) + if result != 0: + return "" + raw = buffer.raw + + parts = raw.split(b"\x00") + decoded = "" + for part in parts: + if part: + try: + decoded = part.decode("utf-8", errors="ignore") + break + except Exception: + # fallback to continue searching + continue + + return decoded + + def clear_task_queue(self) -> None: + """Clear all pending data in the transport library's task queue.""" + if not self._handle: + return + _transport_lib.transport_clear_task_queue(self._handle) + + def can_write(self) -> bool: + """ + Check if the device is currently writable. + + Returns: + bool: True if device can accept write operations + """ + if not self._handle: + return False + can_write_val = c_int() + result = _transport_lib.transport_can_write( + self._handle, ctypes.byref(can_write_val) + ) + if result != 0: + return False + return bool(can_write_val.value) + + def read(self, timeout_ms: int = -1) -> Optional[bytes]: + """ + Read data from the device. + + Args: + timeout_ms: Timeout in milliseconds. -1 means blocking read. + + Returns: + bytes: Data read from device, or None if error occurred + """ + if not self._handle: + return None + + buffer_size = max(self._input_report_size, 1024) + response = (c_uint8 * buffer_size)() + length = c_size_t(buffer_size) + + result = _transport_lib.transport_read( + self._handle, response, ctypes.byref(length), timeout_ms + ) + + if result == 0: # TRANSPORT_SUCCESS is 0 + return bytes(response[: length.value]) + return None + + # ========== Screen Control ========== + + def wakeup_screen(self) -> None: + """Wake up the device screen.""" + if not self._handle: + return + _transport_lib.transport_wakeup_screen(self._handle) + + def magnetic_calibration(self) -> None: + """Perform magnetic calibration.""" + if not self._handle: + return + _transport_lib.transport_magnetic_calibration(self._handle) + + def refresh_screen(self) -> None: + """Refresh the screen display.""" + if not self._handle: + return + _transport_lib.transport_refresh(self._handle) + + def sleep(self) -> None: + """Put the device into sleep mode.""" + if not self._handle: + return + _transport_lib.transport_sleep(self._handle) + + # ========== Key Control ========== + + def set_key_brightness(self, brightness: int) -> None: + """ + Set the brightness of keys. + + Args: + brightness: Brightness value, typically 0-100 + """ + if not self._handle: + return + _transport_lib.transport_set_key_brightness(self._handle, brightness) + + def clear_all_keys(self) -> None: + """Clear all keys on the device.""" + if not self._handle: + return + _transport_lib.transport_clear_all_keys(self._handle) + + def clear_key(self, key_index: int) -> None: + """ + Clear the content of a specific key. + + Args: + key_index: Index of the key to clear + """ + if not self._handle: + return + _transport_lib.transport_clear_key(self._handle, key_index) + + # ========== Image Transfer ========== + + def set_background_bitmap(self, bitmap_data: bytes, timeout_ms: int = 5000) -> None: + """ + Set the full-screen background using raw bitmap data. + + Args: + bitmap_data: Raw bitmap bytes + timeout_ms: Transmission timeout in milliseconds + """ + if not self._handle: + return + _transport_lib.transport_set_background_bitmap( + self._handle, bitmap_data, len(bitmap_data), timeout_ms + ) + + def set_key_image_stream(self, jpeg_data: bytes, key_index: int) -> None: + """ + Set a JPEG image to a specific key. + + Args: + jpeg_data: JPEG image data + key_index: Target key index + """ + if not self._handle: + return + res = _transport_lib.transport_set_key_image_stream( + self._handle, jpeg_data, len(jpeg_data), key_index + ) + return res + + def set_background_image_stream( + self, jpeg_data: bytes, timeout_ms: int = 3000 + ) -> None: + """ + Set a JPEG image as full-screen background. + + Args: + jpeg_data: JPEG image data + timeout_ms: Transmission timeout in milliseconds + """ + if not self._handle: + return + _transport_lib.transport_set_background_image_stream( + self._handle, jpeg_data, len(jpeg_data), timeout_ms + ) + + def set_background_frame_stream( + self, + jpeg_data: bytes, + width: int, + height: int, + x: int = 0, + y: int = 0, + fb_layer: int = 0x00, + ) -> None: + """ + Draw a JPEG frame at a specific position (used for animated backgrounds). + + Args: + jpeg_data: JPEG image data + width: Image width + height: Image height + x: X-coordinate position + y: Y-coordinate position + fb_layer: Framebuffer layer index + """ + if not self._handle: + return + _transport_lib.transport_set_background_frame_stream( + self._handle, jpeg_data, len(jpeg_data), width, height, x, y, fb_layer + ) + + def clear_background_frame_stream(self, position: int = 0x03) -> None: + """ + Clear background frame on the specified framebuffer layer. + + Args: + position: Layer index (default 0x03) + """ + if not self._handle: + return + _transport_lib.transport_clear_background_frame_stream(self._handle, position) + + # ========== LED Control ========== + + def set_led_brightness(self, brightness: int) -> None: + """ + Set LED brightness. + + Args: + brightness: Brightness value, typically 0-100 + """ + if not self._handle: + return + _transport_lib.transport_set_led_brightness(self._handle, brightness) + + def set_led_color(self, count: int, r: int, g: int, b: int) -> None: + """ + Set color for the first N LEDs. + + Args: + count: Number of LEDs to set + r: Red component (0-255) + g: Green component (0-255) + b: Blue component (0-255) + """ + if not self._handle: + return + res = _transport_lib.transport_set_led_color(self._handle, count, r, g, b) + + def reset_led_color(self) -> None | int: + """Reset LED colors to default.""" + if not self._handle: + return + res = _transport_lib.transport_reset_led_color(self._handle) + return res + + # ========== Keyboard Control ========== + + def set_keyboard_backlight_brightness(self, brightness: int) -> None: + """ + Set the keyboard backlight brightness. + + Args: + brightness: Brightness value (0-6) + """ + if not self._handle: + return + _transport_lib.transport_set_keyboard_backlight_brightness( + self._handle, brightness + ) + + def set_keyboard_lighting_effects(self, effect: int) -> None: + """ + Set the keyboard lighting effect. + 0 is static lighting. + Args: + effect: Effect mode identifier (0-9) + """ + if not self._handle: + return + _transport_lib.transport_set_keyboard_lighting_effects(self._handle, effect) + + def set_keyboard_lighting_speed(self, speed: int) -> None: + """ + Set the keyboard lighting effect speed. + + Args: + speed: Speed value for lighting effects (0-7) + """ + if not self._handle: + return + _transport_lib.transport_set_keyboard_lighting_speed(self._handle, speed) + + def set_keyboard_rgb_backlight(self, red: int, green: int, blue: int) -> None: + """ + Set the keyboard RGB backlight color. + + Args: + red: Red component (0-255) + green: Green component (0-255) + blue: Blue component (0-255) + """ + if not self._handle: + return + _transport_lib.transport_set_keyboard_rgb_backlight( + self._handle, red, green, blue + ) + + def keyboard_os_mode_switch(self, os_mode: int) -> None: + """ + Switch the keyboard OS mode. + + Args: + os_mode: OS mode enum value (e.g., 0 for Windows, 1 for macOS) + """ + if not self._handle: + return + _transport_lib.transport_keyboard_os_mode_switch(self._handle, os_mode) + + # ========== Device Configuration ========== + + def set_device_config(self, configs: List[int]) -> None: + """ + Send raw configuration data to the device. + + Args: + configs: List of configuration byte values + """ + if not self._handle: + return + config_array = (c_uint8 * len(configs))(*configs) + _transport_lib.transport_set_device_config( + self._handle, config_array, len(configs) + ) + + def change_mode(self, mode: int) -> None: + """ + Change device working mode. + + Args: + mode: Mode identifier + """ + if not self._handle: + return + _transport_lib.transport_change_mode(self._handle, mode) + + def change_page(self, page: int) -> None: + """ + Change N1 device calculator mode working page. + + Args: + page: Page identifier + """ + if not self._handle: + return + _transport_lib.transport_change_page(self._handle, page) + + def set_n1_skin_bitmap( + self, + jpeg_data: bytes, + skin_mode: int, + skin_page: int, + skin_status: int, + key_index: int, + timeout_ms: int = 3000, + ) -> None: + """ + Set N1 skin bitmap for a specific mode, page, and key. + Args: + jpeg_data: JPEG image data for the skin + skin_mode: Skin mode identifier, 0 for keyboard, 1 for keyboard lock, 2 for calculator + skin_page: Skin page identifier, 1-5 + skin_status: Skin status identifier, 0 for press, 1 for release + key_index: Target key index for the skin, calculator (1-15), keyboard (1-18) + timeout_ms: Transmission timeout in milliseconds + """ + if not self._handle: + return + _transport_lib.transport_set_n1_skin_bitmap( + self._handle, + jpeg_data, + len(jpeg_data), + skin_mode, + skin_page, + skin_status, + key_index, + timeout_ms, + ) + + def notify_disconnected(self) -> None: + """Notify the device of disconnection.""" + if not self._handle: + return + _transport_lib.transport_disconnected(self._handle) + + def heartbeat(self) -> None: + """Send a heartbeat packet to the device.""" + if not self._handle: + return + _transport_lib.transport_heartbeat(self._handle) + + # ========== Report Configuration ========== + + def set_report_id(self, report_id: int) -> None: + """ + Set the report ID used for communication. + + Args: + report_id: Report ID value (default is typically 0x01) + """ + if not self._handle: + return + _transport_lib.transport_set_reportID(self._handle, report_id) + + def get_report_id(self) -> int: + """ + Get the current report ID. + + Returns: + int: Current report ID value + """ + if not self._handle: + return 0x00 + out_id = c_uint8() + result = _transport_lib.transport_reportID(self._handle, ctypes.byref(out_id)) + if result != 0: + return 0x00 + return int(out_id.value) + + def set_report_size( + self, input_report_size: int, output_report_size: int, feature_report_size: int + ) -> None: + """ + Set the sizes of the input, output, and feature reports. + + Args: + input_report_size: Input report length + output_report_size: Output report length + feature_report_size: Feature report length + """ + if not self._handle: + return + self._input_report_size = input_report_size + self._output_report_size = output_report_size + self._feature_report_size = feature_report_size + _transport_lib.transport_set_reportSize( + self._handle, input_report_size, output_report_size, feature_report_size + ) + + # ========== Error Handling ========== + + def get_last_error(self) -> str: + """ + Get the last raw HID error message. + + Returns: + str: Error message string + """ + if not self._handle: + return "" + + buffer_size = 256 + buffer = ctypes.create_unicode_buffer(buffer_size) + length = c_size_t(buffer_size) + result = _transport_lib.transport_raw_hid_last_error( + self._handle, ctypes.cast(buffer, ctypes.c_void_p), ctypes.byref(length) + ) + if result != 0: + return "" + return buffer.value + + def get_last_error_info(self) -> dict: + """ + Get detailed error information from the transport library. + + Returns: + dict: Error information containing error_code, error_message, function_name, timestamp, and line_number + """ + if not self._handle: + return {} + + # Define TransportErrorInfo structure + class TransportErrorInfo(ctypes.Structure): + _fields_ = [ + ("error_code", c_uint32), + ("error_message", c_char * 256), + ("function_name", c_char * 64), + ("timestamp", c_uint32), + ("line_number", c_uint32), + ] + + error_info = TransportErrorInfo() + result = _transport_lib.transport_get_last_error_info( + self._handle, ctypes.byref(error_info) + ) + + if result == 0: # TRANSPORT_SUCCESS is 0 + return { + "error_code": error_info.error_code, + "error_message": error_info.error_message.decode( + "utf-8", errors="ignore" + ), + "function_name": error_info.function_name.decode( + "utf-8", errors="ignore" + ), + "timestamp": error_info.timestamp, + "line_number": error_info.line_number, + } + return {} + + # ========== Static Methods ========== + + @staticmethod + def disable_output(disable: bool = True) -> None: + """ + Globally disable lower-level output (e.g., debug logs). + + Args: + disable: Whether to disable output + """ + _transport_lib.transport_disable_output(1 if disable else 0) + + @staticmethod + def create_device_info_from_dict(device_dict: dict) -> _HidDeviceInfo: + """ + Create a _HidDeviceInfo structure from a device dictionary. + + Args: + device_dict: Device information dictionary + + Returns: + _HidDeviceInfo structure + """ + device_info = _HidDeviceInfo() + path = device_dict.get("path", "") + device_info.path = path.encode("utf-8") if isinstance(path, str) else path + device_info.vendor_id = device_dict.get("vendor_id", 0) + device_info.product_id = device_dict.get("product_id", 0) + device_info.serial_number = device_dict.get("serial_number", "") + device_info.release_number = device_dict.get("release_number", 0) + device_info.manufacturer_string = device_dict.get("manufacturer_string", "") + device_info.product_string = device_dict.get("product_string", "") + device_info.usage_page = device_dict.get("usage_page", 0) + device_info.usage = device_dict.get("usage", 0) + device_info.interface_number = device_dict.get("interface_number", 0) + device_info.next = None + return device_info + + @staticmethod + def enumerate_devices(vendor_id: int, product_id: int) -> List[dict]: + """ + Enumerate HID devices matching the given vendor and product IDs. + + Use the C library's built-in hidapi for enumeration to avoid conflicts with Python's hidapi package. + + Args: + vendor_id: USB vendor ID + product_id: USB product ID + + Returns: + List of device information dictionaries + """ + device_list = [] + # Use C library's hidapi to avoid conflicts + dev_info_ptr = _transport_lib.transport_hid_enumerate(vendor_id, product_id) + + if not dev_info_ptr: + return device_list + + try: + current = dev_info_ptr + while current: + info = current.contents + if info.usage_page > 1025 and info.usage == 1: + device_list.append( + { + "path": info.path.decode("utf-8") if info.path else "", + "vendor_id": info.vendor_id, + "product_id": info.product_id, + "serial_number": ( + info.serial_number if info.serial_number else "" + ), + "manufacturer_string": ( + info.manufacturer_string + if info.manufacturer_string + else "" + ), + "product_string": ( + info.product_string if info.product_string else "" + ), + "release_number": info.release_number, + "usage_page": info.usage_page, + "usage": info.usage, + "interface_number": info.interface_number, + } + ) + current = info.next + finally: + # Free the enumeration list + _transport_lib.transport_hid_free_enumeration(dev_info_ptr) + + return device_list + + # ========== Properties ========== + + @property + def input_report_size(self) -> int: + """Get the input report size.""" + return self._input_report_size + + @property + def output_report_size(self) -> int: + """Get the output report size.""" + return self._output_report_size + + @property + def feature_report_size(self) -> int: + """Get the feature report size.""" + return self._feature_report_size + + # ========== Legacy Method Aliases (for backward compatibility) ========== + + def getFirmwareVersion(self) -> str: + """Legacy alias for get_firmware_version().""" + return self.get_firmware_version() + + def clearTaskQueue(self) -> None: + """Legacy alias for clear_task_queue().""" + self.clear_task_queue() + + def wakeScreen(self) -> None: + """Legacy alias for wakeup_screen().""" + self.wakeup_screen() + + def keyClear(self, index: int) -> None: + """Legacy alias for clear_key().""" + self.clear_key(index) + + def keyAllClear(self) -> None: + """Legacy alias for clear_all_keys().""" + self.clear_all_keys() + + def changePage(self, page: int) -> None: + """Legacy alias for change_page().""" + self.change_page(page) + + def switchMode(self, mode: int) -> None: + """Legacy alias for change_mode().""" + self.change_mode(mode) + + def setN1SkinBitMap( + self, + path, + skin_mode: int, + skin_page: int, + skin_status: int, + key_index: int, + ) -> None: + """ + Legacy method to set N1 skin bitmap from an image file path. + Args: + path: Path to the image file (can be str, bytes, c_char_p, or os.PathLike) + skin_mode: Skin mode identifier, 0 for keyboard, 1 for keyboard lock, 2 for calculator + skin_page: Skin page identifier, 1-5 + skin_status: Skin status identifier, 0 for press, 1 for release + key_index: Target key index for the skin, calculator (1-15), keyboard (1-18) + """ + try: + # Convert c_char_p to string if needed + if isinstance(path, c_char_p): + path = ( + path.value.decode("utf-8") + if isinstance(path.value, bytes) + else path.value + ) + elif isinstance(path, bytes): + path = path.decode("utf-8") + + if path is None: + raise ValueError("Path cannot be None") + + with open(path, "rb") as f: + jpeg_data = f.read() + self.set_n1_skin_bitmap( + jpeg_data, skin_mode, skin_page, skin_status, key_index + ) + except Exception as e: + raise RuntimeError(f"Failed to load image from {path}: {e}") + + def open(self, device_path: bytes) -> bool: + """ + Open a device connection using the device path. + + Args: + device_path: Device path as bytes + """ + if self._is_open or self._handle is not None: + # Already opened + print("[WARNING] Device already open", flush=True) + return False + + # Create device info structure from path + device_info = _HidDeviceInfo() + device_info.path = device_path + + if self._device_info: + # Use stored device info for other fields + device_info.vendor_id = self._device_info.vendor_id + device_info.product_id = self._device_info.product_id + device_info.serial_number = self._device_info.serial_number + device_info.release_number = self._device_info.release_number + device_info.manufacturer_string = self._device_info.manufacturer_string + device_info.product_string = self._device_info.product_string + device_info.usage_page = self._device_info.usage_page + device_info.usage = self._device_info.usage + device_info.interface_number = self._device_info.interface_number + + # Create the transport handle + handle_ptr = c_void_p() + result = _transport_lib.transport_create( + ctypes.byref(device_info), ctypes.byref(handle_ptr) + ) + if result != 0: # TRANSPORT_SUCCESS is 0 + print(f"[ERROR] Failed to create transport handle: {result}", flush=True) + return False + self._handle = handle_ptr.value + self._is_open = True + return True + + def close(self) -> None: + """ + Close the device connection and release resources. + + This method should be called explicitly before object destruction to ensure + clean shutdown of the C library resources. + """ + # CRITICAL: Ensure clean shutdown even if called multiple times + if not self._is_open and not self._handle: + return + + if self._handle: + try: + # Attempt clean shutdown via C library + _transport_lib.transport_destroy(self._handle) + except Exception as e: + # Log but don't raise - close() should be idempotent and safe + print(f"[WARNING] Failed to destroy transport: {e}", flush=True) + finally: + self._handle = None + self._is_open = False + + def read_(self, size: int) -> Optional[bytes]: + """ + Read data from the device with specified size. + + Args: + size: Number of bytes to read + + Returns: + bytes: Data read from device, or None if error occurred + """ + if not self._handle: + return None + + try: + # CRITICAL: Allocate buffer and prepare for C call + buffer = (c_uint8 * size)() + length = c_size_t(size) + + # Store handle locally to avoid attribute access during C call + handle = self._handle + + # CRITICAL FOR LINUX: Release GIL before blocking C call + # This prevents deadlocks when C library blocks on I/O + import threading + + gil_state = None + try: + # Call C function - ctypes should handle GIL automatically + # but we ensure thread safety by using local variables + result = _transport_lib.transport_read( + handle, + buffer, + ctypes.byref(length), + 100, # Use a 100ms timeout for polling to avoid long blocking + ) + finally: + # GIL is automatically reacquired by ctypes + pass + + # Check result: 0 means success, non-zero means error + if result == 0 and length.value > 0: + # CRITICAL: Use simple bytes() constructor for safer conversion + # ctypes.string_at can cause issues in multi-threaded environments on Linux + data_length = int(length.value) + # Create bytes directly from buffer slice + data_bytes = bytes(buffer[:data_length]) + return data_bytes + else: + # Timeout or no data is normal (when the device has no events); return None + return None + except Exception as e: + # Catch all possible exceptions to avoid thread crashes + import traceback + + print(f"read_ exception: {e}", flush=True) + traceback.print_exc() + return None + + # ========== Legacy Image Methods (DualDevice support) ========== + + def setBackgroundImg(self, buffer: bytes, size: int) -> None: + """ + Legacy method: Set background image from buffer. + + Args: + buffer: Image data buffer + size: Size of the buffer + """ + self.set_background_bitmap(buffer[:size]) + + def setBackgroundImgDualDevice(self, path) -> None: + """ + Legacy method: Set background image from file path (for dual device). + + Args: + path: Path to the image file (can be str, bytes, c_char_p, or os.PathLike) + """ + try: + # Convert c_char_p to string if needed + if isinstance(path, c_char_p): + path = ( + path.value.decode("utf-8") + if isinstance(path.value, bytes) + else path.value + ) + elif isinstance(path, bytes): + path = path.decode("utf-8") + + if path is None: + raise ValueError("Path cannot be None") + + with open(path, "rb") as f: + jpeg_data = f.read() + self.set_background_image_stream(jpeg_data) + except Exception as e: + raise RuntimeError(f"Failed to load image from {path}: {e}") + + def setBackgroundImgFrame(self, path, img_width, img_height) -> None: + """ + Legacy method: Set Temporary background image from file path (for dual device). + + Args: + path: Path to the image file (can be str, bytes, c_char_p, or os.PathLike) + img_width: Width of the image + img_height: Height of the image + """ + try: + # Convert c_char_p to string if needed + if isinstance(path, c_char_p): + path = ( + path.value.decode("utf-8") + if isinstance(path.value, bytes) + else path.value + ) + elif isinstance(path, bytes): + path = path.decode("utf-8") + + if path is None: + raise ValueError("Path cannot be None") + + with open(path, "rb") as f: + jpeg_data = f.read() + self.set_background_frame_stream(jpeg_data, img_width, img_height) + except Exception as e: + raise RuntimeError(f"Failed to load image from {path}: {e}") + + def setKeyImg(self, path, key: int) -> None: + """ + Legacy method: Set key image from file path. + + Args: + path: Path to the image file (can be str, bytes, c_char_p, or os.PathLike) + key: Key index + """ + try: + # Convert c_char_p to string if needed + if isinstance(path, c_char_p): + path = ( + path.value.decode("utf-8") + if isinstance(path.value, bytes) + else path.value + ) + elif isinstance(path, bytes): + path = path.decode("utf-8") + + if path is None: + raise ValueError("Path cannot be None") + + with open(path, "rb") as f: + jpeg_data = f.read() + res = self.set_key_image_stream(jpeg_data, key) + return res + except Exception as e: + raise RuntimeError(f"Failed to load image from {path}: {e}") + + def setKeyImgDualDevice(self, path, key: int) -> None: + """ + Legacy method: Set key image from file path (for dual device). + + Args: + path: Path to the image file (can be str, bytes, c_char_p, or os.PathLike) + key: Key index + """ + return self.setKeyImg(path, key) + + def setKeyImgDataDualDevice(self, data: bytes, key: int) -> None: + """ + Legacy method: Set key image from data buffer (for dual device). + + Args: + data: Image data as bytes + key: Key index + """ + self.set_key_image_stream(data, key) + + def setBrightness(self, percent: int) -> None: + """ + Legacy method: Set brightness. + + Args: + percent: Brightness percentage (0-100) + """ + self.set_key_brightness(percent) + + def disconnected(self) -> None: + """Legacy method: Notify device of disconnection.""" + self.notify_disconnected() + + def refresh(self) -> None: + """Legacy method: Refresh the display.""" + self.refresh_screen() diff --git a/StreamDock/Transport/TransportDLL/libtransport.so b/StreamDock/Transport/TransportDLL/libtransport.so new file mode 100644 index 0000000..8246188 Binary files /dev/null and b/StreamDock/Transport/TransportDLL/libtransport.so differ diff --git a/StreamDock/Transport/TransportDLL/libtransport_arm64.dylib b/StreamDock/Transport/TransportDLL/libtransport_arm64.dylib new file mode 100644 index 0000000..ba94c39 Binary files /dev/null and b/StreamDock/Transport/TransportDLL/libtransport_arm64.dylib differ diff --git a/StreamDock/Transport/TransportDLL/libtransport_arm64.so b/StreamDock/Transport/TransportDLL/libtransport_arm64.so new file mode 100644 index 0000000..693a11e Binary files /dev/null and b/StreamDock/Transport/TransportDLL/libtransport_arm64.so differ diff --git a/StreamDock/Transport/__init__.py b/StreamDock/Transport/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/StreamDock/__init__.py b/StreamDock/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..50a1268 --- /dev/null +++ b/config.example.yaml @@ -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" \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..16a1e15 --- /dev/null +++ b/config.py @@ -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) \ No newline at end of file diff --git a/key_renderer.py b/key_renderer.py new file mode 100644 index 0000000..eb158a6 --- /dev/null +++ b/key_renderer.py @@ -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 \ No newline at end of file diff --git a/streamdock_ha.py b/streamdock_ha.py new file mode 100644 index 0000000..1b1f7b8 --- /dev/null +++ b/streamdock_ha.py @@ -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() \ No newline at end of file