feat: initial implementation of StreamDock Home Assistant integration
This commit introduces a complete integration for Mirabox StreamDock devices with Home Assistant, allowing users to control entities and monitor states directly from the hardware. Key features included: - Support for multiple Mirabox models: N1, N3, N4, N4Pro, XL, M3, M18, K1Pro, and various 293 variants. - Home Assistant WebSocket integration for real-time entity updates and service execution. - Dynamic LCD key rendering with support for custom icons, labels, and entity-state aware colors. - Input handling for physical buttons and rotary encoders (knobs). - Multi-page navigation support with configurable cycle keys and knobs. - Cross-platform transport layer using a custom HID library. - Configuration system using YAML with page-based layouts. - Linux udev rules for non-root USB access.
This commit is contained in:
commit
06f4c2768c
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.DS_Store
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
rotated_*.jpg
|
||||||
|
config.yaml
|
||||||
53
99-streamdock.rules
Normal file
53
99-streamdock.rules
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
# StreamDock USB device permission configuration
|
||||||
|
# This file sets appropriate USB permissions for StreamDock devices, allowing ordinary users to access
|
||||||
|
|
||||||
|
# StreamDock 293 series
|
||||||
|
SUBSYSTEM=="usb", ATTR{idVendor}=="5500", ATTR{idProduct}=="1001", MODE="0666", GROUP="plugdev"
|
||||||
|
SUBSYSTEM=="usb", ATTR{idVendor}=="5548", ATTR{idProduct}=="6670", MODE="0666", GROUP="plugdev"
|
||||||
|
SUBSYSTEM=="usb", ATTR{idVendor}=="6603", ATTR{idProduct}=="1005", MODE="0666", GROUP="plugdev"
|
||||||
|
SUBSYSTEM=="usb", ATTR{idVendor}=="6603", ATTR{idProduct}=="1006", MODE="0666", GROUP="plugdev"
|
||||||
|
SUBSYSTEM=="usb", ATTR{idVendor}=="6603", ATTR{idProduct}=="1010", MODE="0666", GROUP="plugdev"
|
||||||
|
SUBSYSTEM=="usb", ATTR{idVendor}=="6603", ATTR{idProduct}=="1014", MODE="0666", GROUP="plugdev"
|
||||||
|
|
||||||
|
# StreamDock N3 series
|
||||||
|
SUBSYSTEM=="usb", ATTR{idVendor}=="6603", ATTR{idProduct}=="1002", MODE="0666", GROUP="plugdev"
|
||||||
|
SUBSYSTEM=="usb", ATTR{idVendor}=="6603", ATTR{idProduct}=="1003", MODE="0666", GROUP="plugdev"
|
||||||
|
SUBSYSTEM=="usb", ATTR{idVendor}=="6602", ATTR{idProduct}=="1000", MODE="0666", GROUP="plugdev"
|
||||||
|
SUBSYSTEM=="usb", ATTR{idVendor}=="6602", ATTR{idProduct}=="1002", MODE="0666", GROUP="plugdev"
|
||||||
|
SUBSYSTEM=="usb", ATTR{idVendor}=="6602", ATTR{idProduct}=="1003", MODE="0666", GROUP="plugdev"
|
||||||
|
SUBSYSTEM=="usb", ATTR{idVendor}=="6602", ATTR{idProduct}=="2929", MODE="0666", GROUP="plugdev"
|
||||||
|
SUBSYSTEM=="usb", ATTR{idVendor}=="1500", ATTR{idProduct}=="3001", MODE="0666", GROUP="plugdev"
|
||||||
|
|
||||||
|
# StreamDock N4 series
|
||||||
|
SUBSYSTEM=="usb", ATTR{idVendor}=="6602", ATTR{idProduct}=="1001", MODE="0666", GROUP="plugdev"
|
||||||
|
SUBSYSTEM=="usb", ATTR{idVendor}=="6603", ATTR{idProduct}=="1007", MODE="0666", GROUP="plugdev"
|
||||||
|
|
||||||
|
# StreamDock N1 series
|
||||||
|
SUBSYSTEM=="usb", ATTR{idVendor}=="6603", ATTR{idProduct}=="1011", MODE="0666", GROUP="plugdev"
|
||||||
|
SUBSYSTEM=="usb", ATTR{idVendor}=="6603", ATTR{idProduct}=="1000", MODE="0666", GROUP="plugdev"
|
||||||
|
|
||||||
|
# StreamDock N4Pro series
|
||||||
|
SUBSYSTEM=="usb", ATTR{idVendor}=="5548", ATTR{idProduct}=="1008", MODE="0666", GROUP="plugdev"
|
||||||
|
SUBSYSTEM=="usb", ATTR{idVendor}=="5548", ATTR{idProduct}=="1021", MODE="0666", GROUP="plugdev"
|
||||||
|
|
||||||
|
# StreamDock XL series
|
||||||
|
SUBSYSTEM=="usb", ATTR{idVendor}=="5548", ATTR{idProduct}=="1028", MODE="0666", GROUP="plugdev"
|
||||||
|
SUBSYSTEM=="usb", ATTR{idVendor}=="5548", ATTR{idProduct}=="1031", MODE="0666", GROUP="plugdev"
|
||||||
|
|
||||||
|
# StreamDock M18 series
|
||||||
|
SUBSYSTEM=="usb", ATTR{idVendor}=="6603", ATTR{idProduct}=="1009", MODE="0666", GROUP="plugdev"
|
||||||
|
SUBSYSTEM=="usb", ATTR{idVendor}=="6603", ATTR{idProduct}=="1012", MODE="0666", GROUP="plugdev"
|
||||||
|
|
||||||
|
# StreamDock M3 series
|
||||||
|
SUBSYSTEM=="usb", ATTR{idVendor}=="5548", ATTR{idProduct}=="1020", MODE="0666", GROUP="plugdev"
|
||||||
|
|
||||||
|
# StreamDock K1Pro series
|
||||||
|
SUBSYSTEM=="usb", ATTR{idVendor}=="6603", ATTR{idProduct}=="1015", MODE="0666", GROUP="plugdev"
|
||||||
|
SUBSYSTEM=="usb", ATTR{idVendor}=="6603", ATTR{idProduct}=="1019", MODE="0666", GROUP="plugdev"
|
||||||
|
|
||||||
|
# HID device permission configuration (for HID interface access)
|
||||||
|
KERNEL=="hidraw*", ATTRS{idVendor}=="5500", MODE="0666", GROUP="plugdev"
|
||||||
|
KERNEL=="hidraw*", ATTRS{idVendor}=="5548", MODE="0666", GROUP="plugdev"
|
||||||
|
KERNEL=="hidraw*", ATTRS{idVendor}=="6603", MODE="0666", GROUP="plugdev"
|
||||||
|
KERNEL=="hidraw*", ATTRS{idVendor}=="6602", MODE="0666", GROUP="plugdev"
|
||||||
|
KERNEL=="hidraw*", ATTRS{idVendor}=="1500", MODE="0666", GROUP="plugdev"
|
||||||
214
README.md
Normal file
214
README.md
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
# StreamDock Home Assistant Integration
|
||||||
|
|
||||||
|
Displays Home Assistant entity states on Mirabox StreamDock LCD keys and triggers HA services on button/knob presses.
|
||||||
|
|
||||||
|
Tested on **StreamDock N3** (6 LCD keys + 3 knobs). Should work with other Mirabox StreamDock models (N1, N4, N4Pro, M3, XL, 293 variants, K1Pro).
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Python 3.11+
|
||||||
|
- A Mirabox StreamDock device
|
||||||
|
- Home Assistant with a long-lived access token
|
||||||
|
- Linux (x86_64 or aarch64), macOS, or Windows
|
||||||
|
|
||||||
|
### System packages (Debian/Raspberry Pi OS)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install python3-venv fonts-dejavu-core
|
||||||
|
```
|
||||||
|
|
||||||
|
For USB device permissions (avoid running as root):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp 99-streamdock.rules /etc/udev/rules.d/
|
||||||
|
sudo udevadm control --reload-rules
|
||||||
|
sudo udevadm trigger
|
||||||
|
# Then unplug and re-plug the StreamDock
|
||||||
|
```
|
||||||
|
|
||||||
|
### Python packages
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install Pillow pyyaml HomeAssistant-API pyudev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Create your config from the example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp config.example.yaml config.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Edit `config.yaml` with your HA connection details:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
homeassistant:
|
||||||
|
url: "ws://YOUR_HA_IP:8123/api/websocket"
|
||||||
|
token: "YOUR_LONG_LIVED_ACCESS_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
Get a token from Home Assistant: **Settings → People → Your User → Security → Long-Lived Access Tokens**
|
||||||
|
|
||||||
|
3. Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source venv/bin/activate
|
||||||
|
python3 streamdock_ha.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration Reference
|
||||||
|
|
||||||
|
Full `config.yaml` structure:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
homeassistant:
|
||||||
|
url: "ws://homeassistant.local:8123/api/websocket"
|
||||||
|
token: "YOUR_TOKEN"
|
||||||
|
|
||||||
|
navigation:
|
||||||
|
page_cycle: "button_8" # which button cycles pages (middle of 3 bottom buttons)
|
||||||
|
knob_left: "knob_1" # knob whose left-rotation goes to previous page
|
||||||
|
knob_right: "knob_2" # knob whose right-rotation goes to next page
|
||||||
|
knob_press_left: "knob_1" # knob whose press goes to previous page
|
||||||
|
knob_press_right: "knob_2"# knob whose press goes to next page
|
||||||
|
|
||||||
|
pages:
|
||||||
|
- name: "Lights"
|
||||||
|
keys:
|
||||||
|
1: # LCD key number (1-6 on N3)
|
||||||
|
entity: "light.living_room" # HA entity ID
|
||||||
|
service: "toggle" # HA service to call on press
|
||||||
|
icon: "lightbulb" # icon name (see below)
|
||||||
|
label: "Living" # short label shown on key
|
||||||
|
2:
|
||||||
|
entity: "switch.kitchen"
|
||||||
|
service: "toggle"
|
||||||
|
icon: "power"
|
||||||
|
label: "Kitchen"
|
||||||
|
3: # sensor with no service = display-only
|
||||||
|
entity: "sensor.temperature"
|
||||||
|
icon: "thermometer"
|
||||||
|
label: "Temp"
|
||||||
|
knobs: # per-page knob bindings
|
||||||
|
knob_3:
|
||||||
|
rotate_left:
|
||||||
|
entity: "script.volume_down"
|
||||||
|
service: "turn_on"
|
||||||
|
rotate_right:
|
||||||
|
entity: "script.volume_up"
|
||||||
|
service: "turn_on"
|
||||||
|
press:
|
||||||
|
entity: "script.mute"
|
||||||
|
service: "turn_on"
|
||||||
|
|
||||||
|
- name: "Climate"
|
||||||
|
keys:
|
||||||
|
1:
|
||||||
|
entity: "climate.bedroom"
|
||||||
|
service: "toggle"
|
||||||
|
icon: "thermostat"
|
||||||
|
label: "Bedroom"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Numbers
|
||||||
|
|
||||||
|
| Keys | N3 | N1 | N4/N4Pro | XL |
|
||||||
|
|------|----|----|-----------|-----|
|
||||||
|
| LCD keys | 1-6 | 1-15 | 6-15 | 1-32 |
|
||||||
|
|
||||||
|
Bottom buttons (no LCD): keys 7, 8, 9 (N3). Key 8 is the middle one.
|
||||||
|
|
||||||
|
### Knob Names
|
||||||
|
|
||||||
|
| Knob | N3 position |
|
||||||
|
|------|------------|
|
||||||
|
| `knob_1` | Bottom-left |
|
||||||
|
| `knob_2` | Bottom-right |
|
||||||
|
| `knob_3` | Top |
|
||||||
|
|
||||||
|
### Navigation Button/ Knob References
|
||||||
|
|
||||||
|
Buttons can be referenced as `button_7`, `button_8`, `button_9`, `key_7`, etc., or just the number `7`, `8`, `9`.
|
||||||
|
|
||||||
|
Knobs: `knob_1`, `knob_2`, `knob_3`, `knob_4`, or aliases `left`, `right`, `top`, `bottom_left`, `bottom_right`.
|
||||||
|
|
||||||
|
### Available Icons
|
||||||
|
|
||||||
|
`lightbulb`, `power`, `thermometer`, `thermostat`, `play`, `film`, `door`, `lock`, `speaker`, `fan`, or `default`
|
||||||
|
|
||||||
|
Icons render with distinct "on" and "off" colors. For example, `lightbulb` shows yellow when on, gray when off. `door` shows green when open, red when closed.
|
||||||
|
|
||||||
|
### Entity State Detection
|
||||||
|
|
||||||
|
The app automatically detects on/off states from HA entity states:
|
||||||
|
|
||||||
|
- **On**: `on`, `playing`, `open`, `unlocked`, `home`, `heat`, `cool`, `auto`
|
||||||
|
- **Off**: everything else
|
||||||
|
|
||||||
|
Sensor values (temperature, humidity, etc.) are displayed as-is on the key.
|
||||||
|
|
||||||
|
## Running on Raspberry Pi
|
||||||
|
|
||||||
|
The included `libtransport_arm64.so` supports aarch64 Linux (Pi 3, 4, 5, Zero 2 W). The library loader auto-detects the architecture.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On Raspberry Pi OS
|
||||||
|
sudo apt install python3-venv fonts-dejavu-core
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install Pillow pyyaml HomeAssistant-API pyudev
|
||||||
|
|
||||||
|
# USB permissions
|
||||||
|
sudo cp 99-streamdock.rules /etc/udev/rules.d/
|
||||||
|
sudo udevadm control --reload-rules && sudo udevadm trigger
|
||||||
|
|
||||||
|
# Configure and run
|
||||||
|
cp config.example.yaml config.yaml
|
||||||
|
nano config.yaml
|
||||||
|
python3 streamdock_ha.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Autostart on Boot (systemd)
|
||||||
|
|
||||||
|
Create `/etc/systemd/system/streamdock-ha.service`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=StreamDock Home Assistant
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=karl
|
||||||
|
WorkingDirectory=/home/karl/streamdocklinux
|
||||||
|
ExecStart=/home/karl/streamdocklinux/venv/bin/python3 streamdock_ha.py
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=10
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl enable streamdock-ha
|
||||||
|
sudo systemctl start streamdock-ha
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
streamdock_ha.py - Main app: HA WebSocket + StreamDock event loop
|
||||||
|
key_renderer.py - Dynamic key image generation (icons + state text)
|
||||||
|
config.py - YAML config loader
|
||||||
|
config.yaml - User configuration (gitignored)
|
||||||
|
config.example.yaml - Example configuration
|
||||||
|
99-streamdock.rules - udev rules for USB permissions
|
||||||
|
StreamDock/ - Mirabox StreamDock Device SDK (Python + C transport lib)
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project includes the Mirabox StreamDock Device SDK. See `StreamDock/` for its license terms.
|
||||||
249
StreamDock/DeviceManager.py
Normal file
249
StreamDock/DeviceManager.py
Normal file
@ -0,0 +1,249 @@
|
|||||||
|
import platform
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
# import pywinusb.hid as hid
|
||||||
|
from .ProductIDs import USBVendorIDs, USBProductIDs, g_products
|
||||||
|
from .Transport.LibUSBHIDAPI import LibUSBHIDAPI
|
||||||
|
|
||||||
|
# Platform-specific imports
|
||||||
|
if platform.system() == "Linux":
|
||||||
|
import pyudev
|
||||||
|
elif platform.system() == "Windows":
|
||||||
|
try:
|
||||||
|
import wmi
|
||||||
|
import pythoncom
|
||||||
|
WINDOWS_SUPPORT = True
|
||||||
|
except ImportError:
|
||||||
|
print("Warning: wmi module not installed, using polling mode")
|
||||||
|
WINDOWS_SUPPORT = False
|
||||||
|
elif platform.system() == "Darwin":
|
||||||
|
# macOS specific imports can be added here if needed
|
||||||
|
pass
|
||||||
|
|
||||||
|
class DeviceManager:
|
||||||
|
streamdocks = list()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_transport(transport):
|
||||||
|
return LibUSBHIDAPI()
|
||||||
|
|
||||||
|
def __init__(self, transport=None):
|
||||||
|
self.transport = self._get_transport(transport)
|
||||||
|
|
||||||
|
def enumerate(self)->list:
|
||||||
|
# CRITICAL: Clear old list to avoid stale references
|
||||||
|
self.streamdocks.clear()
|
||||||
|
|
||||||
|
products = g_products
|
||||||
|
for vid, pid, class_type in products:
|
||||||
|
found_devices = self.transport.enumerate_devices(vendor_id = vid, product_id = pid)
|
||||||
|
# Create a dedicated LibUSBHIDAPI instance per device
|
||||||
|
# CRITICAL: Pass device info to transport for proper resource management
|
||||||
|
for d in found_devices:
|
||||||
|
# Create device_info structure from dict
|
||||||
|
device_info = LibUSBHIDAPI.create_device_info_from_dict(d)
|
||||||
|
device_transport = LibUSBHIDAPI(device_info)
|
||||||
|
self.streamdocks.append(class_type(device_transport, d))
|
||||||
|
return self.streamdocks
|
||||||
|
|
||||||
|
def listen(self):
|
||||||
|
"""
|
||||||
|
Listen for device hotplug events, cross-platform
|
||||||
|
"""
|
||||||
|
products = g_products
|
||||||
|
system = platform.system()
|
||||||
|
|
||||||
|
if system == "Linux":
|
||||||
|
self._listen_linux(products)
|
||||||
|
elif system == "Windows":
|
||||||
|
self._listen_windows(products)
|
||||||
|
elif system == "Darwin":
|
||||||
|
self._listen_macos(products)
|
||||||
|
else:
|
||||||
|
print(f"Unsupported operating system: {system}")
|
||||||
|
|
||||||
|
def _listen_linux(self, products):
|
||||||
|
"""Linux uses pyudev to listen for device events"""
|
||||||
|
context = pyudev.Context()
|
||||||
|
monitor = pyudev.Monitor.from_netlink(context)
|
||||||
|
monitor.filter_by(subsystem='usb')
|
||||||
|
|
||||||
|
for device in iter(monitor.poll, None):
|
||||||
|
self._handle_device_event(device.action, device, products)
|
||||||
|
|
||||||
|
def _listen_windows(self, products):
|
||||||
|
"""Windows uses WMI to listen for device events"""
|
||||||
|
if not WINDOWS_SUPPORT:
|
||||||
|
print("WMI unavailable, using polling mode")
|
||||||
|
self._fallback_polling(products)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
pythoncom.CoInitialize()
|
||||||
|
c = wmi.WMI()
|
||||||
|
|
||||||
|
# Listen for device connection events
|
||||||
|
watcher = c.Win32_DeviceChangeEvent.watch_for(
|
||||||
|
EventType=2 # Device connected
|
||||||
|
)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
event = watcher()
|
||||||
|
if event.EventType == 2: # Device connected
|
||||||
|
self._check_new_devices_windows(products)
|
||||||
|
elif event.EventType == 3: # Device disconnected
|
||||||
|
self._check_removed_devices_windows(products)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Windows device listener error: {e}")
|
||||||
|
time.sleep(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Windows WMI initialization failed: {e}")
|
||||||
|
# Fall back to polling mode
|
||||||
|
self._fallback_polling(products)
|
||||||
|
finally:
|
||||||
|
pythoncom.CoUninitialize()
|
||||||
|
|
||||||
|
def _listen_macos(self, products):
|
||||||
|
"""macOS uses polling to listen for device events"""
|
||||||
|
self._fallback_polling(products)
|
||||||
|
|
||||||
|
def _fallback_polling(self, products):
|
||||||
|
"""Fall back to polling mode for systems without real-time monitoring"""
|
||||||
|
# print("Using polling mode to monitor device changes...")
|
||||||
|
current_devices = set()
|
||||||
|
|
||||||
|
# Initialize current device list
|
||||||
|
for vid, pid, _ in products:
|
||||||
|
devices = self.transport.enumerate_devices(vendor_id=vid, product_id=pid)
|
||||||
|
for device in devices:
|
||||||
|
current_devices.add(device['path'])
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
new_devices = set()
|
||||||
|
for vid, pid, _ in products:
|
||||||
|
devices = self.transport.enumerate_devices(vendor_id=vid, product_id=pid)
|
||||||
|
for device in devices:
|
||||||
|
new_devices.add(device['path'])
|
||||||
|
|
||||||
|
# Check for newly added devices
|
||||||
|
added_devices = new_devices - current_devices
|
||||||
|
for device_path in added_devices:
|
||||||
|
print(f"[add] path: {device_path}")
|
||||||
|
self._handle_device_addition(device_path, products)
|
||||||
|
|
||||||
|
# Check for removed devices
|
||||||
|
removed_devices = current_devices - new_devices
|
||||||
|
for device_path in removed_devices:
|
||||||
|
print(f"[remove] path: {device_path}")
|
||||||
|
self._handle_device_removal(device_path)
|
||||||
|
|
||||||
|
current_devices = new_devices
|
||||||
|
time.sleep(2) # Check every 2 seconds
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Polling listener error: {e}")
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
def _handle_device_event(self, action, device, products):
|
||||||
|
"""Handle device events (Linux)"""
|
||||||
|
if action not in ['add', 'remove']:
|
||||||
|
return
|
||||||
|
|
||||||
|
if action == 'remove':
|
||||||
|
for willRemoveDevice in self.streamdocks:
|
||||||
|
if device.device_path.find(willRemoveDevice.getPath()) != -1:
|
||||||
|
print("[remove] path: " + willRemoveDevice.getPath())
|
||||||
|
self.streamdocks.remove(willRemoveDevice)
|
||||||
|
break
|
||||||
|
|
||||||
|
vendor_id_str = device.get('ID_VENDOR_ID')
|
||||||
|
product_id_str = device.get('ID_MODEL_ID')
|
||||||
|
|
||||||
|
if not vendor_id_str or not product_id_str:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
vendor_id = int(vendor_id_str, 16)
|
||||||
|
product_id = int(product_id_str, 16)
|
||||||
|
except ValueError:
|
||||||
|
return
|
||||||
|
|
||||||
|
for vid, pid, class_type in products:
|
||||||
|
if vendor_id == vid and product_id == pid:
|
||||||
|
if action == 'add':
|
||||||
|
dev_path = device.device_path.split('/')[-1] + ":1.0"
|
||||||
|
full_path = dev_path
|
||||||
|
|
||||||
|
found_devices = self.transport.enumerate_devices(vendor_id=vid, product_id=pid)
|
||||||
|
for d in found_devices:
|
||||||
|
if d['path'].endswith(full_path):
|
||||||
|
print("[add] path:", d['path'])
|
||||||
|
newDevice = class_type(self.transport, d)
|
||||||
|
self.streamdocks.append(newDevice)
|
||||||
|
newDevice.open()
|
||||||
|
# your reconnect logic like the next two line
|
||||||
|
# newDevice.set_key_image(1, "../img/tiga64.png")
|
||||||
|
# newDevice.refresh()
|
||||||
|
break
|
||||||
|
|
||||||
|
def _check_new_devices_windows(self, products):
|
||||||
|
"""Check for new devices on Windows"""
|
||||||
|
for vid, pid, class_type in products:
|
||||||
|
found_devices = self.transport.enumerate_devices(vendor_id=vid, product_id=pid)
|
||||||
|
for device_info in found_devices:
|
||||||
|
device_path = device_info['path']
|
||||||
|
# Check whether the device already exists
|
||||||
|
exists = any(device.getPath() == device_path for device in self.streamdocks)
|
||||||
|
if not exists:
|
||||||
|
print(f"[add] path: {device_path}")
|
||||||
|
newDevice = class_type(self.transport, device_info)
|
||||||
|
self.streamdocks.append(newDevice)
|
||||||
|
newDevice.open()
|
||||||
|
# your reconnect logic like the next two line
|
||||||
|
# newDevice.set_key_image(1, "../img/tiga64.png")
|
||||||
|
# newDevice.refresh()
|
||||||
|
|
||||||
|
def _check_removed_devices_windows(self, products):
|
||||||
|
"""Check for removed devices on Windows"""
|
||||||
|
current_paths = set()
|
||||||
|
for vid, pid, _ in products:
|
||||||
|
found_devices = self.transport.enumerate_devices(vendor_id=vid, product_id=pid)
|
||||||
|
for device_info in found_devices:
|
||||||
|
current_paths.add(device_info['path'])
|
||||||
|
|
||||||
|
# Remove devices that no longer exist
|
||||||
|
devices_to_remove = []
|
||||||
|
for device in self.streamdocks:
|
||||||
|
if device.getPath() not in current_paths:
|
||||||
|
devices_to_remove.append(device)
|
||||||
|
|
||||||
|
for device in devices_to_remove:
|
||||||
|
print(f"[remove] path: {device.getPath()}")
|
||||||
|
self.streamdocks.remove(device)
|
||||||
|
|
||||||
|
def _handle_device_addition(self, device_path, products):
|
||||||
|
"""Handle device addition events (polling mode)"""
|
||||||
|
for vid, pid, class_type in products:
|
||||||
|
found_devices = self.transport.enumerate_devices(vendor_id=vid, product_id=pid)
|
||||||
|
for device_info in found_devices:
|
||||||
|
if device_info['path'] == device_path:
|
||||||
|
newDevice = class_type(self.transport, device_info)
|
||||||
|
self.streamdocks.append(newDevice)
|
||||||
|
newDevice.open()
|
||||||
|
# your reconnect logic like the next two line
|
||||||
|
# newDevice.set_key_image(1, "../img/tiga64.png")
|
||||||
|
# newDevice.refresh()
|
||||||
|
break
|
||||||
|
|
||||||
|
def _handle_device_removal(self, device_path):
|
||||||
|
"""Handle device removal events (polling mode)"""
|
||||||
|
devices_to_remove = []
|
||||||
|
for device in self.streamdocks:
|
||||||
|
if device.getPath() == device_path:
|
||||||
|
devices_to_remove.append(device)
|
||||||
|
|
||||||
|
for device in devices_to_remove:
|
||||||
|
self.streamdocks.remove(device)
|
||||||
|
|
||||||
225
StreamDock/Devices/K1Pro.py
Normal file
225
StreamDock/Devices/K1Pro.py
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
from StreamDock.FeatrueOption import device_type
|
||||||
|
from .StreamDock import StreamDock
|
||||||
|
from ..InputTypes import InputEvent, ButtonKey, EventType, KnobId, Direction
|
||||||
|
from PIL import Image
|
||||||
|
import ctypes
|
||||||
|
import ctypes.util
|
||||||
|
import os, io
|
||||||
|
from ..ImageHelpers.PILHelper import *
|
||||||
|
import random
|
||||||
|
|
||||||
|
|
||||||
|
class K1Pro(StreamDock):
|
||||||
|
"""K1Pro device class - supports 6 keys and 3 knobs"""
|
||||||
|
|
||||||
|
KEY_COUNT = 6
|
||||||
|
KEY_MAP = False
|
||||||
|
|
||||||
|
# Image key mapping: logical key -> hardware key (for setting images)
|
||||||
|
_IMAGE_KEY_MAP = {
|
||||||
|
ButtonKey.KEY_1: 0x05,
|
||||||
|
ButtonKey.KEY_2: 0x03,
|
||||||
|
ButtonKey.KEY_3: 0x01,
|
||||||
|
ButtonKey.KEY_4: 0x06,
|
||||||
|
ButtonKey.KEY_5: 0x04,
|
||||||
|
ButtonKey.KEY_6: 0x02,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Reverse mapping: hardware key -> logical key (for event decoding)
|
||||||
|
_HW_TO_LOGICAL_KEY = {v: k for k, v in _IMAGE_KEY_MAP.items()}
|
||||||
|
|
||||||
|
def __init__(self, transport1, devInfo):
|
||||||
|
super().__init__(transport1, devInfo)
|
||||||
|
|
||||||
|
def get_image_key(self, logical_key: ButtonKey) -> int:
|
||||||
|
"""
|
||||||
|
Convert logical key value to hardware key value (for setting images)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
logical_key: Logical key enum
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: Hardware key value
|
||||||
|
"""
|
||||||
|
if logical_key in self._IMAGE_KEY_MAP:
|
||||||
|
return self._IMAGE_KEY_MAP[logical_key]
|
||||||
|
raise ValueError(f"K1Pro: Unsupported key {logical_key}")
|
||||||
|
|
||||||
|
def decode_input_event(self, hardware_code: int, state: int) -> InputEvent:
|
||||||
|
"""
|
||||||
|
Decode hardware event codes into a unified InputEvent
|
||||||
|
|
||||||
|
Hardware code mapping:
|
||||||
|
- Keys: 0x05, 0x03, 0x01, 0x06, 0x04, 0x02
|
||||||
|
- Knob 1 press: 0x25
|
||||||
|
- Knob 2 press: 0x30
|
||||||
|
- Knob 3 press: 0x31
|
||||||
|
- Knob 1 rotation: 0x50 (left), 0x51 (right)
|
||||||
|
- Knob 2 rotation: 0x60 (left), 0x61 (right)
|
||||||
|
- Knob 3 rotation: 0x90 (left), 0x91 (right)
|
||||||
|
"""
|
||||||
|
# Handle state value: 0x02=release, 0x01=press
|
||||||
|
normalized_state = 1 if state == 0x01 else 0
|
||||||
|
|
||||||
|
# Regular button events
|
||||||
|
if hardware_code in self._HW_TO_LOGICAL_KEY:
|
||||||
|
return InputEvent(
|
||||||
|
event_type=EventType.BUTTON,
|
||||||
|
key=self._HW_TO_LOGICAL_KEY[hardware_code],
|
||||||
|
state=normalized_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Knob press event
|
||||||
|
knob_press_map = {
|
||||||
|
0x25: KnobId.KNOB_1,
|
||||||
|
0x30: KnobId.KNOB_2,
|
||||||
|
0x31: KnobId.KNOB_3,
|
||||||
|
}
|
||||||
|
if hardware_code in knob_press_map:
|
||||||
|
return InputEvent(
|
||||||
|
event_type=EventType.KNOB_PRESS,
|
||||||
|
knob_id=knob_press_map[hardware_code],
|
||||||
|
state=normalized_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Knob rotation event
|
||||||
|
knob_rotate_map = {
|
||||||
|
0x50: (KnobId.KNOB_1, Direction.LEFT),
|
||||||
|
0x51: (KnobId.KNOB_1, Direction.RIGHT),
|
||||||
|
0x60: (KnobId.KNOB_2, Direction.LEFT),
|
||||||
|
0x61: (KnobId.KNOB_2, Direction.RIGHT),
|
||||||
|
0x90: (KnobId.KNOB_3, Direction.LEFT),
|
||||||
|
0x91: (KnobId.KNOB_3, Direction.RIGHT),
|
||||||
|
}
|
||||||
|
if hardware_code in knob_rotate_map:
|
||||||
|
knob_id, direction = knob_rotate_map[hardware_code]
|
||||||
|
return InputEvent(
|
||||||
|
event_type=EventType.KNOB_ROTATE, knob_id=knob_id, direction=direction
|
||||||
|
)
|
||||||
|
|
||||||
|
# Unknown event
|
||||||
|
return InputEvent(event_type=EventType.UNKNOWN)
|
||||||
|
|
||||||
|
# Set device screen brightness
|
||||||
|
def set_brightness(self, percent):
|
||||||
|
return self.transport.setBrightness(percent)
|
||||||
|
|
||||||
|
def set_touchscreen_image(self, path):
|
||||||
|
"""Background setting not supported"""
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Set device key icon image 64 * 64
|
||||||
|
def set_key_image(self, key, path):
|
||||||
|
try:
|
||||||
|
if isinstance(key, int):
|
||||||
|
if key not in range(1, 7):
|
||||||
|
print(f"key '{key}' out of range. you should set (1 ~ 6)")
|
||||||
|
return -1
|
||||||
|
logical_key = ButtonKey(key)
|
||||||
|
else:
|
||||||
|
logical_key = key
|
||||||
|
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print(f"Error: The image file '{path}' does not exist.")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
# Get hardware key value
|
||||||
|
hardware_key = self.get_image_key(logical_key)
|
||||||
|
|
||||||
|
# open formatter
|
||||||
|
image = Image.open(path)
|
||||||
|
image = to_native_key_format(self, image)
|
||||||
|
temp_image_path = (
|
||||||
|
"rotated_key_image_" + str(random.randint(9999, 999999)) + ".jpg"
|
||||||
|
)
|
||||||
|
image.save(temp_image_path)
|
||||||
|
|
||||||
|
# encode send
|
||||||
|
path_bytes = temp_image_path.encode("utf-8")
|
||||||
|
c_path = ctypes.c_char_p(path_bytes)
|
||||||
|
res = self.transport.setKeyImgDualDevice(c_path, hardware_key)
|
||||||
|
os.remove(temp_image_path)
|
||||||
|
return res
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
# TODO
|
||||||
|
def set_key_imageData(self, key, path):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Get device serial number
|
||||||
|
def get_serial_number(self):
|
||||||
|
return self.serial_number
|
||||||
|
|
||||||
|
def key_image_format(self):
|
||||||
|
return {
|
||||||
|
"size": (64, 64),
|
||||||
|
"format": "JPEG",
|
||||||
|
"rotation": -90,
|
||||||
|
"flip": (False, False),
|
||||||
|
}
|
||||||
|
|
||||||
|
def touchscreen_image_format(self):
|
||||||
|
return {
|
||||||
|
"size": (800, 480),
|
||||||
|
"format": "JPEG",
|
||||||
|
"rotation": 180,
|
||||||
|
"flip": (False, False),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Set device parameters
|
||||||
|
def set_device(self):
|
||||||
|
self.transport.set_report_size(513, 1025, 0)
|
||||||
|
self.transport.set_report_id(0x04)
|
||||||
|
self.feature_option.deviceType = device_type.k1pro
|
||||||
|
pass
|
||||||
|
|
||||||
|
def set_keyboard_backlight_brightness(self, brightness):
|
||||||
|
"""
|
||||||
|
Set the keyboard backlight brightness.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
brightness: Brightness value (0-6)
|
||||||
|
"""
|
||||||
|
self.transport.set_keyboard_backlight_brightness(brightness)
|
||||||
|
|
||||||
|
def set_keyboard_lighting_effects(self, effect: int):
|
||||||
|
"""
|
||||||
|
Set the keyboard lighting effect.
|
||||||
|
0 is static lighting.
|
||||||
|
Args:
|
||||||
|
effect: Effect mode identifier (0-9)
|
||||||
|
"""
|
||||||
|
if(effect==0):
|
||||||
|
self.set_keyboard_lighting_speed(0)
|
||||||
|
self.transport.set_keyboard_lighting_effects(effect)
|
||||||
|
|
||||||
|
def set_keyboard_lighting_speed(self, speed: int):
|
||||||
|
"""
|
||||||
|
Set the keyboard lighting effect speed.
|
||||||
|
Args:
|
||||||
|
speed: Speed value for lighting effects (0-7)
|
||||||
|
"""
|
||||||
|
self.transport.set_keyboard_lighting_speed(speed)
|
||||||
|
|
||||||
|
def set_keyboard_rgb_backlight(self, red: int, green: int, blue: int):
|
||||||
|
"""
|
||||||
|
Set the keyboard RGB backlight color.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
red: Red component (0-255)
|
||||||
|
green: Green component (0-255)
|
||||||
|
blue: Blue component (0-255)
|
||||||
|
"""
|
||||||
|
self.transport.set_keyboard_rgb_backlight(red, green, blue)
|
||||||
|
|
||||||
|
def keyboard_os_mode_switch(self, os_mode: int):
|
||||||
|
"""
|
||||||
|
Set the keyboard OS mode.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
os_mode: OS mode identifier
|
||||||
|
"""
|
||||||
|
self.transport.keyboard_os_mode_switch(os_mode)
|
||||||
600
StreamDock/Devices/StreamDock.py
Normal file
600
StreamDock/Devices/StreamDock.py
Normal file
@ -0,0 +1,600 @@
|
|||||||
|
import platform
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from abc import ABC, ABCMeta, abstractmethod
|
||||||
|
import threading
|
||||||
|
from abc import ABC, ABCMeta, abstractmethod
|
||||||
|
import ctypes
|
||||||
|
import ctypes.util
|
||||||
|
import threading
|
||||||
|
import traceback
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from ..FeatrueOption import FeatrueOption, device_type
|
||||||
|
from ..Transport.LibUSBHIDAPI import LibUSBHIDAPI
|
||||||
|
from ..InputTypes import InputEvent, ButtonKey
|
||||||
|
|
||||||
|
|
||||||
|
class TransportError(Exception):
|
||||||
|
"""Custom exception type for transport errors"""
|
||||||
|
|
||||||
|
def __init__(self, message, code=None):
|
||||||
|
super().__init__(message)
|
||||||
|
self.code = code # Optional error code
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
if self.code:
|
||||||
|
return f"[Error Code {self.code}] {super().__str__()}"
|
||||||
|
return super().__str__()
|
||||||
|
|
||||||
|
|
||||||
|
class StreamDock(ABC):
|
||||||
|
"""
|
||||||
|
Represents a physically attached StreamDock device.
|
||||||
|
"""
|
||||||
|
|
||||||
|
KEY_COUNT = 0
|
||||||
|
KEY_COLS = 0
|
||||||
|
KEY_ROWS = 0
|
||||||
|
|
||||||
|
KEY_PIXEL_WIDTH = 0
|
||||||
|
KEY_PIXEL_HEIGHT = 0
|
||||||
|
KEY_IMAGE_FORMAT = ""
|
||||||
|
KEY_FLIP = (False, False)
|
||||||
|
KEY_ROTATION = 0
|
||||||
|
KEY_MAP = False
|
||||||
|
|
||||||
|
TOUCHSCREEN_PIXEL_WIDTH = 0
|
||||||
|
TOUCHSCREEN_PIXEL_HEIGHT = 0
|
||||||
|
TOUCHSCREEN_IMAGE_FORMAT = ""
|
||||||
|
TOUCHSCREEN_FLIP = (False, False)
|
||||||
|
TOUCHSCREEN_ROTATION = 0
|
||||||
|
|
||||||
|
DIAL_COUNT = 0
|
||||||
|
|
||||||
|
DECK_TYPE = ""
|
||||||
|
DECK_VISUAL = False
|
||||||
|
DECK_TOUCH = False
|
||||||
|
|
||||||
|
transport: LibUSBHIDAPI
|
||||||
|
screenlicent = None
|
||||||
|
__metaclass__ = ABCMeta
|
||||||
|
__seconds = 300
|
||||||
|
|
||||||
|
feature_option: FeatrueOption
|
||||||
|
|
||||||
|
def __init__(self, transport1: LibUSBHIDAPI, devInfo):
|
||||||
|
self.transport = transport1
|
||||||
|
self.vendor_id = devInfo["vendor_id"]
|
||||||
|
self.product_id = devInfo["product_id"]
|
||||||
|
self.path = devInfo["path"]
|
||||||
|
self.serial_number = devInfo.get("serial_number", "")
|
||||||
|
self.firmware_version = ""
|
||||||
|
self.read_thread = None
|
||||||
|
self.run_read_thread = False
|
||||||
|
self.feature_option = FeatrueOption()
|
||||||
|
self.key_callback = None
|
||||||
|
# CRITICAL: Add lock to protect callback access in multi-threaded environment
|
||||||
|
self._callback_lock = threading.Lock()
|
||||||
|
# Heartbeat thread for keeping device alive
|
||||||
|
self.heartbeat_thread = None
|
||||||
|
self.run_heartbeat_thread = False
|
||||||
|
|
||||||
|
# self.update_lock = threading.RLock()
|
||||||
|
# self.screenlicent=threading.Timer(self.__seconds,self.screen_Off)
|
||||||
|
# self.screenlicent.start()
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
"""
|
||||||
|
Delete handler for the StreamDock, automatically closing the transport
|
||||||
|
if it is currently open and terminating the transport reader thread.
|
||||||
|
|
||||||
|
CRITICAL: This is called during garbage collection which may happen during
|
||||||
|
interpreter shutdown. We need to be extremely careful to avoid calling
|
||||||
|
C code during shutdown as it can cause segmentation faults.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# CRITICAL: Don't call C code during interpreter shutdown
|
||||||
|
if sys.is_finalizing():
|
||||||
|
# During interpreter shutdown, skip cleanup to avoid segfault
|
||||||
|
# The OS will clean up resources when process exits
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Stop the reader thread (safe operation)
|
||||||
|
self.run_read_thread = False
|
||||||
|
if self.read_thread and self.read_thread.is_alive():
|
||||||
|
self.read_thread.join(timeout=0.5) # Short timeout during __del__
|
||||||
|
except (TransportError, ValueError, RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Stop the heartbeat thread (safe operation)
|
||||||
|
self.run_heartbeat_thread = False
|
||||||
|
if self.heartbeat_thread and self.heartbeat_thread.is_alive():
|
||||||
|
self.heartbeat_thread.join(timeout=0.5) # Short timeout during __del__
|
||||||
|
except (TransportError, ValueError, RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Close transport - this may call C code but we checked is_finalizing above
|
||||||
|
self.close()
|
||||||
|
except TransportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
"""
|
||||||
|
Enter handler for the StreamDock, taking the exclusive update lock on
|
||||||
|
the deck. This can be used in a `with` statement to ensure that only one
|
||||||
|
thread is currently updating the deck, even if it is doing multiple
|
||||||
|
operations (e.g. setting the image on multiple keys).
|
||||||
|
"""
|
||||||
|
# self.update_lock.acquire()
|
||||||
|
|
||||||
|
def __exit__(self, type, value, traceback):
|
||||||
|
"""
|
||||||
|
Exit handler for the StreamDock, releasing the exclusive update lock on
|
||||||
|
the deck.
|
||||||
|
"""
|
||||||
|
# self.update_lock.release()
|
||||||
|
|
||||||
|
# Open device
|
||||||
|
def open(self):
|
||||||
|
res1 = self.transport.open(bytes(self.path, "utf-8"))
|
||||||
|
self._setup_reader(self._read)
|
||||||
|
# Start heartbeat with delay to avoid Linux libusb deadlock
|
||||||
|
# The read thread needs time to initialize before heartbeat starts
|
||||||
|
time.sleep(0.1)
|
||||||
|
self._start_heartbeat()
|
||||||
|
# macOS need to get firmware version after opening device
|
||||||
|
if platform.system() == "Darwin":
|
||||||
|
self.firmware_version = self.transport.get_firmware_version()
|
||||||
|
return res1
|
||||||
|
|
||||||
|
# Initialize
|
||||||
|
def init(self):
|
||||||
|
self.set_device()
|
||||||
|
self.wakeScreen()
|
||||||
|
self.set_brightness(100)
|
||||||
|
self.clearAllIcon()
|
||||||
|
if platform.system() != "Darwin":
|
||||||
|
self.firmware_version = self.transport.get_firmware_version()
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
# Set device parameters
|
||||||
|
@abstractmethod
|
||||||
|
def set_device(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Set device LED brightness
|
||||||
|
def set_led_brightness(self, percent):
|
||||||
|
if self.feature_option.hasRGBLed:
|
||||||
|
return self.transport.set_led_brightness(percent)
|
||||||
|
|
||||||
|
# Set device LED color
|
||||||
|
def set_led_color(self, r, g, b):
|
||||||
|
if self.feature_option.hasRGBLed:
|
||||||
|
return self.transport.set_led_color(self.feature_option.ledCounts, r, g, b)
|
||||||
|
|
||||||
|
# Reset device LED effects
|
||||||
|
def reset_led_effect(self):
|
||||||
|
if self.feature_option.hasRGBLed:
|
||||||
|
return self.transport.reset_led_color()
|
||||||
|
|
||||||
|
# Close device
|
||||||
|
def close(self):
|
||||||
|
"""
|
||||||
|
Close the device and release all resources.
|
||||||
|
|
||||||
|
CRITICAL: This method must be called before the object is destroyed to ensure
|
||||||
|
clean shutdown of the C library and prevent segmentation faults.
|
||||||
|
"""
|
||||||
|
# print(f"[DEBUG] Closing device: {self.path}")
|
||||||
|
|
||||||
|
# CRITICAL: Stop heartbeat thread first
|
||||||
|
self.run_heartbeat_thread = False
|
||||||
|
if self.heartbeat_thread and self.heartbeat_thread.is_alive():
|
||||||
|
try:
|
||||||
|
self.heartbeat_thread.join(timeout=2.0)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WARNING] Error while waiting for heartbeat thread to exit: {e}", flush=True)
|
||||||
|
|
||||||
|
# CRITICAL: Stop reader thread first and wait for it to finish
|
||||||
|
self.run_read_thread = False
|
||||||
|
|
||||||
|
if self.read_thread and self.read_thread.is_alive():
|
||||||
|
try:
|
||||||
|
# Give thread time to exit naturally
|
||||||
|
self.read_thread.join(timeout=2.0) # Wait up to 2 seconds
|
||||||
|
if self.read_thread.is_alive():
|
||||||
|
print("[WARNING] Read thread did not exit in time", flush=True)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WARNING] Error while waiting for read thread to exit: {e}", flush=True)
|
||||||
|
|
||||||
|
# Send disconnect command (may fail if device already disconnected)
|
||||||
|
try:
|
||||||
|
self.disconnected()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WARNING] Error sending disconnect command: {e}", flush=True)
|
||||||
|
|
||||||
|
# CRITICAL: Close transport properly to release HID device
|
||||||
|
try:
|
||||||
|
self.transport.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WARNING] Error closing transport: {e}", flush=True)
|
||||||
|
|
||||||
|
# Clear callback to break any circular references
|
||||||
|
with self._callback_lock:
|
||||||
|
self.key_callback = None
|
||||||
|
|
||||||
|
# print("[DEBUG] Device closed")
|
||||||
|
|
||||||
|
# Disconnect and clear all displays
|
||||||
|
def disconnected(self):
|
||||||
|
self.transport.disconnected()
|
||||||
|
|
||||||
|
# Clear a specific key icon
|
||||||
|
def clearIcon(self, index):
|
||||||
|
origin = index
|
||||||
|
if origin not in range(1, self.KEY_COUNT + 1):
|
||||||
|
print(f"key '{origin}' out of range. you should set (1 ~ {self.KEY_COUNT})")
|
||||||
|
return -1
|
||||||
|
logical_key = ButtonKey(origin) if isinstance(origin, int) else origin
|
||||||
|
hardware_key = self.get_image_key(logical_key)
|
||||||
|
self.transport.keyClear(hardware_key)
|
||||||
|
|
||||||
|
# Clear all key icons
|
||||||
|
def clearAllIcon(self):
|
||||||
|
self.transport.keyAllClear()
|
||||||
|
|
||||||
|
# Wake the screen
|
||||||
|
def wakeScreen(self):
|
||||||
|
self.transport.wakeScreen()
|
||||||
|
|
||||||
|
# Refresh the device display
|
||||||
|
def refresh(self):
|
||||||
|
self.transport.refresh()
|
||||||
|
|
||||||
|
# Get device path
|
||||||
|
def getPath(self):
|
||||||
|
return self.path
|
||||||
|
|
||||||
|
# Get device feedback data
|
||||||
|
def read(self):
|
||||||
|
"""
|
||||||
|
:argtypes: byte array to store info; recommended length 1024
|
||||||
|
|
||||||
|
"""
|
||||||
|
data = self.transport.read_(1024)
|
||||||
|
return data
|
||||||
|
|
||||||
|
# Continuously check for device feedback; recommended to run in a thread
|
||||||
|
def whileread(self):
|
||||||
|
"""
|
||||||
|
@deprecated Use the built-in async callback mechanism instead of calling this directly
|
||||||
|
"""
|
||||||
|
from ..InputTypes import EventType
|
||||||
|
|
||||||
|
while 1:
|
||||||
|
try:
|
||||||
|
data = self.read()
|
||||||
|
if data != None and len(data) >= 11:
|
||||||
|
try:
|
||||||
|
event = self.decode_input_event(data[9], data[10])
|
||||||
|
if event.event_type == EventType.BUTTON:
|
||||||
|
action = "pressed" if event.state == 1 else "released"
|
||||||
|
print(
|
||||||
|
f"Key {event.key.value if event.key else '?'} was {action}"
|
||||||
|
)
|
||||||
|
elif event.event_type == EventType.KNOB_ROTATE:
|
||||||
|
print(
|
||||||
|
f"Knob {event.knob_id.value if event.knob_id else '?'} rotated {event.direction.value if event.direction else '?'}"
|
||||||
|
)
|
||||||
|
elif event.event_type == EventType.KNOB_PRESS:
|
||||||
|
action = "pressed" if event.state == 1 else "released"
|
||||||
|
print(
|
||||||
|
f"Knob {event.knob_id.value if event.knob_id else '?'} was {action}"
|
||||||
|
)
|
||||||
|
elif event.event_type == EventType.SWIPE:
|
||||||
|
print(
|
||||||
|
f"Swipe gesture: {event.direction.value if event.direction else '?'}"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# self.transport.deleteRead()
|
||||||
|
except Exception as e:
|
||||||
|
print("Error occurred:")
|
||||||
|
traceback.print_exc() # Print detailed exception info
|
||||||
|
break
|
||||||
|
|
||||||
|
# # Screen off
|
||||||
|
# def screen_Off(self):
|
||||||
|
# res=self.transport.screen_Off()
|
||||||
|
# self.reset_Countdown(self.__seconds)
|
||||||
|
# return res
|
||||||
|
# # Wake screen
|
||||||
|
# def screen_On(self):
|
||||||
|
# return self.transport.screen_On()
|
||||||
|
# # Set timer interval
|
||||||
|
# def set_seconds(self, data):
|
||||||
|
# self.__seconds = data
|
||||||
|
# self.reset_Countdown(self.__seconds)
|
||||||
|
|
||||||
|
# # Restart timer
|
||||||
|
# def reset_Countdown(self, data):
|
||||||
|
# if self.screenlicent is not None:
|
||||||
|
# self.screenlicent.cancel()
|
||||||
|
# if hasattr(self, 'screen_Off'):
|
||||||
|
# self.screenlicent = threading.Timer(data, self.screen_Off)
|
||||||
|
# self.screenlicent.start()
|
||||||
|
|
||||||
|
def get_serial_number(self):
|
||||||
|
"""Return the device serial number."""
|
||||||
|
return self.serial_number
|
||||||
|
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def set_key_image(self, key, path) -> int | None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# @abstractmethod
|
||||||
|
# def set_key_imageData(self, key, image, width=126, height=126):
|
||||||
|
# pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def set_brightness(self, percent):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def set_touchscreen_image(self, path) -> int | None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def set_background_image(self, path) -> int | None:
|
||||||
|
self.set_touchscreen_image(path)
|
||||||
|
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_image_key(self, logical_key: ButtonKey) -> int:
|
||||||
|
"""
|
||||||
|
Convert logical key value to hardware key value (for setting images)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
logical_key: Logical key enum
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: Hardware key value
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def decode_input_event(self, hardware_code: int, state: int) -> InputEvent:
|
||||||
|
"""
|
||||||
|
Decode hardware event codes into a unified InputEvent
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hardware_code: Hardware event code
|
||||||
|
state: State (0=release, 1=press)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
InputEvent: Decoded event object
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def id(self):
|
||||||
|
"""
|
||||||
|
Retrieves the physical ID of the attached StreamDock. This can be used
|
||||||
|
to differentiate one StreamDock from another.
|
||||||
|
|
||||||
|
:rtype: str
|
||||||
|
:return: Identifier for the attached device.
|
||||||
|
"""
|
||||||
|
return self.getPath()
|
||||||
|
|
||||||
|
def _read(self):
|
||||||
|
try:
|
||||||
|
while self.run_read_thread:
|
||||||
|
try:
|
||||||
|
arr = self.read()
|
||||||
|
if arr is not None and len(arr) >= 10:
|
||||||
|
if arr[9] == 0xFF:
|
||||||
|
# Confirm write success
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
# Use the device class event decoder
|
||||||
|
if self.feature_option.deviceType != device_type.k1pro:
|
||||||
|
event = self.decode_input_event(arr[9], arr[10])
|
||||||
|
else:
|
||||||
|
event = self.decode_input_event(arr[10], arr[11])
|
||||||
|
# Get callback reference with lock
|
||||||
|
with self._callback_lock:
|
||||||
|
callback = self.key_callback
|
||||||
|
|
||||||
|
# Call callback OUTSIDE of lock to avoid deadlocks
|
||||||
|
if callback is not None:
|
||||||
|
try:
|
||||||
|
# Callback signature: callback(device, event)
|
||||||
|
callback(self, event)
|
||||||
|
except Exception as callback_error:
|
||||||
|
print(
|
||||||
|
f"Key callback error: {callback_error}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
traceback.print_exc()
|
||||||
|
except Exception as decode_error:
|
||||||
|
print(f"Event decode error: {decode_error}", flush=True)
|
||||||
|
traceback.print_exc()
|
||||||
|
# else:
|
||||||
|
# print("read control", arr)
|
||||||
|
# Don't explicitly delete arr - let Python's GC handle it
|
||||||
|
# del arr causes issues with ctypes buffers on Linux
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error reading data: {e}", flush=True)
|
||||||
|
traceback.print_exc()
|
||||||
|
continue
|
||||||
|
except Exception as outer_error:
|
||||||
|
print(f"[FATAL] Read thread outer exception: {outer_error}", flush=True)
|
||||||
|
traceback.print_exc()
|
||||||
|
finally:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _heartbeat_worker(self):
|
||||||
|
"""
|
||||||
|
Worker method that sends heartbeat packets to the device every 10 seconds.
|
||||||
|
This keeps the device connection alive and prevents timeout.
|
||||||
|
"""
|
||||||
|
# Initial delay to allow device and read thread to stabilize
|
||||||
|
time.sleep(1.0)
|
||||||
|
try:
|
||||||
|
while self.run_heartbeat_thread:
|
||||||
|
try:
|
||||||
|
self.transport.heartbeat()
|
||||||
|
except Exception as e:
|
||||||
|
# Log but don't crash the thread on heartbeat errors
|
||||||
|
print(f"Heartbeat error: {e}", flush=True)
|
||||||
|
# Wait 10 seconds before next heartbeat
|
||||||
|
time.sleep(10)
|
||||||
|
except Exception as outer_error:
|
||||||
|
print(f"[FATAL] Heartbeat thread outer exception: {outer_error}", flush=True)
|
||||||
|
finally:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _setup_reader(self, callback):
|
||||||
|
"""
|
||||||
|
Sets up the internal transport reader thread with the given callback,
|
||||||
|
for asynchronous processing of HID events from the device. If the thread
|
||||||
|
already exists, it is terminated and restarted with the new callback
|
||||||
|
function.
|
||||||
|
|
||||||
|
:param function callback: Callback to run on the reader thread.
|
||||||
|
"""
|
||||||
|
if self.read_thread is not None:
|
||||||
|
self.run_read_thread = False
|
||||||
|
try:
|
||||||
|
self.read_thread.join()
|
||||||
|
# return
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if callback is not None:
|
||||||
|
self.run_read_thread = True
|
||||||
|
self.read_thread = threading.Thread(target=callback)
|
||||||
|
self.read_thread.daemon = True
|
||||||
|
self.read_thread.start()
|
||||||
|
|
||||||
|
def _start_heartbeat(self):
|
||||||
|
"""
|
||||||
|
Starts the heartbeat thread that sends periodic heartbeat packets to the device.
|
||||||
|
"""
|
||||||
|
if self.heartbeat_thread is not None:
|
||||||
|
self.run_heartbeat_thread = False
|
||||||
|
try:
|
||||||
|
self.heartbeat_thread.join()
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self.run_heartbeat_thread = True
|
||||||
|
self.heartbeat_thread = threading.Thread(target=self._heartbeat_worker)
|
||||||
|
self.heartbeat_thread.daemon = True
|
||||||
|
self.heartbeat_thread.start()
|
||||||
|
|
||||||
|
def set_key_callback(self, callback):
|
||||||
|
"""
|
||||||
|
Sets the callback function called each time a button on the StreamDock
|
||||||
|
changes state (either pressed, or released), or a knob is rotated/pressed,
|
||||||
|
or a swipe gesture is detected.
|
||||||
|
|
||||||
|
.. note:: This callback will be fired from an internal reader thread.
|
||||||
|
Ensure that the given callback function is thread-safe.
|
||||||
|
|
||||||
|
.. note:: Only one callback can be registered at one time.
|
||||||
|
|
||||||
|
.. seealso:: See :func:`~StreamDock.set_key_callback_async` method for
|
||||||
|
a version compatible with Python 3 `asyncio` asynchronous
|
||||||
|
functions.
|
||||||
|
|
||||||
|
:param function callback: Callback function with signature:
|
||||||
|
callback(device: StreamDock, event: InputEvent)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
def on_input(device, event):
|
||||||
|
from StreamDock.InputTypes import EventType
|
||||||
|
if event.event_type == EventType.BUTTON:
|
||||||
|
print(f"Key {event.key.value} pressed")
|
||||||
|
elif event.event_type == EventType.KNOB_ROTATE:
|
||||||
|
print("Knob rotated")
|
||||||
|
"""
|
||||||
|
with self._callback_lock:
|
||||||
|
self.key_callback = callback
|
||||||
|
|
||||||
|
def set_key_callback_async(self, async_callback, loop=None):
|
||||||
|
"""
|
||||||
|
Sets the asynchronous callback function called each time a button on the
|
||||||
|
StreamDock changes state (either pressed, or released), or a knob is
|
||||||
|
rotated/pressed, or a swipe gesture is detected. The given callback
|
||||||
|
should be compatible with Python 3's `asyncio` routines.
|
||||||
|
|
||||||
|
.. note:: The asynchronous callback will be fired in a thread-safe
|
||||||
|
manner.
|
||||||
|
|
||||||
|
.. note:: This will override the callback (if any) set by
|
||||||
|
:func:`~StreamDock.set_key_callback`.
|
||||||
|
|
||||||
|
:param function async_callback: Asynchronous callback function with signature:
|
||||||
|
async_callback(device: StreamDock, event: InputEvent)
|
||||||
|
:param asyncio.loop loop: Asyncio loop to dispatch the callback into
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
loop = loop or asyncio.get_event_loop()
|
||||||
|
|
||||||
|
def callback(*args):
|
||||||
|
asyncio.run_coroutine_threadsafe(async_callback(*args), loop)
|
||||||
|
|
||||||
|
self.set_key_callback(callback)
|
||||||
|
|
||||||
|
def set_touchscreen_callback(self, callback):
|
||||||
|
"""
|
||||||
|
Sets the callback function called each time there is an interaction
|
||||||
|
with a touchscreen on the StreamDock.
|
||||||
|
|
||||||
|
.. note:: This callback will be fired from an internal reader thread.
|
||||||
|
Ensure that the given callback function is thread-safe.
|
||||||
|
|
||||||
|
.. note:: Only one callback can be registered at one time.
|
||||||
|
|
||||||
|
.. seealso:: See :func:`~StreamDock.set_touchscreen_callback_async`
|
||||||
|
method for a version compatible with Python 3 `asyncio`
|
||||||
|
asynchronous functions.
|
||||||
|
|
||||||
|
:param function callback: Callback function to fire each time a button
|
||||||
|
state changes.
|
||||||
|
"""
|
||||||
|
self.touchscreen_callback = callback
|
||||||
|
|
||||||
|
def set_touchscreen_callback_async(self, async_callback, loop=None):
|
||||||
|
"""
|
||||||
|
Sets the asynchronous callback function called each time there is an
|
||||||
|
interaction with the touchscreen on the StreamDock. The given callback
|
||||||
|
should be compatible with Python 3's `asyncio` routines.
|
||||||
|
|
||||||
|
.. note:: The asynchronous callback will be fired in a thread-safe
|
||||||
|
manner.
|
||||||
|
|
||||||
|
.. note:: This will override the callback (if any) set by
|
||||||
|
:func:`~StreamDock.set_touchscreen_callback`.
|
||||||
|
|
||||||
|
:param function async_callback: Asynchronous callback function to fire
|
||||||
|
each time a button state changes.
|
||||||
|
:param asyncio.loop loop: Asyncio loop to dispatch the callback into
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
loop = loop or asyncio.get_event_loop()
|
||||||
|
|
||||||
|
def callback(*args):
|
||||||
|
asyncio.run_coroutine_threadsafe(async_callback(*args), loop)
|
||||||
|
|
||||||
|
self.set_touchscreen_callback(callback)
|
||||||
157
StreamDock/Devices/StreamDock293.py
Normal file
157
StreamDock/Devices/StreamDock293.py
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
from StreamDock.FeatrueOption import device_type
|
||||||
|
from .StreamDock import StreamDock
|
||||||
|
from ..InputTypes import InputEvent, ButtonKey, EventType
|
||||||
|
from PIL import Image
|
||||||
|
import ctypes
|
||||||
|
import ctypes.util
|
||||||
|
import os, io
|
||||||
|
from ..ImageHelpers.PILHelper import *
|
||||||
|
import random
|
||||||
|
|
||||||
|
|
||||||
|
class StreamDock293(StreamDock):
|
||||||
|
"""StreamDock293 device class - supports 15 keys"""
|
||||||
|
|
||||||
|
KEY_COUNT = 15
|
||||||
|
KEY_MAP = False
|
||||||
|
|
||||||
|
# Image key mapping: logical key -> hardware key (for setting images)
|
||||||
|
# The 293 device uses the base class KEY_MAPPING mapping
|
||||||
|
_IMAGE_KEY_MAP = {
|
||||||
|
ButtonKey.KEY_1: 11,
|
||||||
|
ButtonKey.KEY_2: 12,
|
||||||
|
ButtonKey.KEY_3: 13,
|
||||||
|
ButtonKey.KEY_4: 14,
|
||||||
|
ButtonKey.KEY_5: 15,
|
||||||
|
ButtonKey.KEY_6: 6,
|
||||||
|
ButtonKey.KEY_7: 7,
|
||||||
|
ButtonKey.KEY_8: 8,
|
||||||
|
ButtonKey.KEY_9: 9,
|
||||||
|
ButtonKey.KEY_10: 10,
|
||||||
|
ButtonKey.KEY_11: 1,
|
||||||
|
ButtonKey.KEY_12: 2,
|
||||||
|
ButtonKey.KEY_13: 3,
|
||||||
|
ButtonKey.KEY_14: 4,
|
||||||
|
ButtonKey.KEY_15: 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Reverse mapping: hardware key -> logical key (for event decoding)
|
||||||
|
_HW_TO_LOGICAL_KEY = {v: k for k, v in _IMAGE_KEY_MAP.items()}
|
||||||
|
|
||||||
|
def __init__(self, transport1, devInfo):
|
||||||
|
super().__init__(transport1, devInfo)
|
||||||
|
|
||||||
|
def get_image_key(self, logical_key: ButtonKey) -> int:
|
||||||
|
"""
|
||||||
|
Convert logical key value to hardware key value (for setting images)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
logical_key: Logical key enum
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: Hardware key value
|
||||||
|
"""
|
||||||
|
if logical_key in self._IMAGE_KEY_MAP:
|
||||||
|
return self._IMAGE_KEY_MAP[logical_key]
|
||||||
|
raise ValueError(f"StreamDock293: Unsupported key {logical_key}")
|
||||||
|
|
||||||
|
def decode_input_event(self, hardware_code: int, state: int) -> InputEvent:
|
||||||
|
"""
|
||||||
|
Decode hardware event codes into a unified InputEvent
|
||||||
|
|
||||||
|
The 293 device supports only regular buttons; hardware code range 1-15
|
||||||
|
"""
|
||||||
|
# Handle state value: 0x02=release, 0x01=press
|
||||||
|
normalized_state = 1 if state == 0x01 else 0
|
||||||
|
|
||||||
|
# Regular button events (1-15)
|
||||||
|
if hardware_code in self._HW_TO_LOGICAL_KEY:
|
||||||
|
return InputEvent(
|
||||||
|
event_type=EventType.BUTTON,
|
||||||
|
key=self._HW_TO_LOGICAL_KEY[hardware_code],
|
||||||
|
state=normalized_state
|
||||||
|
)
|
||||||
|
|
||||||
|
# Unknown event
|
||||||
|
return InputEvent(event_type=EventType.UNKNOWN)
|
||||||
|
|
||||||
|
# Set device screen brightness
|
||||||
|
def set_brightness(self, percent):
|
||||||
|
return self.transport.setBrightness(percent)
|
||||||
|
|
||||||
|
# Set device background image 800 * 480
|
||||||
|
def set_touchscreen_image(self, path):
|
||||||
|
try:
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print(f"Error: The image file '{path}' does not exist.")
|
||||||
|
return -1
|
||||||
|
image = Image.open(path)
|
||||||
|
image = to_native_touchscreen_format(self, image)
|
||||||
|
width, height = image.size
|
||||||
|
bgr_data = []
|
||||||
|
for y in range(height):
|
||||||
|
for x in range(width):
|
||||||
|
r,g,b = image.getpixel((x,y))
|
||||||
|
bgr_data.extend([b,g,r])
|
||||||
|
arr_type = ctypes.c_char * len(bgr_data)
|
||||||
|
arr_ctypes = arr_type(*bgr_data)
|
||||||
|
return self.transport.setBackgroundImg(ctypes.cast(arr_ctypes, ctypes.POINTER(ctypes.c_ubyte)),width * height * 3)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
# Set device key icon image 100 * 100
|
||||||
|
def set_key_image(self, key, path):
|
||||||
|
try:
|
||||||
|
if isinstance(key, int):
|
||||||
|
if key not in range(1, 16):
|
||||||
|
print(f"key '{key}' out of range. you should set (1 ~ 15)")
|
||||||
|
return -1
|
||||||
|
logical_key = ButtonKey(key)
|
||||||
|
else:
|
||||||
|
logical_key = key
|
||||||
|
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print(f"Error: The image file '{path}' does not exist.")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
# Get hardware key value
|
||||||
|
hardware_key = self.get_image_key(logical_key)
|
||||||
|
|
||||||
|
image = Image.open(path)
|
||||||
|
rotated_image = to_native_key_format(self, image)
|
||||||
|
rotated_image.save("Temporary.jpg", "JPEG", subsampling=0, quality=100)
|
||||||
|
returnvalue = self.transport.setKeyImg(bytes("Temporary.jpg",'utf-8'), hardware_key)
|
||||||
|
os.remove("Temporary.jpg")
|
||||||
|
return returnvalue
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
# Get device firmware version
|
||||||
|
def get_serial_number(self):
|
||||||
|
return self.serial_number
|
||||||
|
|
||||||
|
def key_image_format(self):
|
||||||
|
return {
|
||||||
|
'size': (100, 100),
|
||||||
|
'format': "JPEG",
|
||||||
|
'rotation': 180,
|
||||||
|
'flip': (False, False)
|
||||||
|
}
|
||||||
|
|
||||||
|
def touchscreen_image_format(self):
|
||||||
|
return {
|
||||||
|
'size': (800, 480),
|
||||||
|
'format': "JPEG",
|
||||||
|
'rotation': 180,
|
||||||
|
'flip': (False, False)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Set device parameters
|
||||||
|
def set_device(self):
|
||||||
|
self.transport.set_report_size(513, 513, 0)
|
||||||
|
self.feature_option.deviceType = device_type.dock_293
|
||||||
|
pass
|
||||||
168
StreamDock/Devices/StreamDock293V3.py
Normal file
168
StreamDock/Devices/StreamDock293V3.py
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
from StreamDock.FeatrueOption import device_type
|
||||||
|
from .StreamDock import StreamDock
|
||||||
|
from ..InputTypes import InputEvent, ButtonKey, EventType
|
||||||
|
from PIL import Image
|
||||||
|
import ctypes
|
||||||
|
import ctypes.util
|
||||||
|
import os, io
|
||||||
|
from ..ImageHelpers.PILHelper import *
|
||||||
|
import random
|
||||||
|
|
||||||
|
|
||||||
|
class StreamDock293V3(StreamDock):
|
||||||
|
"""StreamDock293V3 device class - supports 15 keys"""
|
||||||
|
|
||||||
|
KEY_COUNT = 15
|
||||||
|
KEY_MAP = False
|
||||||
|
|
||||||
|
# Image key mapping: logical key -> hardware key (for setting images)
|
||||||
|
_IMAGE_KEY_MAP = {
|
||||||
|
ButtonKey.KEY_1: 11,
|
||||||
|
ButtonKey.KEY_2: 12,
|
||||||
|
ButtonKey.KEY_3: 13,
|
||||||
|
ButtonKey.KEY_4: 14,
|
||||||
|
ButtonKey.KEY_5: 15,
|
||||||
|
ButtonKey.KEY_6: 6,
|
||||||
|
ButtonKey.KEY_7: 7,
|
||||||
|
ButtonKey.KEY_8: 8,
|
||||||
|
ButtonKey.KEY_9: 9,
|
||||||
|
ButtonKey.KEY_10: 10,
|
||||||
|
ButtonKey.KEY_11: 1,
|
||||||
|
ButtonKey.KEY_12: 2,
|
||||||
|
ButtonKey.KEY_13: 3,
|
||||||
|
ButtonKey.KEY_14: 4,
|
||||||
|
ButtonKey.KEY_15: 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Reverse mapping: hardware key -> logical key (for event decoding)
|
||||||
|
_HW_TO_LOGICAL_KEY = {v: k for k, v in _IMAGE_KEY_MAP.items()}
|
||||||
|
|
||||||
|
def __init__(self, transport1, devInfo):
|
||||||
|
super().__init__(transport1, devInfo)
|
||||||
|
|
||||||
|
def get_image_key(self, logical_key: ButtonKey) -> int:
|
||||||
|
"""
|
||||||
|
Convert logical key value to hardware key value (for setting images)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
logical_key: Logical key enum
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: Hardware key value
|
||||||
|
"""
|
||||||
|
if logical_key in self._IMAGE_KEY_MAP:
|
||||||
|
return self._IMAGE_KEY_MAP[logical_key]
|
||||||
|
raise ValueError(f"StreamDock293V3: Unsupported key {logical_key}")
|
||||||
|
|
||||||
|
def decode_input_event(self, hardware_code: int, state: int) -> InputEvent:
|
||||||
|
"""
|
||||||
|
Decode hardware event codes into a unified InputEvent
|
||||||
|
|
||||||
|
The 293V3 device supports only regular buttons; hardware code range 1-15
|
||||||
|
"""
|
||||||
|
# Handle state value: 0x02=release, 0x01=press
|
||||||
|
normalized_state = 1 if state == 0x01 else 0
|
||||||
|
|
||||||
|
# Regular button events (1-15)
|
||||||
|
if hardware_code in self._HW_TO_LOGICAL_KEY:
|
||||||
|
return InputEvent(
|
||||||
|
event_type=EventType.BUTTON,
|
||||||
|
key=self._HW_TO_LOGICAL_KEY[hardware_code],
|
||||||
|
state=normalized_state
|
||||||
|
)
|
||||||
|
|
||||||
|
# Unknown event
|
||||||
|
return InputEvent(event_type=EventType.UNKNOWN)
|
||||||
|
|
||||||
|
# Set device screen brightness
|
||||||
|
def set_brightness(self, percent):
|
||||||
|
return self.transport.setBrightness(percent)
|
||||||
|
|
||||||
|
# Set device background image 800 * 480
|
||||||
|
def set_touchscreen_image(self, path):
|
||||||
|
try:
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print(f"Error: The image file '{path}' does not exist.")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
# open formatter
|
||||||
|
image = Image.open(path)
|
||||||
|
image = to_native_touchscreen_format(self, image)
|
||||||
|
temp_image_path = "rotated_touchscreen_image_" + str(random.randint(9999, 999999)) + ".jpg"
|
||||||
|
image.save(temp_image_path)
|
||||||
|
|
||||||
|
# encode send
|
||||||
|
path_bytes = temp_image_path.encode('utf-8')
|
||||||
|
c_path = ctypes.c_char_p(path_bytes)
|
||||||
|
res = self.transport.setBackgroundImgDualDevice(c_path)
|
||||||
|
os.remove(temp_image_path)
|
||||||
|
return res
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
# Set device key icon image 112 * 112
|
||||||
|
def set_key_image(self, key, path):
|
||||||
|
try:
|
||||||
|
if isinstance(key, int):
|
||||||
|
if key not in range(1, 16):
|
||||||
|
print(f"key '{key}' out of range. you should set (1 ~ 15)")
|
||||||
|
return -1
|
||||||
|
logical_key = ButtonKey(key)
|
||||||
|
else:
|
||||||
|
logical_key = key
|
||||||
|
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print(f"Error: The image file '{path}' does not exist.")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
# Get hardware key value
|
||||||
|
hardware_key = self.get_image_key(logical_key)
|
||||||
|
|
||||||
|
# open formatter
|
||||||
|
image = Image.open(path)
|
||||||
|
image = to_native_key_format(self, image)
|
||||||
|
temp_image_path = "rotated_key_image_" + str(random.randint(9999, 999999)) + ".jpg"
|
||||||
|
image.save(temp_image_path)
|
||||||
|
|
||||||
|
# encode send
|
||||||
|
path_bytes = temp_image_path.encode('utf-8')
|
||||||
|
c_path = ctypes.c_char_p(path_bytes)
|
||||||
|
res = self.transport.setKeyImgDualDevice(c_path, hardware_key)
|
||||||
|
os.remove(temp_image_path)
|
||||||
|
return res
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
# TODO
|
||||||
|
def set_key_imageData(self, key, path):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Get device firmware version
|
||||||
|
def get_serial_number(self):
|
||||||
|
return self.serial_number
|
||||||
|
|
||||||
|
def key_image_format(self):
|
||||||
|
return {
|
||||||
|
'size': (112, 112),
|
||||||
|
'format': "JPEG",
|
||||||
|
'rotation': 180,
|
||||||
|
'flip': (False, False)
|
||||||
|
}
|
||||||
|
|
||||||
|
def touchscreen_image_format(self):
|
||||||
|
return {
|
||||||
|
'size': (800, 480),
|
||||||
|
'format': "JPEG",
|
||||||
|
'rotation': 180,
|
||||||
|
'flip': (False, False)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Set device parameters
|
||||||
|
def set_device(self):
|
||||||
|
self.transport.set_report_size(513, 1025, 0)
|
||||||
|
self.feature_option.deviceType = device_type.dock_293v3
|
||||||
|
pass
|
||||||
167
StreamDock/Devices/StreamDock293s.py
Normal file
167
StreamDock/Devices/StreamDock293s.py
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from StreamDock.FeatrueOption import device_type
|
||||||
|
from .StreamDock import StreamDock
|
||||||
|
from ..InputTypes import InputEvent, ButtonKey, EventType
|
||||||
|
from PIL import Image
|
||||||
|
import ctypes
|
||||||
|
import ctypes.util
|
||||||
|
import os, io
|
||||||
|
from ..ImageHelpers.PILHelper import *
|
||||||
|
import random
|
||||||
|
|
||||||
|
|
||||||
|
class StreamDock293s(StreamDock):
|
||||||
|
"""StreamDock293s device class - supports 15 keys and 3 secondary screen keys"""
|
||||||
|
|
||||||
|
KEY_COUNT = 18
|
||||||
|
KEY_MAP = False
|
||||||
|
|
||||||
|
# Image key mapping: logical key -> hardware key (for setting images)
|
||||||
|
_IMAGE_KEY_MAP = {
|
||||||
|
ButtonKey.KEY_1: 13,
|
||||||
|
ButtonKey.KEY_2: 10,
|
||||||
|
ButtonKey.KEY_3: 7,
|
||||||
|
ButtonKey.KEY_4: 4,
|
||||||
|
ButtonKey.KEY_5: 1,
|
||||||
|
ButtonKey.KEY_6: 14,
|
||||||
|
ButtonKey.KEY_7: 11,
|
||||||
|
ButtonKey.KEY_8: 8,
|
||||||
|
ButtonKey.KEY_9: 5,
|
||||||
|
ButtonKey.KEY_10: 2,
|
||||||
|
ButtonKey.KEY_11: 15,
|
||||||
|
ButtonKey.KEY_12: 12,
|
||||||
|
ButtonKey.KEY_13: 9,
|
||||||
|
ButtonKey.KEY_14: 6,
|
||||||
|
ButtonKey.KEY_15: 3,
|
||||||
|
# Secondary screen keys 16-18
|
||||||
|
ButtonKey.KEY_16: 16,
|
||||||
|
ButtonKey.KEY_17: 17,
|
||||||
|
ButtonKey.KEY_18: 18,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Reverse mapping: hardware key -> logical key (for event decoding)
|
||||||
|
_HW_TO_LOGICAL_KEY = {v: k for k, v in _IMAGE_KEY_MAP.items()}
|
||||||
|
|
||||||
|
def __init__(self, transport1, devInfo):
|
||||||
|
super().__init__(transport1, devInfo)
|
||||||
|
|
||||||
|
def get_image_key(self, logical_key: ButtonKey) -> int:
|
||||||
|
"""
|
||||||
|
Convert logical key value to hardware key value (for setting images)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
logical_key: Logical key enum
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: Hardware key value
|
||||||
|
"""
|
||||||
|
if logical_key in self._IMAGE_KEY_MAP:
|
||||||
|
return self._IMAGE_KEY_MAP[logical_key]
|
||||||
|
raise ValueError(f"StreamDock293s: Unsupported key {logical_key}")
|
||||||
|
|
||||||
|
def decode_input_event(self, hardware_code: int, state: int) -> InputEvent:
|
||||||
|
"""
|
||||||
|
Decode hardware event codes into a unified InputEvent
|
||||||
|
|
||||||
|
The 293s device supports only regular buttons; hardware code range 1-18
|
||||||
|
"""
|
||||||
|
# Handle state value: 0x02=release, 0x01=press
|
||||||
|
normalized_state = 1 if state == 0x01 else 0
|
||||||
|
|
||||||
|
# Regular button events (1-18)
|
||||||
|
if hardware_code in self._HW_TO_LOGICAL_KEY:
|
||||||
|
return InputEvent(
|
||||||
|
event_type=EventType.BUTTON,
|
||||||
|
key=self._HW_TO_LOGICAL_KEY[hardware_code],
|
||||||
|
state=normalized_state
|
||||||
|
)
|
||||||
|
|
||||||
|
# Unknown event
|
||||||
|
return InputEvent(event_type=EventType.UNKNOWN)
|
||||||
|
|
||||||
|
# Set device screen brightness
|
||||||
|
def set_brightness(self, percent):
|
||||||
|
return self.transport.setBrightness(percent)
|
||||||
|
|
||||||
|
# Set device background image 854 * 480
|
||||||
|
def set_touchscreen_image(self, image):
|
||||||
|
image = Image.open(image)
|
||||||
|
image = to_native_touchscreen_format(self, image)
|
||||||
|
width, height = image.size
|
||||||
|
bgr_data = []
|
||||||
|
|
||||||
|
for x in range(width):
|
||||||
|
for y in range(height):
|
||||||
|
r,g,b = image.getpixel((x,y))
|
||||||
|
bgr_data.extend([b,g,r])
|
||||||
|
arr_type = ctypes.c_char * len(bgr_data)
|
||||||
|
arr_ctypes = arr_type(*bgr_data)
|
||||||
|
|
||||||
|
return self.transport.setBackgroundImg(ctypes.cast(arr_ctypes, ctypes.POINTER(ctypes.c_ubyte)),width * height * 3)
|
||||||
|
|
||||||
|
# Set device key icon image 85 * 85
|
||||||
|
def set_key_image(self, key, path):
|
||||||
|
try:
|
||||||
|
if isinstance(key, int):
|
||||||
|
if key not in range(1, 19):
|
||||||
|
print(f"key '{key}' out of range. you should set (1 ~ 18)")
|
||||||
|
return -1
|
||||||
|
logical_key = ButtonKey(key)
|
||||||
|
else:
|
||||||
|
logical_key = key
|
||||||
|
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print(f"Error: The image file '{path}' does not exist.")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
# Get hardware key value
|
||||||
|
hardware_key = self.get_image_key(logical_key)
|
||||||
|
|
||||||
|
image = Image.open(path)
|
||||||
|
if hardware_key in range(1, 16):
|
||||||
|
# icon
|
||||||
|
rotated_image = to_native_key_format(self, image)
|
||||||
|
elif hardware_key in range(16, 19):
|
||||||
|
# second screen
|
||||||
|
rotated_image = to_native_seondscreen_format(self, image)
|
||||||
|
rotated_image.save("Temporary.jpg", "JPEG", subsampling=0, quality=100)
|
||||||
|
returnvalue = self.transport.setKeyImg(bytes("Temporary.jpg",'utf-8'), hardware_key)
|
||||||
|
os.remove("Temporary.jpg")
|
||||||
|
return returnvalue
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
def get_serial_number(self):
|
||||||
|
return self.serial_number
|
||||||
|
|
||||||
|
def key_image_format(self):
|
||||||
|
return {
|
||||||
|
'size': (85, 85),
|
||||||
|
'format': "JPEG",
|
||||||
|
'rotation': 90,
|
||||||
|
'flip': (False, False)
|
||||||
|
}
|
||||||
|
|
||||||
|
def secondscreen_image_format(self):
|
||||||
|
return {
|
||||||
|
'size': (80, 80),
|
||||||
|
'format': "JPEG",
|
||||||
|
'rotation': 90,
|
||||||
|
'flip': (False, False)
|
||||||
|
}
|
||||||
|
|
||||||
|
def touchscreen_image_format(self):
|
||||||
|
return {
|
||||||
|
'size': (854, 480),
|
||||||
|
'format': "JPEG",
|
||||||
|
'rotation': 0,
|
||||||
|
'flip': (True, False)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Set device parameters
|
||||||
|
def set_device(self):
|
||||||
|
self.transport.set_report_size(513, 513, 0)
|
||||||
|
self.feature_option.deviceType = device_type.dock_293s
|
||||||
|
pass
|
||||||
179
StreamDock/Devices/StreamDock293sV3.py
Normal file
179
StreamDock/Devices/StreamDock293sV3.py
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from StreamDock.FeatrueOption import device_type
|
||||||
|
from .StreamDock import StreamDock
|
||||||
|
from ..InputTypes import InputEvent, ButtonKey, EventType
|
||||||
|
from PIL import Image
|
||||||
|
import ctypes
|
||||||
|
import ctypes.util
|
||||||
|
import os, io
|
||||||
|
from ..ImageHelpers.PILHelper import *
|
||||||
|
import random
|
||||||
|
|
||||||
|
|
||||||
|
class StreamDock293sV3(StreamDock):
|
||||||
|
"""StreamDock293sV3 device class - supports 15 keys and 3 secondary screen keys"""
|
||||||
|
|
||||||
|
KEY_COUNT = 18
|
||||||
|
KEY_MAP = False
|
||||||
|
|
||||||
|
# Image key mapping: logical key -> hardware key (for setting images)
|
||||||
|
_IMAGE_KEY_MAP = {
|
||||||
|
ButtonKey.KEY_1: 13,
|
||||||
|
ButtonKey.KEY_2: 10,
|
||||||
|
ButtonKey.KEY_3: 7,
|
||||||
|
ButtonKey.KEY_4: 4,
|
||||||
|
ButtonKey.KEY_5: 1,
|
||||||
|
ButtonKey.KEY_6: 14,
|
||||||
|
ButtonKey.KEY_7: 11,
|
||||||
|
ButtonKey.KEY_8: 8,
|
||||||
|
ButtonKey.KEY_9: 5,
|
||||||
|
ButtonKey.KEY_10: 2,
|
||||||
|
ButtonKey.KEY_11: 15,
|
||||||
|
ButtonKey.KEY_12: 12,
|
||||||
|
ButtonKey.KEY_13: 9,
|
||||||
|
ButtonKey.KEY_14: 6,
|
||||||
|
ButtonKey.KEY_15: 3,
|
||||||
|
# Secondary screen keys 16-18
|
||||||
|
ButtonKey.KEY_16: 16,
|
||||||
|
ButtonKey.KEY_17: 17,
|
||||||
|
ButtonKey.KEY_18: 18,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Reverse mapping: hardware key -> logical key (for event decoding)
|
||||||
|
_HW_TO_LOGICAL_KEY = {v: k for k, v in _IMAGE_KEY_MAP.items()}
|
||||||
|
|
||||||
|
def __init__(self, transport1, devInfo):
|
||||||
|
super().__init__(transport1, devInfo)
|
||||||
|
|
||||||
|
def get_image_key(self, logical_key: ButtonKey) -> int:
|
||||||
|
"""
|
||||||
|
Convert logical key value to hardware key value (for setting images)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
logical_key: Logical key enum
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: Hardware key value
|
||||||
|
"""
|
||||||
|
if logical_key in self._IMAGE_KEY_MAP:
|
||||||
|
return self._IMAGE_KEY_MAP[logical_key]
|
||||||
|
raise ValueError(f"StreamDock293sV3: Unsupported key {logical_key}")
|
||||||
|
|
||||||
|
def decode_input_event(self, hardware_code: int, state: int) -> InputEvent:
|
||||||
|
"""
|
||||||
|
Decode hardware event codes into a unified InputEvent
|
||||||
|
|
||||||
|
The 293sV3 device supports only regular buttons; hardware code range 1-18
|
||||||
|
"""
|
||||||
|
# Handle state value: 0x02=release, 0x01=press
|
||||||
|
normalized_state = 1 if state == 0x01 else 0
|
||||||
|
|
||||||
|
# Regular button events (1-18)
|
||||||
|
if hardware_code in self._HW_TO_LOGICAL_KEY:
|
||||||
|
return InputEvent(
|
||||||
|
event_type=EventType.BUTTON,
|
||||||
|
key=self._HW_TO_LOGICAL_KEY[hardware_code],
|
||||||
|
state=normalized_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Unknown event
|
||||||
|
return InputEvent(event_type=EventType.UNKNOWN)
|
||||||
|
|
||||||
|
# Set device screen brightness
|
||||||
|
def set_brightness(self, percent):
|
||||||
|
return self.transport.setBrightness(percent)
|
||||||
|
|
||||||
|
# Set device background image 854 * 480
|
||||||
|
def set_touchscreen_image(self, path):
|
||||||
|
try:
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print(f"Error: The image file '{path}' does not exist.")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
image = Image.open(path)
|
||||||
|
image = to_native_touchscreen_format(self, image)
|
||||||
|
temp_image_path = (
|
||||||
|
"rotated_touchscreen_image_"
|
||||||
|
+ str(random.randint(9999, 999999))
|
||||||
|
+ ".jpg"
|
||||||
|
)
|
||||||
|
image.save(temp_image_path)
|
||||||
|
|
||||||
|
# encode send
|
||||||
|
path_bytes = temp_image_path.encode("utf-8")
|
||||||
|
c_path = ctypes.c_char_p(path_bytes)
|
||||||
|
res = self.transport.setBackgroundImgDualDevice(c_path)
|
||||||
|
os.remove(temp_image_path)
|
||||||
|
return res
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
# Set device key icon image 85 * 85
|
||||||
|
def set_key_image(self, key, path):
|
||||||
|
try:
|
||||||
|
if isinstance(key, int):
|
||||||
|
if key not in range(1, 19):
|
||||||
|
print(f"key '{key}' out of range. you should set (1 ~ 18)")
|
||||||
|
return -1
|
||||||
|
logical_key = ButtonKey(key)
|
||||||
|
else:
|
||||||
|
logical_key = key
|
||||||
|
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print(f"Error: The image file '{path}' does not exist.")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
# Get hardware key value
|
||||||
|
hardware_key = self.get_image_key(logical_key)
|
||||||
|
|
||||||
|
image = Image.open(path)
|
||||||
|
if hardware_key in range(1, 16):
|
||||||
|
# icon
|
||||||
|
image = to_native_key_format(self, image)
|
||||||
|
elif hardware_key in range(16, 19):
|
||||||
|
# second screen
|
||||||
|
image = to_native_seondscreen_format(self, image)
|
||||||
|
image.save("Temporary.jpg", "JPEG", subsampling=0, quality=100)
|
||||||
|
returnvalue = self.transport.setKeyImg(
|
||||||
|
bytes("Temporary.jpg", "utf-8"), hardware_key
|
||||||
|
)
|
||||||
|
os.remove("Temporary.jpg")
|
||||||
|
return returnvalue
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
def get_serial_number(self):
|
||||||
|
return self.serial_number
|
||||||
|
|
||||||
|
def key_image_format(self):
|
||||||
|
return {
|
||||||
|
"size": (96, 96),
|
||||||
|
"format": "JPEG",
|
||||||
|
"rotation": 90,
|
||||||
|
"flip": (False, False),
|
||||||
|
}
|
||||||
|
|
||||||
|
def secondscreen_image_format(self):
|
||||||
|
return {
|
||||||
|
"size": (80, 80),
|
||||||
|
"format": "JPEG",
|
||||||
|
"rotation": 90,
|
||||||
|
"flip": (False, False),
|
||||||
|
}
|
||||||
|
|
||||||
|
def touchscreen_image_format(self):
|
||||||
|
return {
|
||||||
|
"size": (854, 480),
|
||||||
|
"format": "JPEG",
|
||||||
|
"rotation": 90,
|
||||||
|
"flip": (False, False),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Set device parameters
|
||||||
|
def set_device(self):
|
||||||
|
self.transport.set_report_size(513, 1025, 0)
|
||||||
|
self.feature_option.deviceType = device_type.dock_293sv3
|
||||||
|
pass
|
||||||
179
StreamDock/Devices/StreamDockM18.py
Normal file
179
StreamDock/Devices/StreamDockM18.py
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
from StreamDock.FeatrueOption import device_type
|
||||||
|
from .StreamDock import StreamDock
|
||||||
|
from ..InputTypes import InputEvent, ButtonKey, EventType
|
||||||
|
from PIL import Image
|
||||||
|
import ctypes
|
||||||
|
import ctypes.util
|
||||||
|
import os, io
|
||||||
|
from ..ImageHelpers.PILHelper import *
|
||||||
|
import random
|
||||||
|
|
||||||
|
|
||||||
|
class StreamDockM18(StreamDock):
|
||||||
|
"""StreamDockM18 device class - supports 15 keys"""
|
||||||
|
|
||||||
|
KEY_COUNT = 15
|
||||||
|
KEY_MAP = False
|
||||||
|
|
||||||
|
# Image key mapping: logical key -> hardware key (for setting images)
|
||||||
|
_IMAGE_KEY_MAP = {
|
||||||
|
ButtonKey.KEY_1: 11,
|
||||||
|
ButtonKey.KEY_2: 12,
|
||||||
|
ButtonKey.KEY_3: 13,
|
||||||
|
ButtonKey.KEY_4: 14,
|
||||||
|
ButtonKey.KEY_5: 15,
|
||||||
|
ButtonKey.KEY_6: 6,
|
||||||
|
ButtonKey.KEY_7: 7,
|
||||||
|
ButtonKey.KEY_8: 8,
|
||||||
|
ButtonKey.KEY_9: 9,
|
||||||
|
ButtonKey.KEY_10: 10,
|
||||||
|
ButtonKey.KEY_11: 1,
|
||||||
|
ButtonKey.KEY_12: 2,
|
||||||
|
ButtonKey.KEY_13: 3,
|
||||||
|
ButtonKey.KEY_14: 4,
|
||||||
|
ButtonKey.KEY_15: 5,
|
||||||
|
ButtonKey.KEY_16: 0x25,
|
||||||
|
ButtonKey.KEY_17: 0x30,
|
||||||
|
ButtonKey.KEY_18: 0x31,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Reverse mapping: hardware key -> logical key (for event decoding)
|
||||||
|
_HW_TO_LOGICAL_KEY = {v: k for k, v in _IMAGE_KEY_MAP.items()}
|
||||||
|
|
||||||
|
def __init__(self, transport1, devInfo):
|
||||||
|
super().__init__(transport1, devInfo)
|
||||||
|
|
||||||
|
def get_image_key(self, logical_key: ButtonKey) -> int:
|
||||||
|
"""
|
||||||
|
Convert logical key value to hardware key value (for setting images)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
logical_key: Logical key enum
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: Hardware key value
|
||||||
|
"""
|
||||||
|
if logical_key in self._IMAGE_KEY_MAP:
|
||||||
|
return self._IMAGE_KEY_MAP[logical_key]
|
||||||
|
raise ValueError(f"StreamDockM18: Unsupported key {logical_key}")
|
||||||
|
|
||||||
|
def decode_input_event(self, hardware_code: int, state: int) -> InputEvent:
|
||||||
|
"""
|
||||||
|
Decode hardware event codes into a unified InputEvent
|
||||||
|
|
||||||
|
M18 supports only regular buttons; hardware code range 1-15
|
||||||
|
"""
|
||||||
|
# Handle state value: 0x02=release, 0x01=press
|
||||||
|
normalized_state = 1 if state == 0x01 else 0
|
||||||
|
|
||||||
|
# Regular button events (1-15)
|
||||||
|
if hardware_code in self._HW_TO_LOGICAL_KEY:
|
||||||
|
return InputEvent(
|
||||||
|
event_type=EventType.BUTTON,
|
||||||
|
key=self._HW_TO_LOGICAL_KEY[hardware_code],
|
||||||
|
state=normalized_state
|
||||||
|
)
|
||||||
|
|
||||||
|
# Unknown event
|
||||||
|
return InputEvent(event_type=EventType.UNKNOWN)
|
||||||
|
|
||||||
|
# Set device screen brightness
|
||||||
|
def set_brightness(self, percent):
|
||||||
|
return self.transport.setBrightness(percent)
|
||||||
|
|
||||||
|
# Set device background image 480 * 272
|
||||||
|
def set_touchscreen_image(self, path):
|
||||||
|
try:
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print(f"Error: The image file '{path}' does not exist.")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
# open formatter
|
||||||
|
image = Image.open(path)
|
||||||
|
image = to_native_touchscreen_format(self, image)
|
||||||
|
temp_image_path = (
|
||||||
|
"rotated_touchscreen_image_"
|
||||||
|
+ str(random.randint(9999, 999999))
|
||||||
|
+ ".jpg"
|
||||||
|
)
|
||||||
|
image.save(temp_image_path)
|
||||||
|
|
||||||
|
# encode send
|
||||||
|
path_bytes = temp_image_path.encode("utf-8")
|
||||||
|
c_path = ctypes.c_char_p(path_bytes)
|
||||||
|
res = self.transport.setBackgroundImgDualDevice(c_path)
|
||||||
|
os.remove(temp_image_path)
|
||||||
|
return res
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
# Set device key icon image 64 * 64
|
||||||
|
def set_key_image(self, key, path):
|
||||||
|
try:
|
||||||
|
if isinstance(key, int):
|
||||||
|
if key not in range(1, 16):
|
||||||
|
print(f"key '{key}' out of range. you should set (1 ~ 15)")
|
||||||
|
return -1
|
||||||
|
logical_key = ButtonKey(key)
|
||||||
|
else:
|
||||||
|
logical_key = key
|
||||||
|
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print(f"Error: The image file '{path}' does not exist.")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
# Get hardware key value
|
||||||
|
hardware_key = self.get_image_key(logical_key)
|
||||||
|
|
||||||
|
# open formatter
|
||||||
|
image = Image.open(path)
|
||||||
|
image = to_native_key_format(self, image)
|
||||||
|
temp_image_path = (
|
||||||
|
"rotated_key_image_" + str(random.randint(9999, 999999)) + ".jpg"
|
||||||
|
)
|
||||||
|
image.save(temp_image_path)
|
||||||
|
|
||||||
|
# encode send
|
||||||
|
path_bytes = temp_image_path.encode("utf-8")
|
||||||
|
c_path = ctypes.c_char_p(path_bytes)
|
||||||
|
res = self.transport.setKeyImgDualDevice(c_path, hardware_key)
|
||||||
|
os.remove(temp_image_path)
|
||||||
|
return res
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
# TODO
|
||||||
|
def set_key_imageData(self, key, path):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Get device firmware version
|
||||||
|
def get_serial_number(self):
|
||||||
|
return self.serial_number
|
||||||
|
|
||||||
|
def key_image_format(self):
|
||||||
|
return {
|
||||||
|
"size": (64, 64),
|
||||||
|
"format": "JPEG",
|
||||||
|
"rotation": 0,
|
||||||
|
"flip": (False, False),
|
||||||
|
}
|
||||||
|
|
||||||
|
def touchscreen_image_format(self):
|
||||||
|
return {
|
||||||
|
"size": (480, 272),
|
||||||
|
"format": "JPEG",
|
||||||
|
"rotation": 0,
|
||||||
|
"flip": (False, False),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Set device parameters
|
||||||
|
def set_device(self):
|
||||||
|
self.transport.set_report_size(513, 1025, 0)
|
||||||
|
self.feature_option.hasRGBLed = True
|
||||||
|
self.feature_option.ledCounts = 24
|
||||||
|
self.feature_option.deviceType = device_type.dock_m18
|
||||||
|
pass
|
||||||
230
StreamDock/Devices/StreamDockM3.py
Normal file
230
StreamDock/Devices/StreamDockM3.py
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
from StreamDock.FeatrueOption import device_type
|
||||||
|
from .StreamDock import StreamDock
|
||||||
|
from ..InputTypes import Direction, InputEvent, ButtonKey, EventType, KnobId
|
||||||
|
from PIL import Image
|
||||||
|
import ctypes
|
||||||
|
import ctypes.util
|
||||||
|
import os, io
|
||||||
|
from ..ImageHelpers.PILHelper import *
|
||||||
|
import random
|
||||||
|
|
||||||
|
|
||||||
|
class StreamDockM3(StreamDock):
|
||||||
|
"""StreamDockM3 device class - supports 15 keys"""
|
||||||
|
|
||||||
|
KEY_COUNT = 15
|
||||||
|
KEY_MAP = False
|
||||||
|
|
||||||
|
# Image key mapping: logical key -> hardware key (for setting images)
|
||||||
|
_IMAGE_KEY_MAP = {
|
||||||
|
ButtonKey.KEY_1: 11,
|
||||||
|
ButtonKey.KEY_2: 12,
|
||||||
|
ButtonKey.KEY_3: 13,
|
||||||
|
ButtonKey.KEY_4: 14,
|
||||||
|
ButtonKey.KEY_5: 15,
|
||||||
|
ButtonKey.KEY_6: 6,
|
||||||
|
ButtonKey.KEY_7: 7,
|
||||||
|
ButtonKey.KEY_8: 8,
|
||||||
|
ButtonKey.KEY_9: 9,
|
||||||
|
ButtonKey.KEY_10: 10,
|
||||||
|
ButtonKey.KEY_11: 1,
|
||||||
|
ButtonKey.KEY_12: 2,
|
||||||
|
ButtonKey.KEY_13: 3,
|
||||||
|
ButtonKey.KEY_14: 4,
|
||||||
|
ButtonKey.KEY_15: 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Reverse mapping: hardware key -> logical key (for event decoding)
|
||||||
|
_HW_TO_LOGICAL_KEY = {v: k for k, v in _IMAGE_KEY_MAP.items()}
|
||||||
|
|
||||||
|
def __init__(self, transport1, devInfo):
|
||||||
|
super().__init__(transport1, devInfo)
|
||||||
|
|
||||||
|
def get_image_key(self, logical_key: ButtonKey) -> int:
|
||||||
|
"""
|
||||||
|
Convert logical key value to hardware key value (for setting images)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
logical_key: Logical key enum
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: Hardware key value
|
||||||
|
"""
|
||||||
|
if logical_key in self._IMAGE_KEY_MAP:
|
||||||
|
return self._IMAGE_KEY_MAP[logical_key]
|
||||||
|
raise ValueError(f"StreamDockM3: Unsupported key {logical_key}")
|
||||||
|
|
||||||
|
def decode_input_event(self, hardware_code: int, state: int) -> InputEvent:
|
||||||
|
"""
|
||||||
|
Decode hardware event codes into a unified InputEvent
|
||||||
|
|
||||||
|
M3 supports only regular buttons; hardware code range 1-15
|
||||||
|
"""
|
||||||
|
# Handle state value: 0x02=release, 0x01=press
|
||||||
|
normalized_state = 1 if state == 0x01 else 0
|
||||||
|
|
||||||
|
# Regular button events (1-15)
|
||||||
|
if hardware_code in self._HW_TO_LOGICAL_KEY:
|
||||||
|
return InputEvent(
|
||||||
|
event_type=EventType.BUTTON,
|
||||||
|
key=self._HW_TO_LOGICAL_KEY[hardware_code],
|
||||||
|
state=normalized_state
|
||||||
|
)
|
||||||
|
# Knob rotation event
|
||||||
|
knob_rotate_map = {
|
||||||
|
0x50: (KnobId.KNOB_1, Direction.LEFT),
|
||||||
|
0x51: (KnobId.KNOB_1, Direction.RIGHT),
|
||||||
|
0x90: (KnobId.KNOB_2, Direction.LEFT),
|
||||||
|
0x91: (KnobId.KNOB_2, Direction.RIGHT),
|
||||||
|
0xa0: (KnobId.KNOB_3, Direction.LEFT),
|
||||||
|
0xa1: (KnobId.KNOB_3, Direction.RIGHT),
|
||||||
|
}
|
||||||
|
if hardware_code in knob_rotate_map:
|
||||||
|
knob_id, direction = knob_rotate_map[hardware_code]
|
||||||
|
return InputEvent(
|
||||||
|
event_type=EventType.KNOB_ROTATE, knob_id=knob_id, direction=direction
|
||||||
|
)
|
||||||
|
|
||||||
|
# Knob press event
|
||||||
|
knob_press_map = {
|
||||||
|
0x35: KnobId.KNOB_1,
|
||||||
|
0x33: KnobId.KNOB_2,
|
||||||
|
0x37: KnobId.KNOB_3,
|
||||||
|
}
|
||||||
|
if hardware_code in knob_press_map:
|
||||||
|
return InputEvent(
|
||||||
|
event_type=EventType.KNOB_PRESS,
|
||||||
|
knob_id=knob_press_map[hardware_code],
|
||||||
|
state=normalized_state,
|
||||||
|
)
|
||||||
|
# Unknown event
|
||||||
|
return InputEvent(event_type=EventType.UNKNOWN)
|
||||||
|
|
||||||
|
# Set device screen brightness
|
||||||
|
def set_brightness(self, percent):
|
||||||
|
return self.transport.setBrightness(percent)
|
||||||
|
|
||||||
|
# Set device background image 480 * 272
|
||||||
|
def set_touchscreen_image(self, path):
|
||||||
|
try:
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print(f"Error: The image file '{path}' does not exist.")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
# open formatter
|
||||||
|
image = Image.open(path)
|
||||||
|
image = to_native_touchscreen_format(self, image)
|
||||||
|
temp_image_path = (
|
||||||
|
"rotated_touchscreen_image_"
|
||||||
|
+ str(random.randint(9999, 999999))
|
||||||
|
+ ".jpg"
|
||||||
|
)
|
||||||
|
image.save(temp_image_path)
|
||||||
|
|
||||||
|
# encode send
|
||||||
|
path_bytes = temp_image_path.encode("utf-8")
|
||||||
|
c_path = ctypes.c_char_p(path_bytes)
|
||||||
|
res = self.transport.setBackgroundImgDualDevice(c_path)
|
||||||
|
os.remove(temp_image_path)
|
||||||
|
return res
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
# Set device key icon image 64 * 64
|
||||||
|
def set_key_image(self, key, path):
|
||||||
|
try:
|
||||||
|
if isinstance(key, int):
|
||||||
|
if key not in range(1, 16):
|
||||||
|
print(f"key '{key}' out of range. you should set (1 ~ 15)")
|
||||||
|
return -1
|
||||||
|
logical_key = ButtonKey(key)
|
||||||
|
else:
|
||||||
|
logical_key = key
|
||||||
|
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print(f"Error: The image file '{path}' does not exist.")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
# Get hardware key value
|
||||||
|
hardware_key = self.get_image_key(logical_key)
|
||||||
|
|
||||||
|
# open formatter
|
||||||
|
image = Image.open(path)
|
||||||
|
image = to_native_key_format(self, image)
|
||||||
|
temp_image_path = (
|
||||||
|
"rotated_key_image_" + str(random.randint(9999, 999999)) + ".jpg"
|
||||||
|
)
|
||||||
|
image.save(temp_image_path)
|
||||||
|
|
||||||
|
# encode send
|
||||||
|
path_bytes = temp_image_path.encode("utf-8")
|
||||||
|
c_path = ctypes.c_char_p(path_bytes)
|
||||||
|
res = self.transport.setKeyImgDualDevice(c_path, hardware_key)
|
||||||
|
os.remove(temp_image_path)
|
||||||
|
return res
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
return -1
|
||||||
|
def set_frame_background(self, path):
|
||||||
|
try:
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print(f"Error: The image file '{path}' does not exist.")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
image = Image.open(path)
|
||||||
|
image = to_native_touchscreen_format(self, image)
|
||||||
|
temp_image_path = (
|
||||||
|
"rotated_touchscreen_image_"
|
||||||
|
+ str(random.randint(9999, 999999))
|
||||||
|
+ ".jpg"
|
||||||
|
)
|
||||||
|
image.save(temp_image_path, quality=80)
|
||||||
|
|
||||||
|
# encode send
|
||||||
|
path_bytes = temp_image_path.encode("utf-8")
|
||||||
|
c_path = ctypes.c_char_p(path_bytes)
|
||||||
|
res = self.transport.setBackgroundImgFrame(
|
||||||
|
c_path,
|
||||||
|
854,
|
||||||
|
480,
|
||||||
|
)
|
||||||
|
os.remove(temp_image_path)
|
||||||
|
return res
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
return -1
|
||||||
|
def set_key_imageData(self, key, path):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def magnetic_calibration(self):
|
||||||
|
"""Perform magnetic calibration."""
|
||||||
|
self.transport.magnetic_calibration()
|
||||||
|
|
||||||
|
# Get device firmware version
|
||||||
|
def get_serial_number(self):
|
||||||
|
return self.serial_number
|
||||||
|
|
||||||
|
def key_image_format(self):
|
||||||
|
return {
|
||||||
|
"size": (96, 96),
|
||||||
|
"format": "JPEG",
|
||||||
|
"rotation": 90,
|
||||||
|
"flip": (False, False),
|
||||||
|
}
|
||||||
|
|
||||||
|
def touchscreen_image_format(self):
|
||||||
|
return {
|
||||||
|
"size": (854, 480),
|
||||||
|
"format": "JPEG",
|
||||||
|
"rotation": 90,
|
||||||
|
"flip": (False, False),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Set device parameters
|
||||||
|
def set_device(self):
|
||||||
|
self.transport.set_report_size(513, 1025, 0)
|
||||||
|
self.feature_option.deviceType = device_type.dock_m3
|
||||||
|
pass
|
||||||
323
StreamDock/Devices/StreamDockN1.py
Normal file
323
StreamDock/Devices/StreamDockN1.py
Normal file
@ -0,0 +1,323 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from StreamDock.FeatrueOption import device_type
|
||||||
|
from .StreamDock import StreamDock
|
||||||
|
from ..InputTypes import InputEvent, ButtonKey, EventType, KnobId, Direction
|
||||||
|
from PIL import Image
|
||||||
|
import ctypes
|
||||||
|
import ctypes.util
|
||||||
|
import os, io
|
||||||
|
from ..ImageHelpers.PILHelper import *
|
||||||
|
import random
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
def extract_last_number(code):
|
||||||
|
"""
|
||||||
|
Extract consecutive digits after the last dot in a string and convert to int
|
||||||
|
|
||||||
|
Args:
|
||||||
|
code: String like "N3.02.013" or "N3.02.013V2"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: Extracted number, or None if not found
|
||||||
|
"""
|
||||||
|
# Find the position of the last dot
|
||||||
|
last_dot = code.rfind(".")
|
||||||
|
if last_dot == -1:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Extract consecutive digits after the last dot
|
||||||
|
num_str = ""
|
||||||
|
for char in code[last_dot + 1 :]:
|
||||||
|
if char.isdigit():
|
||||||
|
num_str += char
|
||||||
|
else:
|
||||||
|
# Stop at the first non-digit character
|
||||||
|
break
|
||||||
|
|
||||||
|
# If digits were found, convert to int
|
||||||
|
if num_str:
|
||||||
|
return int(num_str)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class StreamDockN1(StreamDock):
|
||||||
|
"""StreamDockN1 device class - supports 20 inputs (15 main screen + 3 secondary screen + knob)"""
|
||||||
|
|
||||||
|
KEY_COUNT = 20
|
||||||
|
KEY_MAP = False
|
||||||
|
|
||||||
|
# N1 device keys map directly; no mapping needed
|
||||||
|
_IMAGE_KEY_MAP = {
|
||||||
|
ButtonKey.KEY_1: 1,
|
||||||
|
ButtonKey.KEY_2: 2,
|
||||||
|
ButtonKey.KEY_3: 3,
|
||||||
|
ButtonKey.KEY_4: 4,
|
||||||
|
ButtonKey.KEY_5: 5,
|
||||||
|
ButtonKey.KEY_6: 6,
|
||||||
|
ButtonKey.KEY_7: 7,
|
||||||
|
ButtonKey.KEY_8: 8,
|
||||||
|
ButtonKey.KEY_9: 9,
|
||||||
|
ButtonKey.KEY_10: 10,
|
||||||
|
ButtonKey.KEY_11: 11,
|
||||||
|
ButtonKey.KEY_12: 12,
|
||||||
|
ButtonKey.KEY_13: 13,
|
||||||
|
ButtonKey.KEY_14: 14,
|
||||||
|
ButtonKey.KEY_15: 15,
|
||||||
|
ButtonKey.KEY_16: 0x1E,
|
||||||
|
ButtonKey.KEY_17: 0x1F,
|
||||||
|
}
|
||||||
|
|
||||||
|
class DeviceMode(Enum):
|
||||||
|
KEYBOARD = 0
|
||||||
|
CALCULATOR = 1
|
||||||
|
DOCK = 2
|
||||||
|
|
||||||
|
class SkinMode(Enum):
|
||||||
|
KEYBOARD = 0x11
|
||||||
|
KEYBOARD_LOCK = 0x1F
|
||||||
|
CALCULATOR = 0xFF
|
||||||
|
|
||||||
|
class SkinStatus(Enum):
|
||||||
|
PRESS = 0
|
||||||
|
RELEASE = 1
|
||||||
|
|
||||||
|
# Reverse mapping: hardware key -> logical key (for event decoding)
|
||||||
|
_HW_TO_LOGICAL_KEY = {v: k for k, v in _IMAGE_KEY_MAP.items()}
|
||||||
|
|
||||||
|
def __init__(self, transport1, devInfo):
|
||||||
|
super().__init__(transport1, devInfo)
|
||||||
|
self.devInfo = devInfo
|
||||||
|
|
||||||
|
def open(self):
|
||||||
|
super().open()
|
||||||
|
self.transport.switchMode(2)
|
||||||
|
|
||||||
|
def get_image_key(self, logical_key: ButtonKey) -> int:
|
||||||
|
"""
|
||||||
|
Convert logical key value to hardware key value (for setting images)
|
||||||
|
|
||||||
|
N1 device keys map directly
|
||||||
|
|
||||||
|
Args:
|
||||||
|
logical_key: Logical key enum
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: Hardware key value
|
||||||
|
"""
|
||||||
|
if logical_key in self._IMAGE_KEY_MAP:
|
||||||
|
return self._IMAGE_KEY_MAP[logical_key]
|
||||||
|
raise ValueError(f"StreamDockN1: Unsupported key {logical_key}")
|
||||||
|
|
||||||
|
def decode_input_event(self, hardware_code: int, state: int) -> InputEvent:
|
||||||
|
"""
|
||||||
|
Decode hardware event codes into a unified InputEvent
|
||||||
|
|
||||||
|
The N1 device supports regular button and knob events:
|
||||||
|
- Regular buttons 1-17: hardware codes 0x01-0x0F, 0x1E-0x1F
|
||||||
|
- Knob press 18: hardware code 0x23
|
||||||
|
- Knob rotation 19-20: hardware codes 0x32 (left), 0x33 (right)
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Knob rotation event
|
||||||
|
knob_rotate_map = {
|
||||||
|
0x32: (KnobId.KNOB_1, Direction.LEFT),
|
||||||
|
0x33: (KnobId.KNOB_1, Direction.RIGHT),
|
||||||
|
}
|
||||||
|
if hardware_code in knob_rotate_map:
|
||||||
|
knob_id, direction = knob_rotate_map[hardware_code]
|
||||||
|
return InputEvent(
|
||||||
|
event_type=EventType.KNOB_ROTATE, knob_id=knob_id, direction=direction
|
||||||
|
)
|
||||||
|
|
||||||
|
# Handle state value: 0x02=release, 0x01=press
|
||||||
|
normalized_state = 1 if state == 0x01 else 0
|
||||||
|
# Knob press event
|
||||||
|
knob_press_map = {
|
||||||
|
0x23: KnobId.KNOB_1,
|
||||||
|
}
|
||||||
|
if hardware_code in knob_press_map:
|
||||||
|
return InputEvent(
|
||||||
|
event_type=EventType.KNOB_PRESS,
|
||||||
|
knob_id=knob_press_map[hardware_code],
|
||||||
|
state=normalized_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Regular button events (1-17)
|
||||||
|
if hardware_code in self._HW_TO_LOGICAL_KEY:
|
||||||
|
return InputEvent(
|
||||||
|
event_type=EventType.BUTTON,
|
||||||
|
key=self._HW_TO_LOGICAL_KEY[hardware_code],
|
||||||
|
state=normalized_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Unknown event
|
||||||
|
return InputEvent(event_type=EventType.UNKNOWN)
|
||||||
|
|
||||||
|
# Set device screen brightness
|
||||||
|
def set_brightness(self, percent):
|
||||||
|
return self.transport.setBrightness(percent)
|
||||||
|
|
||||||
|
# Set device background image 480 * 854
|
||||||
|
def set_touchscreen_image(self, path):
|
||||||
|
version_str = self.get_serial_number()
|
||||||
|
version_num = extract_last_number(version_str)
|
||||||
|
if version_num is None:
|
||||||
|
return -1
|
||||||
|
if version_num >= 13:
|
||||||
|
try:
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print(f"Error: The image file '{path}' does not exist.")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
# open formatter
|
||||||
|
image = Image.open(path)
|
||||||
|
image = to_native_touchscreen_format(self, image)
|
||||||
|
temp_image_path = (
|
||||||
|
"rotated_touchscreen_image_"
|
||||||
|
+ str(random.randint(9999, 999999))
|
||||||
|
+ ".jpg"
|
||||||
|
)
|
||||||
|
image.save(temp_image_path)
|
||||||
|
|
||||||
|
# encode send
|
||||||
|
path_bytes = temp_image_path.encode("utf-8")
|
||||||
|
c_path = ctypes.c_char_p(path_bytes)
|
||||||
|
res = self.transport.setBackgroundImgDualDevice(c_path)
|
||||||
|
os.remove(temp_image_path)
|
||||||
|
return res
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
# Set device key icon image 96 * 96
|
||||||
|
def set_key_image(self, key, path):
|
||||||
|
try:
|
||||||
|
if isinstance(key, int):
|
||||||
|
if key not in range(1, 19):
|
||||||
|
print(f"key '{key}' out of range. you should set (1 ~ 18)")
|
||||||
|
return -1
|
||||||
|
logical_key = ButtonKey(key)
|
||||||
|
else:
|
||||||
|
logical_key = key
|
||||||
|
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print(f"Error: The image file '{path}' does not exist.")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
# N1 device keys map directly
|
||||||
|
hardware_key = logical_key.value
|
||||||
|
|
||||||
|
image = Image.open(path)
|
||||||
|
if hardware_key in range(1, 16):
|
||||||
|
# icon
|
||||||
|
rotated_image = to_native_key_format(self, image)
|
||||||
|
elif hardware_key in range(16, 19):
|
||||||
|
# second screen
|
||||||
|
rotated_image = to_native_seondscreen_format(self, image)
|
||||||
|
else:
|
||||||
|
print(f"Error: Invalid hardware key '{hardware_key}'.")
|
||||||
|
return -1
|
||||||
|
rotated_image.save("Temporary.jpg", "JPEG", subsampling=0, quality=90)
|
||||||
|
returnvalue = self.transport.setKeyImgDualDevice(
|
||||||
|
bytes("Temporary.jpg", "utf-8"), hardware_key
|
||||||
|
)
|
||||||
|
os.remove("Temporary.jpg")
|
||||||
|
return returnvalue
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
def get_serial_number(self):
|
||||||
|
return self.serial_number
|
||||||
|
|
||||||
|
def switch_mode(self, mode: DeviceMode):
|
||||||
|
# 0:calculator, 1:dock
|
||||||
|
return self.transport.switchMode(mode.value)
|
||||||
|
|
||||||
|
def change_page(self, page):
|
||||||
|
return self.transport.changePage(page)
|
||||||
|
|
||||||
|
def set_n1_skin_bitmap(
|
||||||
|
self,
|
||||||
|
path,
|
||||||
|
skin_mode: SkinMode,
|
||||||
|
skin_page: int,
|
||||||
|
skin_status: SkinStatus,
|
||||||
|
key_index: int,
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print(f"Error: The image file '{path}' does not exist.")
|
||||||
|
return -1
|
||||||
|
if self.SkinMode.CALCULATOR == skin_mode:
|
||||||
|
if key_index < 1 or key_index > 18:
|
||||||
|
print(
|
||||||
|
f"Error: For CALCULATOR skin mode, key_index should be in range 1-18."
|
||||||
|
)
|
||||||
|
return -1
|
||||||
|
elif (
|
||||||
|
self.SkinMode.KEYBOARD == skin_mode
|
||||||
|
or self.SkinMode.KEYBOARD_LOCK == skin_mode
|
||||||
|
):
|
||||||
|
if key_index < 1 or key_index > 15:
|
||||||
|
print(
|
||||||
|
f"Error: For KEYBOARD skin mode, key_index should be in range 1-15."
|
||||||
|
)
|
||||||
|
return -1
|
||||||
|
if skin_page < 1 or skin_page > 5:
|
||||||
|
print(f"Error: skin_page should be in range 1-5.")
|
||||||
|
return -1
|
||||||
|
image = Image.open(path)
|
||||||
|
if self.SkinMode.KEYBOARD == skin_mode and key_index in range(16, 19):
|
||||||
|
image = to_native_seondscreen_format(self, image)
|
||||||
|
else:
|
||||||
|
image = to_native_key_format(self, image)
|
||||||
|
temp_image_path = (
|
||||||
|
"rotated_n1_skin_image_" + str(random.randint(9999, 999999)) + ".png"
|
||||||
|
)
|
||||||
|
image.save(temp_image_path)
|
||||||
|
|
||||||
|
# encode send
|
||||||
|
path_bytes = temp_image_path.encode("utf-8")
|
||||||
|
c_path = ctypes.c_char_p(path_bytes)
|
||||||
|
res = self.transport.setN1SkinBitMap(
|
||||||
|
c_path, skin_mode.value, skin_page, skin_status.value, key_index
|
||||||
|
)
|
||||||
|
os.remove(temp_image_path)
|
||||||
|
return res
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
def key_image_format(self):
|
||||||
|
return {
|
||||||
|
"size": (96, 96),
|
||||||
|
"format": "JPEG",
|
||||||
|
"rotation": 0,
|
||||||
|
"flip": (False, False),
|
||||||
|
}
|
||||||
|
|
||||||
|
def secondscreen_image_format(self):
|
||||||
|
return {
|
||||||
|
"size": (80, 80),
|
||||||
|
"format": "JPEG",
|
||||||
|
"rotation": 0,
|
||||||
|
"flip": (False, False),
|
||||||
|
}
|
||||||
|
|
||||||
|
def touchscreen_image_format(self):
|
||||||
|
return {
|
||||||
|
"size": (480, 854),
|
||||||
|
"format": "JPEG",
|
||||||
|
"rotation": 0,
|
||||||
|
"flip": (False, False),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Set device parameters
|
||||||
|
def set_device(self):
|
||||||
|
self.transport.set_report_size(513, 1025, 0)
|
||||||
|
self.feature_option.deviceType = device_type.dock_n1
|
||||||
|
pass
|
||||||
142
StreamDock/Devices/StreamDockN3.py
Normal file
142
StreamDock/Devices/StreamDockN3.py
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
from StreamDock.FeatrueOption import device_type
|
||||||
|
from .StreamDock import StreamDock
|
||||||
|
from ..InputTypes import InputEvent, ButtonKey, EventType, KnobId, Direction
|
||||||
|
from PIL import Image
|
||||||
|
import os
|
||||||
|
import io
|
||||||
|
from ..ImageHelpers.PILHelper import *
|
||||||
|
|
||||||
|
|
||||||
|
class StreamDockN3(StreamDock):
|
||||||
|
"""StreamDockN3 device class (N3V25 variant) - 6 LCD keys + 3 bottom buttons + 3 knobs"""
|
||||||
|
|
||||||
|
KEY_COUNT = 18
|
||||||
|
KEY_MAP = False
|
||||||
|
|
||||||
|
_IMAGE_KEY_MAP = {
|
||||||
|
ButtonKey.KEY_1: 1,
|
||||||
|
ButtonKey.KEY_2: 2,
|
||||||
|
ButtonKey.KEY_3: 3,
|
||||||
|
ButtonKey.KEY_4: 4,
|
||||||
|
ButtonKey.KEY_5: 5,
|
||||||
|
ButtonKey.KEY_6: 6,
|
||||||
|
ButtonKey.KEY_7: 0x25,
|
||||||
|
ButtonKey.KEY_8: 0x30,
|
||||||
|
ButtonKey.KEY_9: 0x31,
|
||||||
|
}
|
||||||
|
|
||||||
|
_HW_TO_LOGICAL_KEY = {v: k for k, v in _IMAGE_KEY_MAP.items()}
|
||||||
|
|
||||||
|
def __init__(self, transport1, devInfo):
|
||||||
|
super().__init__(transport1, devInfo)
|
||||||
|
|
||||||
|
def get_image_key(self, logical_key: ButtonKey) -> int:
|
||||||
|
if logical_key in self._IMAGE_KEY_MAP:
|
||||||
|
return self._IMAGE_KEY_MAP[logical_key]
|
||||||
|
raise ValueError(f"StreamDockN3: Unsupported key {logical_key}")
|
||||||
|
|
||||||
|
def decode_input_event(self, hardware_code: int, state: int) -> InputEvent:
|
||||||
|
normalized_state = 1 if state == 0x01 else 0
|
||||||
|
if hardware_code in self._HW_TO_LOGICAL_KEY:
|
||||||
|
return InputEvent(
|
||||||
|
event_type=EventType.BUTTON,
|
||||||
|
key=self._HW_TO_LOGICAL_KEY[hardware_code],
|
||||||
|
state=normalized_state,
|
||||||
|
)
|
||||||
|
knob_rotate_map = {
|
||||||
|
0x90: (KnobId.KNOB_1, Direction.LEFT),
|
||||||
|
0x91: (KnobId.KNOB_1, Direction.RIGHT),
|
||||||
|
0x60: (KnobId.KNOB_2, Direction.LEFT),
|
||||||
|
0x61: (KnobId.KNOB_2, Direction.RIGHT),
|
||||||
|
0x50: (KnobId.KNOB_3, Direction.LEFT),
|
||||||
|
0x51: (KnobId.KNOB_3, Direction.RIGHT),
|
||||||
|
}
|
||||||
|
if hardware_code in knob_rotate_map:
|
||||||
|
knob_id, direction = knob_rotate_map[hardware_code]
|
||||||
|
return InputEvent(
|
||||||
|
event_type=EventType.KNOB_ROTATE, knob_id=knob_id, direction=direction
|
||||||
|
)
|
||||||
|
knob_press_map = {
|
||||||
|
0x33: KnobId.KNOB_1,
|
||||||
|
0x34: KnobId.KNOB_2,
|
||||||
|
0x35: KnobId.KNOB_3,
|
||||||
|
}
|
||||||
|
if hardware_code in knob_press_map:
|
||||||
|
return InputEvent(
|
||||||
|
event_type=EventType.KNOB_PRESS,
|
||||||
|
knob_id=knob_press_map[hardware_code],
|
||||||
|
state=normalized_state,
|
||||||
|
)
|
||||||
|
return InputEvent(event_type=EventType.UNKNOWN)
|
||||||
|
|
||||||
|
def set_brightness(self, percent):
|
||||||
|
return self.transport.set_key_brightness(percent)
|
||||||
|
|
||||||
|
def set_touchscreen_image(self, path):
|
||||||
|
try:
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print(f"Error: The image file '{path}' does not exist.")
|
||||||
|
return -1
|
||||||
|
image = Image.open(path)
|
||||||
|
image = to_native_touchscreen_format(self, image)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
image.save(buf, format="JPEG", quality=85)
|
||||||
|
jpeg_data = buf.getvalue()
|
||||||
|
res = self.transport.set_background_image_stream(jpeg_data)
|
||||||
|
return res
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
def set_key_image(self, key, path):
|
||||||
|
try:
|
||||||
|
if isinstance(key, int):
|
||||||
|
if key not in range(1, 19):
|
||||||
|
print(f"key '{key}' out of range. you should set (1 ~ 18)")
|
||||||
|
return -1
|
||||||
|
logical_key = ButtonKey(key)
|
||||||
|
else:
|
||||||
|
logical_key = key
|
||||||
|
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print(f"Error: The image file '{path}' does not exist.")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
hardware_key = self.get_image_key(logical_key)
|
||||||
|
|
||||||
|
if hardware_key > 6:
|
||||||
|
return -1
|
||||||
|
|
||||||
|
image = Image.open(path)
|
||||||
|
image = to_native_key_format(self, image)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
image.save(buf, format="JPEG", quality=90)
|
||||||
|
jpeg_data = buf.getvalue()
|
||||||
|
res = self.transport.set_key_image_stream(jpeg_data, hardware_key)
|
||||||
|
return res
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
def get_serial_number(self):
|
||||||
|
return self.serial_number
|
||||||
|
|
||||||
|
def key_image_format(self):
|
||||||
|
return {
|
||||||
|
"size": (64, 64),
|
||||||
|
"format": "JPEG",
|
||||||
|
"rotation": -90,
|
||||||
|
"flip": (False, False),
|
||||||
|
}
|
||||||
|
|
||||||
|
def touchscreen_image_format(self):
|
||||||
|
return {
|
||||||
|
"size": (320, 240),
|
||||||
|
"format": "JPEG",
|
||||||
|
"rotation": -90,
|
||||||
|
"flip": (False, False),
|
||||||
|
}
|
||||||
|
|
||||||
|
def set_device(self):
|
||||||
|
self.transport.set_report_size(513, 1025, 0)
|
||||||
|
self.feature_option.deviceType = device_type.dock_n3
|
||||||
210
StreamDock/Devices/StreamDockN4.py
Normal file
210
StreamDock/Devices/StreamDockN4.py
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
from StreamDock.FeatrueOption import device_type
|
||||||
|
from .StreamDock import StreamDock
|
||||||
|
from ..InputTypes import InputEvent, ButtonKey, EventType
|
||||||
|
from PIL import Image
|
||||||
|
import ctypes
|
||||||
|
import ctypes.util
|
||||||
|
import os, io
|
||||||
|
from ..ImageHelpers.PILHelper import *
|
||||||
|
import random
|
||||||
|
|
||||||
|
|
||||||
|
class StreamDockN4(StreamDock):
|
||||||
|
"""StreamDockN4 device class - supports 14 keys (10 main screen + 4 secondary screen)"""
|
||||||
|
|
||||||
|
KEY_COUNT = 14
|
||||||
|
KEY_MAP = False
|
||||||
|
|
||||||
|
# Image key mapping: logical key -> hardware key (for setting images)
|
||||||
|
_IMAGE_KEY_MAP = {
|
||||||
|
ButtonKey.KEY_1: 11,
|
||||||
|
ButtonKey.KEY_2: 12,
|
||||||
|
ButtonKey.KEY_3: 13,
|
||||||
|
ButtonKey.KEY_4: 14,
|
||||||
|
ButtonKey.KEY_5: 15,
|
||||||
|
ButtonKey.KEY_6: 6,
|
||||||
|
ButtonKey.KEY_7: 7,
|
||||||
|
ButtonKey.KEY_8: 8,
|
||||||
|
ButtonKey.KEY_9: 9,
|
||||||
|
ButtonKey.KEY_10: 10,
|
||||||
|
ButtonKey.KEY_11: 1,
|
||||||
|
ButtonKey.KEY_12: 2,
|
||||||
|
ButtonKey.KEY_13: 3,
|
||||||
|
ButtonKey.KEY_14: 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Reverse mapping: hardware key -> logical key (for event decoding)
|
||||||
|
_HW_TO_LOGICAL_KEY = {v: k for k, v in _IMAGE_KEY_MAP.items()}
|
||||||
|
|
||||||
|
def __init__(self, transport1, devInfo):
|
||||||
|
super().__init__(transport1, devInfo)
|
||||||
|
|
||||||
|
def get_image_key(self, logical_key: ButtonKey) -> int:
|
||||||
|
"""
|
||||||
|
Convert logical key value to hardware key value (for setting images)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
logical_key: Logical key enum
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: Hardware key value
|
||||||
|
"""
|
||||||
|
if logical_key in self._IMAGE_KEY_MAP:
|
||||||
|
return self._IMAGE_KEY_MAP[logical_key]
|
||||||
|
raise ValueError(f"StreamDockN4: Unsupported key {logical_key}")
|
||||||
|
|
||||||
|
def decode_input_event(self, hardware_code: int, state: int) -> InputEvent:
|
||||||
|
"""
|
||||||
|
Decode hardware event codes into a unified InputEvent
|
||||||
|
|
||||||
|
The N4 device supports only regular buttons; hardware code range 1-15
|
||||||
|
"""
|
||||||
|
# Handle state value: 0x02=release, 0x01=press
|
||||||
|
normalized_state = 1 if state == 0x01 else 0
|
||||||
|
|
||||||
|
# Regular button events (1-14)
|
||||||
|
if hardware_code in self._HW_TO_LOGICAL_KEY:
|
||||||
|
return InputEvent(
|
||||||
|
event_type=EventType.BUTTON,
|
||||||
|
key=self._HW_TO_LOGICAL_KEY[hardware_code],
|
||||||
|
state=normalized_state
|
||||||
|
)
|
||||||
|
|
||||||
|
# Unknown event
|
||||||
|
return InputEvent(event_type=EventType.UNKNOWN)
|
||||||
|
|
||||||
|
# Set device screen brightness
|
||||||
|
def set_brightness(self, percent):
|
||||||
|
return self.transport.setBrightness(percent)
|
||||||
|
|
||||||
|
# Set device background image 800 * 480
|
||||||
|
def set_touchscreen_image(self, path):
|
||||||
|
try:
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print(f"Error: The image file '{path}' does not exist.")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
# open formatter
|
||||||
|
image = Image.open(path)
|
||||||
|
image = to_native_touchscreen_format(self, image)
|
||||||
|
temp_image_path = "rotated_touchscreen_image_" + str(random.randint(9999, 999999)) + ".jpg"
|
||||||
|
image.save(temp_image_path)
|
||||||
|
|
||||||
|
# encode send
|
||||||
|
path_bytes = temp_image_path.encode('utf-8')
|
||||||
|
c_path = ctypes.c_char_p(path_bytes)
|
||||||
|
res = self.transport.setBackgroundImgDualDevice(c_path)
|
||||||
|
os.remove(temp_image_path)
|
||||||
|
return res
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
# Set device key icon image 112 * 112
|
||||||
|
def set_key_image(self, key, path):
|
||||||
|
try:
|
||||||
|
if isinstance(key, int):
|
||||||
|
if key not in range(1, 15):
|
||||||
|
print(f"key '{key}' out of range. you should set (1 ~ 14)")
|
||||||
|
return -1
|
||||||
|
logical_key = ButtonKey(key)
|
||||||
|
else:
|
||||||
|
logical_key = key
|
||||||
|
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print(f"Error: The image file '{path}' does not exist.")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
# Secondary screen keys 11-14
|
||||||
|
if logical_key.value in range(11, 15):
|
||||||
|
return self.set_seondscreen_image(logical_key.value, path)
|
||||||
|
|
||||||
|
# Get hardware key value
|
||||||
|
hardware_key = self.get_image_key(logical_key)
|
||||||
|
|
||||||
|
# open formatter
|
||||||
|
image = Image.open(path)
|
||||||
|
image = to_native_key_format(self, image)
|
||||||
|
temp_image_path = "rotated_key_image_" + str(random.randint(9999, 999999)) + ".jpg"
|
||||||
|
image.save(temp_image_path)
|
||||||
|
|
||||||
|
# encode send
|
||||||
|
path_bytes = temp_image_path.encode('utf-8')
|
||||||
|
c_path = ctypes.c_char_p(path_bytes)
|
||||||
|
res = self.transport.setKeyImgDualDevice(c_path, hardware_key)
|
||||||
|
os.remove(temp_image_path)
|
||||||
|
return res
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
# Set device secondary screen key icon image 176 * 112
|
||||||
|
def set_seondscreen_image(self, key, path):
|
||||||
|
try:
|
||||||
|
if key not in range(11, 15):
|
||||||
|
print(f"key '{key}' out of range. you should set (11 ~ 14)")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
logical_key = ButtonKey(key)
|
||||||
|
hardware_key = self.get_image_key(logical_key)
|
||||||
|
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print(f"Error: The image file '{path}' does not exist.")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
# open formatter
|
||||||
|
image = Image.open(path)
|
||||||
|
image = to_native_seondscreen_format(self, image)
|
||||||
|
temp_image_path = "rotated_key_image_" + str(random.randint(9999, 999999)) + ".jpg"
|
||||||
|
image.save(temp_image_path)
|
||||||
|
|
||||||
|
# encode send
|
||||||
|
path_bytes = temp_image_path.encode('utf-8')
|
||||||
|
c_path = ctypes.c_char_p(path_bytes)
|
||||||
|
res = self.transport.setKeyImgDualDevice(c_path, hardware_key)
|
||||||
|
os.remove(temp_image_path)
|
||||||
|
return res
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
# TODO
|
||||||
|
def set_key_imageData(self, key, path):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Get device firmware version
|
||||||
|
def get_serial_number(self):
|
||||||
|
return self.serial_number
|
||||||
|
|
||||||
|
def key_image_format(self):
|
||||||
|
return {
|
||||||
|
'size': (112, 112),
|
||||||
|
'format': "JPEG",
|
||||||
|
'rotation': 180,
|
||||||
|
'flip': (False, False)
|
||||||
|
}
|
||||||
|
|
||||||
|
def secondscreen_image_format(self):
|
||||||
|
return {
|
||||||
|
'size': (176, 112),
|
||||||
|
'format': "JPEG",
|
||||||
|
'rotation': 180,
|
||||||
|
'flip': (False, False)
|
||||||
|
}
|
||||||
|
|
||||||
|
def touchscreen_image_format(self):
|
||||||
|
return {
|
||||||
|
'size': (800, 480),
|
||||||
|
'format': "JPEG",
|
||||||
|
'rotation': 180,
|
||||||
|
'flip': (False, False)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Set device parameters
|
||||||
|
def set_device(self):
|
||||||
|
self.transport.set_report_size(513, 1025, 0)
|
||||||
|
self.feature_option.deviceType = device_type.dock_n4
|
||||||
|
pass
|
||||||
306
StreamDock/Devices/StreamDockN4Pro.py
Normal file
306
StreamDock/Devices/StreamDockN4Pro.py
Normal file
@ -0,0 +1,306 @@
|
|||||||
|
from StreamDock.FeatrueOption import device_type
|
||||||
|
from .StreamDock import StreamDock
|
||||||
|
from ..InputTypes import InputEvent, ButtonKey, EventType, KnobId, Direction
|
||||||
|
from PIL import Image
|
||||||
|
import ctypes
|
||||||
|
import ctypes.util
|
||||||
|
import os, io
|
||||||
|
from ..ImageHelpers.PILHelper import *
|
||||||
|
import random
|
||||||
|
|
||||||
|
|
||||||
|
class StreamDockN4Pro(StreamDock):
|
||||||
|
"""StreamDockN4Pro device class - supports 15 keys, 4 knobs, and swipe gestures"""
|
||||||
|
|
||||||
|
KEY_COUNT = 15
|
||||||
|
KEY_MAP = False
|
||||||
|
|
||||||
|
# Image key mapping: logical key -> hardware key (for setting images)
|
||||||
|
_IMAGE_KEY_MAP = {
|
||||||
|
# Main keys 1-10
|
||||||
|
ButtonKey.KEY_1: 11,
|
||||||
|
ButtonKey.KEY_2: 12,
|
||||||
|
ButtonKey.KEY_3: 13,
|
||||||
|
ButtonKey.KEY_4: 14,
|
||||||
|
ButtonKey.KEY_5: 15,
|
||||||
|
ButtonKey.KEY_6: 6,
|
||||||
|
ButtonKey.KEY_7: 7,
|
||||||
|
ButtonKey.KEY_8: 8,
|
||||||
|
ButtonKey.KEY_9: 9,
|
||||||
|
ButtonKey.KEY_10: 10,
|
||||||
|
# Secondary screen keys 11-14 (176x112)
|
||||||
|
ButtonKey.KEY_11: 1,
|
||||||
|
ButtonKey.KEY_12: 2,
|
||||||
|
ButtonKey.KEY_13: 3,
|
||||||
|
ButtonKey.KEY_14: 4,
|
||||||
|
ButtonKey.KEY_15: 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Reverse mapping: hardware key -> logical key (for event decoding)
|
||||||
|
_HW_TO_LOGICAL_KEY = {v: k for k, v in _IMAGE_KEY_MAP.items()}
|
||||||
|
|
||||||
|
def __init__(self, transport1, devInfo):
|
||||||
|
super().__init__(transport1, devInfo)
|
||||||
|
|
||||||
|
def get_image_key(self, logical_key: ButtonKey) -> int:
|
||||||
|
"""
|
||||||
|
Convert logical key value to hardware key value (for setting images)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
logical_key: Logical key enum
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: Hardware key value
|
||||||
|
"""
|
||||||
|
if logical_key in self._IMAGE_KEY_MAP:
|
||||||
|
return self._IMAGE_KEY_MAP[logical_key]
|
||||||
|
raise ValueError(f"StreamDockN4Pro: Unsupported key {logical_key}")
|
||||||
|
|
||||||
|
def decode_input_event(self, hardware_code: int, state: int) -> InputEvent:
|
||||||
|
"""
|
||||||
|
Decode hardware event codes into a unified InputEvent
|
||||||
|
|
||||||
|
Hardware code mapping:
|
||||||
|
- Keys: 1-15
|
||||||
|
- Secondary screen keys: 0x40-0x43
|
||||||
|
- Knob rotation: 0xA0, 0xA1(Knob1), 0x50, 0x51(Knob2), 0x90, 0x91(Knob3), 0x70, 0x71(Knob4)
|
||||||
|
- Knob press: 0x37(Knob1), 0x35(Knob2), 0x33(Knob3), 0x36(Knob4)
|
||||||
|
- Swipe: 0x38 (left), 0x39 (right)
|
||||||
|
"""
|
||||||
|
# Handle state value: 0x02=release, 0x01=press
|
||||||
|
normalized_state = 1 if state == 0x01 else 0
|
||||||
|
|
||||||
|
# Regular button events (1-15)
|
||||||
|
if hardware_code in self._HW_TO_LOGICAL_KEY:
|
||||||
|
return InputEvent(
|
||||||
|
event_type=EventType.BUTTON,
|
||||||
|
key=self._HW_TO_LOGICAL_KEY[hardware_code],
|
||||||
|
state=normalized_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Secondary screen key events
|
||||||
|
secondary_key_map = {
|
||||||
|
0x40: ButtonKey.KEY_11,
|
||||||
|
0x41: ButtonKey.KEY_12,
|
||||||
|
0x42: ButtonKey.KEY_13,
|
||||||
|
0x43: ButtonKey.KEY_14,
|
||||||
|
}
|
||||||
|
if hardware_code in secondary_key_map:
|
||||||
|
return InputEvent(
|
||||||
|
event_type=EventType.BUTTON,
|
||||||
|
key=secondary_key_map[hardware_code],
|
||||||
|
state=normalized_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Knob rotation event
|
||||||
|
knob_rotate_map = {
|
||||||
|
0xA0: (KnobId.KNOB_1, Direction.LEFT),
|
||||||
|
0xA1: (KnobId.KNOB_1, Direction.RIGHT),
|
||||||
|
0x50: (KnobId.KNOB_2, Direction.LEFT),
|
||||||
|
0x51: (KnobId.KNOB_2, Direction.RIGHT),
|
||||||
|
0x90: (KnobId.KNOB_3, Direction.LEFT),
|
||||||
|
0x91: (KnobId.KNOB_3, Direction.RIGHT),
|
||||||
|
0x70: (KnobId.KNOB_4, Direction.LEFT),
|
||||||
|
0x71: (KnobId.KNOB_4, Direction.RIGHT),
|
||||||
|
}
|
||||||
|
if hardware_code in knob_rotate_map:
|
||||||
|
knob_id, direction = knob_rotate_map[hardware_code]
|
||||||
|
return InputEvent(
|
||||||
|
event_type=EventType.KNOB_ROTATE, knob_id=knob_id, direction=direction
|
||||||
|
)
|
||||||
|
|
||||||
|
# Knob press event
|
||||||
|
knob_press_map = {
|
||||||
|
0x37: KnobId.KNOB_1,
|
||||||
|
0x35: KnobId.KNOB_2,
|
||||||
|
0x33: KnobId.KNOB_3,
|
||||||
|
0x36: KnobId.KNOB_4,
|
||||||
|
}
|
||||||
|
if hardware_code in knob_press_map:
|
||||||
|
return InputEvent(
|
||||||
|
event_type=EventType.KNOB_PRESS,
|
||||||
|
knob_id=knob_press_map[hardware_code],
|
||||||
|
state=normalized_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Swipe gesture
|
||||||
|
if hardware_code == 0x38:
|
||||||
|
return InputEvent(event_type=EventType.SWIPE, direction=Direction.LEFT)
|
||||||
|
if hardware_code == 0x39:
|
||||||
|
return InputEvent(event_type=EventType.SWIPE, direction=Direction.RIGHT)
|
||||||
|
|
||||||
|
# Unknown event
|
||||||
|
return InputEvent(event_type=EventType.UNKNOWN)
|
||||||
|
|
||||||
|
# Set device screen brightness
|
||||||
|
def set_brightness(self, percent):
|
||||||
|
return self.transport.setBrightness(percent)
|
||||||
|
|
||||||
|
# Set device background image 800 * 480
|
||||||
|
def set_touchscreen_image(self, path):
|
||||||
|
try:
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print(f"Error: The image file '{path}' does not exist.")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
image = Image.open(path)
|
||||||
|
image = to_native_touchscreen_format(self, image)
|
||||||
|
temp_image_path = (
|
||||||
|
"rotated_touchscreen_image_"
|
||||||
|
+ str(random.randint(9999, 999999))
|
||||||
|
+ ".jpg"
|
||||||
|
)
|
||||||
|
image.save(temp_image_path)
|
||||||
|
|
||||||
|
# encode send
|
||||||
|
path_bytes = temp_image_path.encode("utf-8")
|
||||||
|
c_path = ctypes.c_char_p(path_bytes)
|
||||||
|
res = self.transport.setBackgroundImgDualDevice(c_path)
|
||||||
|
os.remove(temp_image_path)
|
||||||
|
return res
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
return -1
|
||||||
|
def set_frame_background(self, path):
|
||||||
|
try:
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print(f"Error: The image file '{path}' does not exist.")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
image = Image.open(path)
|
||||||
|
image = to_native_touchscreen_format(self, image)
|
||||||
|
temp_image_path = (
|
||||||
|
"rotated_touchscreen_image_"
|
||||||
|
+ str(random.randint(9999, 999999))
|
||||||
|
+ ".jpg"
|
||||||
|
)
|
||||||
|
image.save(temp_image_path, quality=80)
|
||||||
|
|
||||||
|
# encode send
|
||||||
|
path_bytes = temp_image_path.encode("utf-8")
|
||||||
|
c_path = ctypes.c_char_p(path_bytes)
|
||||||
|
res = self.transport.setBackgroundImgFrame(
|
||||||
|
c_path,
|
||||||
|
800,
|
||||||
|
480,
|
||||||
|
)
|
||||||
|
os.remove(temp_image_path)
|
||||||
|
return res
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
# Set device key icon image 112 * 112
|
||||||
|
def set_key_image(self, key, path):
|
||||||
|
try:
|
||||||
|
if isinstance(key, int):
|
||||||
|
if key not in range(1, 16):
|
||||||
|
print(f"key '{key}' out of range. you should set (1 ~ 15)")
|
||||||
|
return -1
|
||||||
|
logical_key = ButtonKey(key)
|
||||||
|
else:
|
||||||
|
logical_key = key
|
||||||
|
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print(f"Error: The image file '{path}' does not exist.")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
# Secondary screen keys use a different image format
|
||||||
|
if logical_key.value in range(11, 15):
|
||||||
|
return self.set_seondscreen_image(logical_key.value, path)
|
||||||
|
|
||||||
|
# Get hardware key value
|
||||||
|
hardware_key = self.get_image_key(logical_key)
|
||||||
|
|
||||||
|
# open formatter
|
||||||
|
image = Image.open(path)
|
||||||
|
image = to_native_key_format(self, image)
|
||||||
|
temp_image_path = (
|
||||||
|
"rotated_key_image_" + str(random.randint(9999, 999999)) + ".jpg"
|
||||||
|
)
|
||||||
|
image.save(temp_image_path)
|
||||||
|
|
||||||
|
# encode send
|
||||||
|
path_bytes = temp_image_path.encode("utf-8")
|
||||||
|
c_path = ctypes.c_char_p(path_bytes)
|
||||||
|
res = self.transport.setKeyImgDualDevice(c_path, hardware_key)
|
||||||
|
os.remove(temp_image_path)
|
||||||
|
return res
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
# Set device secondary screen key icon image 176 * 112
|
||||||
|
def set_seondscreen_image(self, key, path):
|
||||||
|
try:
|
||||||
|
if key not in range(11, 15):
|
||||||
|
print(f"key '{key}' out of range. you should set (11 ~ 14)")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
logical_key = ButtonKey(key)
|
||||||
|
hardware_key = self.get_image_key(logical_key)
|
||||||
|
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print(f"Error: The image file '{path}' does not exist.")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
# open formatter
|
||||||
|
image = Image.open(path)
|
||||||
|
image = to_native_seondscreen_format(self, image)
|
||||||
|
temp_image_path = (
|
||||||
|
"rotated_key_image_" + str(random.randint(9999, 999999)) + ".jpg"
|
||||||
|
)
|
||||||
|
image.save(temp_image_path)
|
||||||
|
|
||||||
|
# encode send
|
||||||
|
path_bytes = temp_image_path.encode("utf-8")
|
||||||
|
c_path = ctypes.c_char_p(path_bytes)
|
||||||
|
res = self.transport.setKeyImgDualDevice(c_path, hardware_key)
|
||||||
|
os.remove(temp_image_path)
|
||||||
|
return res
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
# TODO
|
||||||
|
def set_key_imageData(self, key, path):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Get device serial number
|
||||||
|
def get_serial_number(self):
|
||||||
|
return self.serial_number
|
||||||
|
|
||||||
|
def key_image_format(self):
|
||||||
|
return {
|
||||||
|
"size": (112, 112),
|
||||||
|
"format": "JPEG",
|
||||||
|
"rotation": 180,
|
||||||
|
"flip": (False, False),
|
||||||
|
}
|
||||||
|
|
||||||
|
def secondscreen_image_format(self):
|
||||||
|
return {
|
||||||
|
"size": (176, 112),
|
||||||
|
"format": "JPEG",
|
||||||
|
"rotation": 180,
|
||||||
|
"flip": (False, False),
|
||||||
|
}
|
||||||
|
|
||||||
|
def touchscreen_image_format(self):
|
||||||
|
return {
|
||||||
|
"size": (800, 480),
|
||||||
|
"format": "JPEG",
|
||||||
|
"rotation": 180,
|
||||||
|
"flip": (False, False),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Set device parameters
|
||||||
|
def set_device(self):
|
||||||
|
self.transport.set_report_size(513, 1025, 0)
|
||||||
|
self.feature_option.hasRGBLed = True
|
||||||
|
self.feature_option.ledCounts = 4
|
||||||
|
self.feature_option.deviceType = device_type.dock_n4pro
|
||||||
|
pass
|
||||||
242
StreamDock/Devices/StreamDockXL.py
Normal file
242
StreamDock/Devices/StreamDockXL.py
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
from StreamDock.FeatrueOption import device_type
|
||||||
|
from .StreamDock import StreamDock
|
||||||
|
from ..InputTypes import InputEvent, ButtonKey, EventType, KnobId, Direction
|
||||||
|
from PIL import Image
|
||||||
|
import ctypes
|
||||||
|
import ctypes.util
|
||||||
|
import os, io
|
||||||
|
from ..ImageHelpers.PILHelper import *
|
||||||
|
import random
|
||||||
|
|
||||||
|
|
||||||
|
class StreamDockXL(StreamDock):
|
||||||
|
"""StreamDockXL device class - supports 36 inputs (32 keys + 2 knobs)"""
|
||||||
|
|
||||||
|
KEY_COUNT = 36
|
||||||
|
KEY_MAP = False
|
||||||
|
|
||||||
|
# Image key mapping: logical key -> hardware key (for setting images)
|
||||||
|
_IMAGE_KEY_MAP = {
|
||||||
|
ButtonKey.KEY_1: 25,
|
||||||
|
ButtonKey.KEY_2: 26,
|
||||||
|
ButtonKey.KEY_3: 27,
|
||||||
|
ButtonKey.KEY_4: 28,
|
||||||
|
ButtonKey.KEY_5: 29,
|
||||||
|
ButtonKey.KEY_6: 30,
|
||||||
|
ButtonKey.KEY_7: 31,
|
||||||
|
ButtonKey.KEY_8: 32,
|
||||||
|
ButtonKey.KEY_9: 17,
|
||||||
|
ButtonKey.KEY_10: 18,
|
||||||
|
ButtonKey.KEY_11: 19,
|
||||||
|
ButtonKey.KEY_12: 20,
|
||||||
|
ButtonKey.KEY_13: 21,
|
||||||
|
ButtonKey.KEY_14: 22,
|
||||||
|
ButtonKey.KEY_15: 23,
|
||||||
|
ButtonKey.KEY_16: 24,
|
||||||
|
ButtonKey.KEY_17: 9,
|
||||||
|
ButtonKey.KEY_18: 10,
|
||||||
|
ButtonKey.KEY_19: 11,
|
||||||
|
ButtonKey.KEY_20: 12,
|
||||||
|
ButtonKey.KEY_21: 13,
|
||||||
|
ButtonKey.KEY_22: 14,
|
||||||
|
ButtonKey.KEY_23: 15,
|
||||||
|
ButtonKey.KEY_24: 16,
|
||||||
|
ButtonKey.KEY_25: 1,
|
||||||
|
ButtonKey.KEY_26: 2,
|
||||||
|
ButtonKey.KEY_27: 3,
|
||||||
|
ButtonKey.KEY_28: 4,
|
||||||
|
ButtonKey.KEY_29: 5,
|
||||||
|
ButtonKey.KEY_30: 6,
|
||||||
|
ButtonKey.KEY_31: 7,
|
||||||
|
ButtonKey.KEY_32: 8,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Reverse mapping: hardware key -> logical key (for event decoding)
|
||||||
|
_HW_TO_LOGICAL_KEY = {v: k for k, v in _IMAGE_KEY_MAP.items()}
|
||||||
|
|
||||||
|
def __init__(self, transport1, devInfo):
|
||||||
|
super().__init__(transport1, devInfo)
|
||||||
|
|
||||||
|
def get_image_key(self, logical_key: ButtonKey) -> int:
|
||||||
|
"""
|
||||||
|
Convert logical key value to hardware key value (for setting images)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
logical_key: Logical key enum
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: Hardware key value
|
||||||
|
"""
|
||||||
|
if logical_key in self._IMAGE_KEY_MAP:
|
||||||
|
return self._IMAGE_KEY_MAP[logical_key]
|
||||||
|
raise ValueError(f"StreamDockXL: Unsupported key {logical_key}")
|
||||||
|
|
||||||
|
def decode_input_event(self, hardware_code: int, state: int) -> InputEvent:
|
||||||
|
"""
|
||||||
|
Decode hardware event codes into a unified InputEvent
|
||||||
|
|
||||||
|
XL supports regular button and knob events:
|
||||||
|
- Regular buttons 1-32: hardware codes 0x19-0x08
|
||||||
|
- Left knob up/down 33-34: hardware codes 0x21 (up), 0x23 (down)
|
||||||
|
- Right knob up/down 35-36: hardware codes 0x24 (up), 0x26 (down)
|
||||||
|
"""
|
||||||
|
|
||||||
|
knob_rotate_map = {
|
||||||
|
0x23: (KnobId.KNOB_1, Direction.LEFT),
|
||||||
|
0x21: (KnobId.KNOB_1, Direction.RIGHT),
|
||||||
|
0x24: (KnobId.KNOB_2, Direction.LEFT),
|
||||||
|
0x26: (KnobId.KNOB_2, Direction.RIGHT),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Knob rotation event
|
||||||
|
if hardware_code in knob_rotate_map:
|
||||||
|
knob_id, direction = knob_rotate_map[hardware_code]
|
||||||
|
return InputEvent(
|
||||||
|
event_type=EventType.KNOB_ROTATE, knob_id=knob_id, direction=direction
|
||||||
|
)
|
||||||
|
# Handle state value: 0x02=release, 0x01=press
|
||||||
|
normalized_state = 1 if state == 0x01 else 0
|
||||||
|
|
||||||
|
# Regular button events (1-32)
|
||||||
|
if hardware_code in self._HW_TO_LOGICAL_KEY:
|
||||||
|
return InputEvent(
|
||||||
|
event_type=EventType.BUTTON,
|
||||||
|
key=self._HW_TO_LOGICAL_KEY[hardware_code],
|
||||||
|
state=normalized_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Unknown event
|
||||||
|
return InputEvent(event_type=EventType.UNKNOWN)
|
||||||
|
|
||||||
|
def set_frame_background(self, path):
|
||||||
|
try:
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print(f"Error: The image file '{path}' does not exist.")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
image = Image.open(path)
|
||||||
|
image = to_native_touchscreen_format(self, image)
|
||||||
|
temp_image_path = (
|
||||||
|
"rotated_touchscreen_image_"
|
||||||
|
+ str(random.randint(9999, 999999))
|
||||||
|
+ ".jpg"
|
||||||
|
)
|
||||||
|
image.save(temp_image_path, quality=80)
|
||||||
|
|
||||||
|
# encode send
|
||||||
|
path_bytes = temp_image_path.encode("utf-8")
|
||||||
|
c_path = ctypes.c_char_p(path_bytes)
|
||||||
|
res = self.transport.setBackgroundImgFrame(
|
||||||
|
c_path,
|
||||||
|
1024,
|
||||||
|
600,
|
||||||
|
)
|
||||||
|
os.remove(temp_image_path)
|
||||||
|
return res
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
return -1
|
||||||
|
# Set device screen brightness
|
||||||
|
def set_brightness(self, percent):
|
||||||
|
return self.transport.setBrightness(percent)
|
||||||
|
|
||||||
|
# Set device background image 1024 * 600
|
||||||
|
def set_touchscreen_image(self, path):
|
||||||
|
try:
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print(f"Error: The image file '{path}' does not exist.")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
# open formatter
|
||||||
|
image = Image.open(path)
|
||||||
|
image = to_native_touchscreen_format(self, image)
|
||||||
|
temp_image_path = (
|
||||||
|
"rotated_touchscreen_image_"
|
||||||
|
+ str(random.randint(9999, 999999))
|
||||||
|
+ ".jpg"
|
||||||
|
)
|
||||||
|
image.save(temp_image_path)
|
||||||
|
|
||||||
|
# encode send
|
||||||
|
path_bytes = temp_image_path.encode("utf-8")
|
||||||
|
c_path = ctypes.c_char_p(path_bytes)
|
||||||
|
res = self.transport.setBackgroundImgDualDevice(c_path)
|
||||||
|
os.remove(temp_image_path)
|
||||||
|
return res
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
# Set device key icon image 80 * 80
|
||||||
|
def set_key_image(self, key, path):
|
||||||
|
try:
|
||||||
|
if isinstance(key, int):
|
||||||
|
if key not in range(1, 33):
|
||||||
|
print(f"key '{key}' out of range. you should set (1 ~ 32)")
|
||||||
|
return -1
|
||||||
|
logical_key = ButtonKey(key)
|
||||||
|
else:
|
||||||
|
logical_key = key
|
||||||
|
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print(f"Error: The image file '{path}' does not exist.")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
# Get hardware key value
|
||||||
|
hardware_key = self.get_image_key(logical_key)
|
||||||
|
|
||||||
|
# XL supports setting icons only for keys 1-32 (knob events do not require icons)
|
||||||
|
if hardware_key not in range(1, 33):
|
||||||
|
return -1
|
||||||
|
|
||||||
|
# open formatter
|
||||||
|
image = Image.open(path)
|
||||||
|
image = to_native_key_format(self, image)
|
||||||
|
temp_image_path = (
|
||||||
|
"rotated_key_image_" + str(random.randint(9999, 999999)) + ".jpg"
|
||||||
|
)
|
||||||
|
image.save(temp_image_path)
|
||||||
|
|
||||||
|
# encode send
|
||||||
|
path_bytes = temp_image_path.encode("utf-8")
|
||||||
|
c_path = ctypes.c_char_p(path_bytes)
|
||||||
|
res = self.transport.setKeyImgDualDevice(c_path, hardware_key)
|
||||||
|
os.remove(temp_image_path)
|
||||||
|
return res
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
# TODO
|
||||||
|
def set_key_imageData(self, key, path):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Get device firmware version
|
||||||
|
def get_serial_number(self):
|
||||||
|
return self.serial_number
|
||||||
|
|
||||||
|
def key_image_format(self):
|
||||||
|
return {
|
||||||
|
"size": (80, 80),
|
||||||
|
"format": "JPEG",
|
||||||
|
"rotation": 180,
|
||||||
|
"flip": (False, False),
|
||||||
|
}
|
||||||
|
|
||||||
|
def touchscreen_image_format(self):
|
||||||
|
return {
|
||||||
|
"size": (1024, 600),
|
||||||
|
"format": "JPEG",
|
||||||
|
"rotation": 180,
|
||||||
|
"flip": (False, False),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Set device parameters
|
||||||
|
def set_device(self):
|
||||||
|
self.transport.set_report_size(513, 1025, 0)
|
||||||
|
self.feature_option.hasRGBLed = True
|
||||||
|
self.feature_option.ledCounts = 6
|
||||||
|
self.feature_option.deviceType = device_type.dock_xl
|
||||||
|
pass
|
||||||
0
StreamDock/Devices/__init__.py
Normal file
0
StreamDock/Devices/__init__.py
Normal file
24
StreamDock/FeatrueOption.py
Normal file
24
StreamDock/FeatrueOption.py
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
class device_type(Enum):
|
||||||
|
dock_universal = 0
|
||||||
|
dock_293 = 1
|
||||||
|
dock_293v3=2
|
||||||
|
dock_293s=3
|
||||||
|
dock_293sv3=4
|
||||||
|
dock_m3=5
|
||||||
|
dock_m18=6
|
||||||
|
dock_n1=7
|
||||||
|
dock_n3=8
|
||||||
|
dock_n4=9
|
||||||
|
dock_n4pro=10
|
||||||
|
dock_xl=11
|
||||||
|
k1pro=12
|
||||||
|
|
||||||
|
class FeatrueOption:
|
||||||
|
def __init__(self):
|
||||||
|
self.hasRGBLed = False
|
||||||
|
self.ledCounts = 0
|
||||||
|
self.supportConfig = False
|
||||||
|
self.supportBackgroundImage = True
|
||||||
|
self.deviceType = device_type.dock_universal
|
||||||
89
StreamDock/ImageHelpers/PILHelper.py
Normal file
89
StreamDock/ImageHelpers/PILHelper.py
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import io
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
|
||||||
|
def _create_image(image_format, background):
|
||||||
|
return Image.new("RGB", image_format['size'], background)
|
||||||
|
|
||||||
|
|
||||||
|
def _scale_image(image, image_format, margins=[0, 0, 0, 0], background='black'):
|
||||||
|
if len(margins) != 4:
|
||||||
|
raise ValueError("Margins should be given as an array of four integers.")
|
||||||
|
|
||||||
|
final_image = _create_image(image_format, background=background)
|
||||||
|
|
||||||
|
thumbnail_max_width = final_image.width - (margins[1] + margins[3])
|
||||||
|
thumbnail_max_height = final_image.height - (margins[0] + margins[2])
|
||||||
|
|
||||||
|
thumbnail = image.convert("RGBA")
|
||||||
|
thumbnail.thumbnail((thumbnail_max_width, thumbnail_max_height), Image.LANCZOS)
|
||||||
|
|
||||||
|
thumbnail_x = (margins[3] + (thumbnail_max_width - thumbnail.width) // 2)
|
||||||
|
thumbnail_y = (margins[0] + (thumbnail_max_height - thumbnail.height) // 2)
|
||||||
|
|
||||||
|
final_image.paste(thumbnail, (thumbnail_x, thumbnail_y), thumbnail)
|
||||||
|
|
||||||
|
return final_image
|
||||||
|
|
||||||
|
|
||||||
|
def _to_native_format(image, image_format):
|
||||||
|
if image_format["format"].lower() != "jpeg" and image_format["format"].lower() != "jpg":
|
||||||
|
raise ValueError(f"no support format: {image_format['format']}. only 'jpeg' or 'jpg' is supported")
|
||||||
|
|
||||||
|
_expand = True
|
||||||
|
if image.size[1] == image_format["size"][0] and image.size[0] == image_format["size"][1]:
|
||||||
|
_expand = False
|
||||||
|
|
||||||
|
# must rotate the picture first then resize the picture
|
||||||
|
if image_format["rotation"] == 90 or image_format["rotation"] == -90:
|
||||||
|
swapped_tuple = (image_format["size"][1], image_format["size"][0])
|
||||||
|
image_format["size"] = swapped_tuple
|
||||||
|
|
||||||
|
if image_format['rotation']:
|
||||||
|
image = image.rotate(image_format['rotation'], expand = _expand)
|
||||||
|
|
||||||
|
if image.size != image_format['size']:
|
||||||
|
image = image.resize(image_format["size"])
|
||||||
|
|
||||||
|
if image_format['flip'][0]:
|
||||||
|
image = image.transpose(Image.FLIP_LEFT_RIGHT)
|
||||||
|
|
||||||
|
if image_format['flip'][1]:
|
||||||
|
image = image.transpose(Image.FLIP_TOP_BOTTOM)
|
||||||
|
|
||||||
|
image = image.convert('RGB')
|
||||||
|
|
||||||
|
return image
|
||||||
|
|
||||||
|
|
||||||
|
def create_image(dock, background='black'):
|
||||||
|
return create_key_image(dock, background)
|
||||||
|
|
||||||
|
|
||||||
|
def create_key_image(dock, background='black'):
|
||||||
|
return _create_image(dock.key_image_format(), background)
|
||||||
|
|
||||||
|
|
||||||
|
def create_touchscreen_image(dock, background='black'):
|
||||||
|
return _create_image(dock.touchscreen_image_format(), background)
|
||||||
|
|
||||||
|
|
||||||
|
def create_scaled_image(dock, image, margins=[0, 0, 0, 0], background='black'):
|
||||||
|
return create_scaled_key_image(dock, image, margins, background)
|
||||||
|
|
||||||
|
|
||||||
|
def create_scaled_key_image(dock, image, margins=[0, 0, 0, 0], background='black'):
|
||||||
|
return _scale_image(image, dock.key_image_format(), margins, background)
|
||||||
|
|
||||||
|
|
||||||
|
def create_scaled_touchscreen_image(dock, image, margins=[0, 0, 0, 0], background='black'):
|
||||||
|
return _scale_image(image, dock.touchscreen_image_format(), margins, background)
|
||||||
|
|
||||||
|
def to_native_key_format(dock, image):
|
||||||
|
return _to_native_format(image, dock.key_image_format())
|
||||||
|
|
||||||
|
def to_native_seondscreen_format(dock, image):
|
||||||
|
return _to_native_format(image, dock.secondscreen_image_format())
|
||||||
|
|
||||||
|
def to_native_touchscreen_format(dock, image):
|
||||||
|
return _to_native_format(image, dock.touchscreen_image_format())
|
||||||
0
StreamDock/ImageHelpers/__init__.py
Normal file
0
StreamDock/ImageHelpers/__init__.py
Normal file
109
StreamDock/InputTypes.py
Normal file
109
StreamDock/InputTypes.py
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
"""
|
||||||
|
StreamDock input event type system
|
||||||
|
|
||||||
|
Provides unified input event definitions, including buttons, knobs, and swipe gestures.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from enum import Enum, IntEnum
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class EventType(Enum):
|
||||||
|
"""Event type enum"""
|
||||||
|
BUTTON = "button" # Button press/release
|
||||||
|
KNOB_ROTATE = "knob_rotate" # Knob rotation
|
||||||
|
KNOB_PRESS = "knob_press" # Knob press
|
||||||
|
SWIPE = "swipe" # Swipe gesture
|
||||||
|
UNKNOWN = "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
class ButtonKey(IntEnum):
|
||||||
|
"""
|
||||||
|
Logical key values for regular buttons (used for setting images)
|
||||||
|
|
||||||
|
Devices can define their own key ranges, for example:
|
||||||
|
- Simple devices: KEY_1 ~ KEY_15
|
||||||
|
- XL devices: KEY_1 ~ KEY_32
|
||||||
|
"""
|
||||||
|
KEY_1 = 1
|
||||||
|
KEY_2 = 2
|
||||||
|
KEY_3 = 3
|
||||||
|
KEY_4 = 4
|
||||||
|
KEY_5 = 5
|
||||||
|
KEY_6 = 6
|
||||||
|
KEY_7 = 7
|
||||||
|
KEY_8 = 8
|
||||||
|
KEY_9 = 9
|
||||||
|
KEY_10 = 10
|
||||||
|
KEY_11 = 11
|
||||||
|
KEY_12 = 12
|
||||||
|
KEY_13 = 13
|
||||||
|
KEY_14 = 14
|
||||||
|
KEY_15 = 15
|
||||||
|
KEY_16 = 16
|
||||||
|
KEY_17 = 17
|
||||||
|
KEY_18 = 18
|
||||||
|
KEY_19 = 19
|
||||||
|
KEY_20 = 20
|
||||||
|
KEY_21 = 21
|
||||||
|
KEY_22 = 22
|
||||||
|
KEY_23 = 23
|
||||||
|
KEY_24 = 24
|
||||||
|
KEY_25 = 25
|
||||||
|
KEY_26 = 26
|
||||||
|
KEY_27 = 27
|
||||||
|
KEY_28 = 28
|
||||||
|
KEY_29 = 29
|
||||||
|
KEY_30 = 30
|
||||||
|
KEY_31 = 31
|
||||||
|
KEY_32 = 32
|
||||||
|
|
||||||
|
|
||||||
|
class KnobId(Enum):
|
||||||
|
"""Knob ID enum"""
|
||||||
|
KNOB_1 = "knob_1"
|
||||||
|
KNOB_2 = "knob_2"
|
||||||
|
KNOB_3 = "knob_3"
|
||||||
|
KNOB_4 = "knob_4"
|
||||||
|
|
||||||
|
|
||||||
|
class Direction(Enum):
|
||||||
|
"""Direction enum (for knob rotation and swipe gestures)"""
|
||||||
|
LEFT = "left"
|
||||||
|
RIGHT = "right"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class InputEvent:
|
||||||
|
"""
|
||||||
|
Unified input event class
|
||||||
|
|
||||||
|
All input events (buttons, knobs, swipes) are passed to callbacks through this class.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
event_type: Event type
|
||||||
|
key: Button event: which key
|
||||||
|
knob_id: Knob event: which knob
|
||||||
|
direction: Direction: knob rotation direction or swipe direction
|
||||||
|
state: State: 0=release, 1=press
|
||||||
|
"""
|
||||||
|
event_type: EventType
|
||||||
|
key: Optional[ButtonKey] = None # Button event: which key
|
||||||
|
knob_id: Optional[KnobId] = None # Knob event: which knob
|
||||||
|
direction: Optional[Direction] = None # Direction: knob rotation direction or swipe direction
|
||||||
|
state: int = 0 # State: 0=release, 1=press
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
"""Data validation"""
|
||||||
|
if self.event_type == EventType.BUTTON:
|
||||||
|
if self.key is None:
|
||||||
|
raise ValueError("BUTTON event requires key")
|
||||||
|
elif self.event_type in (EventType.KNOB_ROTATE, EventType.KNOB_PRESS):
|
||||||
|
if self.knob_id is None:
|
||||||
|
raise ValueError("KNOB event requires knob_id")
|
||||||
|
if self.event_type == EventType.KNOB_ROTATE and self.direction is None:
|
||||||
|
raise ValueError("KNOB_ROTATE event requires direction")
|
||||||
|
elif self.event_type == EventType.SWIPE:
|
||||||
|
if self.direction is None:
|
||||||
|
raise ValueError("SWIPE event requires direction")
|
||||||
129
StreamDock/ProductIDs.py
Normal file
129
StreamDock/ProductIDs.py
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
class USBVendorIDs:
|
||||||
|
"""
|
||||||
|
USB Vendor IDs for known StreamDock devices.
|
||||||
|
"""
|
||||||
|
|
||||||
|
USB_VID_293 = 0x5500
|
||||||
|
USB_VID_293V3 = 0x6603
|
||||||
|
USB_VID_293V3EN = 0x6603
|
||||||
|
USB_VID_293s = 0x5548
|
||||||
|
USB_VID_293sV3 = 0x6603
|
||||||
|
USB_VIDN3 = 0x6603
|
||||||
|
USB_VIDN3V2 = 0xEEEF
|
||||||
|
USB_VIDN3V25 = 0x1500
|
||||||
|
USB_VIDN3E = 0x6602
|
||||||
|
USB_VIDN4 = 0x6602
|
||||||
|
USB_VIDN4EN = 0x6603
|
||||||
|
USB_VIDN1EN = 0x6603
|
||||||
|
USB_VIDN1 = 0x6603
|
||||||
|
USB_VID_N4PRO = 0x5548
|
||||||
|
USB_VID_N4PROEN = 0x5548
|
||||||
|
USB_VID_XL = 0x5548
|
||||||
|
USB_VID_XLEN = 0x5548
|
||||||
|
USB_VID_M18 = 0x6603
|
||||||
|
USB_VID_M18EN = 0x6603
|
||||||
|
# USB_VID_M18V2 = 0x6603
|
||||||
|
# USB_VID_M18V2EN = 0x6603
|
||||||
|
# USB_VID_M18V25 = 0x6603
|
||||||
|
# USB_VID_M18V25EN = 0x6603
|
||||||
|
# USB_VID_M18V3 = 0x6603
|
||||||
|
# USB_VID_M18V3EN = 0x6603
|
||||||
|
USB_VID_M3 = 0x5548
|
||||||
|
USB_VID_K1_PRO = 0x6603
|
||||||
|
USB_VID_K1_PROEU = 0x6603
|
||||||
|
|
||||||
|
|
||||||
|
class USBProductIDs:
|
||||||
|
"""
|
||||||
|
USB Product IDs for known StreamDock devices.
|
||||||
|
"""
|
||||||
|
|
||||||
|
USB_PID_STREAMDOCK_293 = 0x1001
|
||||||
|
USB_PID_STREAMDOCK_293V3 = 0x1005
|
||||||
|
USB_PID_STREAMDOCK_293V3EN = 0x1006
|
||||||
|
USB_PID_STREAMDOCK_293V25 = 0x1010
|
||||||
|
USB_PID_STREAMDOCK_293s = 0x6670
|
||||||
|
USB_PID_STREAMDOCK_293sV3 = 0x1014
|
||||||
|
USB_PID_STREAMDOCK_N3 = 0x1002
|
||||||
|
USB_PID_STREAMDOCK_N3EN = 0x1003
|
||||||
|
USB_PID_STREAMDOCK_N3V2 = 0x2929
|
||||||
|
USB_PID_STREAMDOCK_N3V25 = 0x3001
|
||||||
|
USB_PID_STREAMDOCK_N4 = 0x1001
|
||||||
|
USB_PID_STREAMDOCK_N4EN = 0x1007
|
||||||
|
USB_PID_STREAMDOCK_N1EN = 0x1000
|
||||||
|
USB_PID_STREAMDOCK_N1 = 0x1011
|
||||||
|
USB_PID_STREAMDOCK_N4PRO = 0x1008
|
||||||
|
USB_PID_STREAMDOCK_N4PROEN = 0x1021
|
||||||
|
USB_PID_STREAMDOCK_VSD_N4PRO = 0x1023
|
||||||
|
USB_PID_STREAMDOCK_XL = 0x1028
|
||||||
|
USB_PID_STREAMDOCK_XLEN = 0x1031
|
||||||
|
USB_PID_STREAMDOCK_M18 = 0x1009
|
||||||
|
USB_PID_STREAMDOCK_M18EN = 0x1012
|
||||||
|
# USB_PID_STREAMDOCK_M18V2 = 0x1009
|
||||||
|
# USB_PID_STREAMDOCK_M18V2EN = 0x1012
|
||||||
|
# USB_PID_STREAMDOCK_M18V25 = 0x1009
|
||||||
|
# USB_PID_STREAMDOCK_M18V25EN = 0x1012
|
||||||
|
# USB_PID_STREAMDOCK_M18V3 = 0x1009
|
||||||
|
# USB_PID_STREAMDOCK_M18V3EN = 0x1012
|
||||||
|
USB_PID_STREAMDOCK_M3 = 0x1020
|
||||||
|
USB_PID_K1_PRO = 0x1015
|
||||||
|
USB_PID_K1_PROEU = 0x1019
|
||||||
|
|
||||||
|
|
||||||
|
from .Devices.StreamDock293 import StreamDock293
|
||||||
|
from .Devices.StreamDock293V3 import StreamDock293V3
|
||||||
|
from .Devices.StreamDock293s import StreamDock293s
|
||||||
|
from .Devices.StreamDock293sV3 import StreamDock293sV3
|
||||||
|
from .Devices.StreamDockN3 import StreamDockN3
|
||||||
|
from .Devices.StreamDockN4 import StreamDockN4
|
||||||
|
from .Devices.StreamDockN1 import StreamDockN1
|
||||||
|
from .Devices.StreamDockN4Pro import StreamDockN4Pro
|
||||||
|
from .Devices.StreamDockXL import StreamDockXL
|
||||||
|
from .Devices.StreamDockM18 import StreamDockM18
|
||||||
|
from .Devices.StreamDockM3 import StreamDockM3
|
||||||
|
from .Devices.K1Pro import K1Pro
|
||||||
|
|
||||||
|
g_products = [
|
||||||
|
# 293 serial
|
||||||
|
(USBVendorIDs.USB_VID_293, USBProductIDs.USB_PID_STREAMDOCK_293, StreamDock293),
|
||||||
|
(USBVendorIDs.USB_VID_293V3,USBProductIDs.USB_PID_STREAMDOCK_293V3,StreamDock293V3),
|
||||||
|
(USBVendorIDs.USB_VID_293V3EN,USBProductIDs.USB_PID_STREAMDOCK_293V3EN,StreamDock293V3),
|
||||||
|
(USBVendorIDs.USB_VID_293V3,USBProductIDs.USB_PID_STREAMDOCK_293V25,StreamDock293V3),
|
||||||
|
(USBVendorIDs.USB_VID_293s, USBProductIDs.USB_PID_STREAMDOCK_293s, StreamDock293s),
|
||||||
|
(USBVendorIDs.USB_VID_293sV3, USBProductIDs.USB_PID_STREAMDOCK_293sV3, StreamDock293sV3),
|
||||||
|
# N3
|
||||||
|
(USBVendorIDs.USB_VIDN3, USBProductIDs.USB_PID_STREAMDOCK_N3, StreamDockN3),
|
||||||
|
(USBVendorIDs.USB_VIDN3, USBProductIDs.USB_PID_STREAMDOCK_N3EN, StreamDockN3),
|
||||||
|
(USBVendorIDs.USB_VIDN3E, USBProductIDs.USB_PID_STREAMDOCK_N3, StreamDockN3),
|
||||||
|
(USBVendorIDs.USB_VIDN3E, USBProductIDs.USB_PID_STREAMDOCK_N3EN, StreamDockN3),
|
||||||
|
(USBVendorIDs.USB_VIDN3E, USBProductIDs.USB_PID_STREAMDOCK_N3V2, StreamDockN3),
|
||||||
|
(USBVendorIDs.USB_VIDN3V25, USBProductIDs.USB_PID_STREAMDOCK_N3V25, StreamDockN3),
|
||||||
|
# N4
|
||||||
|
(USBVendorIDs.USB_VIDN4, USBProductIDs.USB_PID_STREAMDOCK_N4, StreamDockN4),
|
||||||
|
(USBVendorIDs.USB_VIDN4EN, USBProductIDs.USB_PID_STREAMDOCK_N4EN, StreamDockN4),
|
||||||
|
# N1
|
||||||
|
(USBVendorIDs.USB_VIDN1, USBProductIDs.USB_PID_STREAMDOCK_N1, StreamDockN1),
|
||||||
|
(USBVendorIDs.USB_VIDN1EN, USBProductIDs.USB_PID_STREAMDOCK_N1EN, StreamDockN1),
|
||||||
|
# N4PRO
|
||||||
|
(USBVendorIDs.USB_VID_N4PRO, USBProductIDs.USB_PID_STREAMDOCK_N4PRO, StreamDockN4Pro),
|
||||||
|
(USBVendorIDs.USB_VID_N4PROEN, USBProductIDs.USB_PID_STREAMDOCK_N4PROEN, StreamDockN4Pro),
|
||||||
|
(USBVendorIDs.USB_VID_N4PRO, USBProductIDs.USB_PID_STREAMDOCK_VSD_N4PRO, StreamDockN4Pro),
|
||||||
|
# XL
|
||||||
|
(USBVendorIDs.USB_VID_XL, USBProductIDs.USB_PID_STREAMDOCK_XL, StreamDockXL),
|
||||||
|
(USBVendorIDs.USB_VID_XLEN, USBProductIDs.USB_PID_STREAMDOCK_XLEN, StreamDockXL),
|
||||||
|
# M18/M18V2/M18V25/M18V3
|
||||||
|
(USBVendorIDs.USB_VID_M18, USBProductIDs.USB_PID_STREAMDOCK_M18, StreamDockM18),
|
||||||
|
(USBVendorIDs.USB_VID_M18EN, USBProductIDs.USB_PID_STREAMDOCK_M18EN, StreamDockM18),
|
||||||
|
# (USBVendorIDs.USB_VID_M18V2, USBProductIDs.USB_PID_STREAMDOCK_M18V2, StreamDockM18),
|
||||||
|
# (USBVendorIDs.USB_VID_M18V2EN, USBProductIDs.USB_PID_STREAMDOCK_M18V2EN, StreamDockM18),
|
||||||
|
# (USBVendorIDs.USB_VID_M18V25, USBProductIDs.USB_PID_STREAMDOCK_M18V25, StreamDockM18),
|
||||||
|
# (USBVendorIDs.USB_VID_M18V25EN, USBProductIDs.USB_PID_STREAMDOCK_M18V25EN, StreamDockM18),
|
||||||
|
# (USBVendorIDs.USB_VID_M18V3, USBProductIDs.USB_PID_STREAMDOCK_M18V3, StreamDockM18),
|
||||||
|
# (USBVendorIDs.USB_VID_M18V3EN, USBProductIDs.USB_PID_STREAMDOCK_M18V3EN, StreamDockM18),
|
||||||
|
# M3
|
||||||
|
(USBVendorIDs.USB_VID_M3, USBProductIDs.USB_PID_STREAMDOCK_M3, StreamDockM3),
|
||||||
|
# K1 Pro
|
||||||
|
(USBVendorIDs.USB_VID_K1_PRO, USBProductIDs.USB_PID_K1_PRO, K1Pro),
|
||||||
|
(USBVendorIDs.USB_VID_K1_PROEU, USBProductIDs.USB_PID_K1_PROEU, K1Pro),
|
||||||
|
|
||||||
|
]
|
||||||
1398
StreamDock/Transport/LibUSBHIDAPI.py
Normal file
1398
StreamDock/Transport/LibUSBHIDAPI.py
Normal file
File diff suppressed because it is too large
Load Diff
BIN
StreamDock/Transport/TransportDLL/libtransport.so
Normal file
BIN
StreamDock/Transport/TransportDLL/libtransport.so
Normal file
Binary file not shown.
BIN
StreamDock/Transport/TransportDLL/libtransport_arm64.dylib
Normal file
BIN
StreamDock/Transport/TransportDLL/libtransport_arm64.dylib
Normal file
Binary file not shown.
BIN
StreamDock/Transport/TransportDLL/libtransport_arm64.so
Normal file
BIN
StreamDock/Transport/TransportDLL/libtransport_arm64.so
Normal file
Binary file not shown.
0
StreamDock/Transport/__init__.py
Normal file
0
StreamDock/Transport/__init__.py
Normal file
0
StreamDock/__init__.py
Normal file
0
StreamDock/__init__.py
Normal file
123
config.example.yaml
Normal file
123
config.example.yaml
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
homeassistant:
|
||||||
|
url: "ws://homeassistant.local:8123/api/websocket"
|
||||||
|
token: "YOUR_LONG_LIVED_ACCESS_TOKEN_HERE"
|
||||||
|
|
||||||
|
navigation:
|
||||||
|
page_cycle: "button_8"
|
||||||
|
knob_left: "knob_1"
|
||||||
|
knob_right: "knob_2"
|
||||||
|
knob_press_left: "knob_1"
|
||||||
|
knob_press_right: "knob_2"
|
||||||
|
|
||||||
|
pages:
|
||||||
|
- name: "Lights"
|
||||||
|
keys:
|
||||||
|
1:
|
||||||
|
entity: "light.living_room"
|
||||||
|
service: "toggle"
|
||||||
|
icon: "lightbulb"
|
||||||
|
label: "Living"
|
||||||
|
2:
|
||||||
|
entity: "light.kitchen"
|
||||||
|
service: "toggle"
|
||||||
|
icon: "lightbulb"
|
||||||
|
label: "Kitchen"
|
||||||
|
3:
|
||||||
|
entity: "light.bedroom"
|
||||||
|
service: "toggle"
|
||||||
|
icon: "lightbulb"
|
||||||
|
label: "Bedroom"
|
||||||
|
4:
|
||||||
|
entity: "light.bathroom"
|
||||||
|
service: "toggle"
|
||||||
|
icon: "lightbulb"
|
||||||
|
label: "Bath"
|
||||||
|
5:
|
||||||
|
entity: "light.hallway"
|
||||||
|
service: "toggle"
|
||||||
|
icon: "lightbulb"
|
||||||
|
label: "Hallway"
|
||||||
|
6:
|
||||||
|
entity: "light.office"
|
||||||
|
service: "toggle"
|
||||||
|
icon: "lightbulb"
|
||||||
|
label: "Office"
|
||||||
|
knobs:
|
||||||
|
knob_3:
|
||||||
|
entity: "light.all_lights"
|
||||||
|
service: "toggle"
|
||||||
|
icon: "lightbulb"
|
||||||
|
|
||||||
|
- name: "Climate"
|
||||||
|
keys:
|
||||||
|
1:
|
||||||
|
entity: "climate.living_room"
|
||||||
|
service: "toggle"
|
||||||
|
icon: "thermostat"
|
||||||
|
label: "LR HVAC"
|
||||||
|
2:
|
||||||
|
entity: "sensor.living_room_temperature"
|
||||||
|
icon: "thermometer"
|
||||||
|
label: "LR Temp"
|
||||||
|
3:
|
||||||
|
entity: "climate.bedroom"
|
||||||
|
service: "toggle"
|
||||||
|
icon: "thermostat"
|
||||||
|
label: "BR HVAC"
|
||||||
|
4:
|
||||||
|
entity: "sensor.bedroom_temperature"
|
||||||
|
icon: "thermometer"
|
||||||
|
label: "BR Temp"
|
||||||
|
5:
|
||||||
|
entity: "sensor.humidity"
|
||||||
|
icon: "thermometer"
|
||||||
|
label: "Humid"
|
||||||
|
6:
|
||||||
|
entity: "switch.heater"
|
||||||
|
service: "toggle"
|
||||||
|
icon: "power"
|
||||||
|
label: "Heater"
|
||||||
|
|
||||||
|
- name: "Media"
|
||||||
|
keys:
|
||||||
|
1:
|
||||||
|
entity: "media_player.living_room"
|
||||||
|
service: "toggle"
|
||||||
|
icon: "play"
|
||||||
|
label: "Living"
|
||||||
|
2:
|
||||||
|
entity: "media_player.bedroom"
|
||||||
|
service: "toggle"
|
||||||
|
icon: "play"
|
||||||
|
label: "Bedroom"
|
||||||
|
3:
|
||||||
|
entity: "script.volume_up"
|
||||||
|
service: "turn_on"
|
||||||
|
icon: "speaker"
|
||||||
|
label: "Vol +"
|
||||||
|
4:
|
||||||
|
entity: "script.volume_down"
|
||||||
|
service: "turn_on"
|
||||||
|
icon: "speaker"
|
||||||
|
label: "Vol -"
|
||||||
|
5:
|
||||||
|
entity: "scene.movie_night"
|
||||||
|
service: "turn_on"
|
||||||
|
icon: "film"
|
||||||
|
label: "Movie"
|
||||||
|
6:
|
||||||
|
entity: "script.mute"
|
||||||
|
service: "turn_on"
|
||||||
|
icon: "speaker"
|
||||||
|
label: "Mute"
|
||||||
|
knobs:
|
||||||
|
knob_3:
|
||||||
|
rotate_left:
|
||||||
|
entity: "script.volume_down"
|
||||||
|
service: "turn_on"
|
||||||
|
rotate_right:
|
||||||
|
entity: "script.volume_up"
|
||||||
|
service: "turn_on"
|
||||||
|
press:
|
||||||
|
entity: "script.mute"
|
||||||
|
service: "turn_on"
|
||||||
30
config.py
Normal file
30
config.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import yaml
|
||||||
|
import os
|
||||||
|
|
||||||
|
CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.yaml")
|
||||||
|
|
||||||
|
_defaults = {
|
||||||
|
"homeassistant": {
|
||||||
|
"url": "ws://homeassistant.local:8123/api/websocket",
|
||||||
|
"token": "",
|
||||||
|
},
|
||||||
|
"pages": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(path=None):
|
||||||
|
p = path or CONFIG_PATH
|
||||||
|
with open(p, "r") as f:
|
||||||
|
cfg = yaml.safe_load(f)
|
||||||
|
if not cfg:
|
||||||
|
cfg = {}
|
||||||
|
for k, v in _defaults.items():
|
||||||
|
if k not in cfg:
|
||||||
|
cfg[k] = v
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def save_config(cfg, path=None):
|
||||||
|
p = path or CONFIG_PATH
|
||||||
|
with open(p, "w") as f:
|
||||||
|
yaml.dump(cfg, f, default_flow_style=False, sort_keys=False)
|
||||||
308
key_renderer.py
Normal file
308
key_renderer.py
Normal file
@ -0,0 +1,308 @@
|
|||||||
|
import os
|
||||||
|
import math
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
_FONT_CACHE = {}
|
||||||
|
|
||||||
|
_FONT_SEARCH_PATHS = [
|
||||||
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||||
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||||
|
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
|
||||||
|
"/usr/share/fonts/TTF/DejaVuSans.ttf",
|
||||||
|
"/usr/share/fonts/truetype/freefont/FreeSansBold.ttf",
|
||||||
|
"/usr/share/fonts/truetype/freefont/FreeSans.ttf",
|
||||||
|
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
|
||||||
|
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_font(size):
|
||||||
|
if size not in _FONT_CACHE:
|
||||||
|
for name in _FONT_SEARCH_PATHS:
|
||||||
|
if os.path.exists(name):
|
||||||
|
_FONT_CACHE[size] = ImageFont.truetype(name, size)
|
||||||
|
return _FONT_CACHE[size]
|
||||||
|
_FONT_CACHE[size] = ImageFont.load_default()
|
||||||
|
return _FONT_CACHE[size]
|
||||||
|
|
||||||
|
ICON_COLORS = {
|
||||||
|
"lightbulb": {"on": (255, 210, 50), "off": (80, 80, 80)},
|
||||||
|
"power": {"on": (50, 200, 50), "off": (80, 80, 80)},
|
||||||
|
"thermometer": {"on": (200, 60, 30), "off": (80, 80, 80)},
|
||||||
|
"thermostat": {"on": (50, 150, 220), "off": (80, 80, 80)},
|
||||||
|
"play": {"on": (50, 200, 50), "off": (80, 80, 80)},
|
||||||
|
"film": {"on": (160, 50, 200), "off": (80, 80, 80)},
|
||||||
|
"door": {"on": (50, 200, 50), "off": (200, 50, 50)},
|
||||||
|
"lock": {"on": (50, 200, 50), "off": (200, 50, 50)},
|
||||||
|
"speaker": {"on": (50, 150, 220), "off": (80, 80, 80)},
|
||||||
|
"fan": {"on": (50, 200, 220), "off": (80, 80, 80)},
|
||||||
|
"default": {"on": (50, 150, 220), "off": (80, 80, 80)},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_on(state_str):
|
||||||
|
s = state_str.lower() if state_str else "off"
|
||||||
|
return s in ("on", "playing", "open", "unlocked", "home", "heat", "cool", "auto")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_colors(icon, state_str):
|
||||||
|
is_on = _is_on(state_str)
|
||||||
|
icon_key = icon if icon in ICON_COLORS else "default"
|
||||||
|
key = "on" if is_on else "off"
|
||||||
|
return ICON_COLORS[icon_key][key], is_on
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_icon(draw, icon, cx, cy, r, color):
|
||||||
|
if icon == "lightbulb":
|
||||||
|
_draw_lightbulb(draw, cx, cy, r, color)
|
||||||
|
elif icon == "power":
|
||||||
|
_draw_power(draw, cx, cy, r, color)
|
||||||
|
elif icon == "thermometer":
|
||||||
|
_draw_thermometer(draw, cx, cy, r, color)
|
||||||
|
elif icon == "play":
|
||||||
|
_draw_play(draw, cx, cy, r, color)
|
||||||
|
elif icon == "film":
|
||||||
|
_draw_film(draw, cx, cy, r, color)
|
||||||
|
elif icon == "door":
|
||||||
|
_draw_door(draw, cx, cy, r, color)
|
||||||
|
elif icon == "lock":
|
||||||
|
_draw_lock(draw, cx, cy, r, color)
|
||||||
|
elif icon == "fan":
|
||||||
|
_draw_fan(draw, cx, cy, r, color)
|
||||||
|
elif icon == "thermostat":
|
||||||
|
_draw_thermostat(draw, cx, cy, r, color)
|
||||||
|
elif icon == "speaker":
|
||||||
|
_draw_speaker(draw, cx, cy, r, color)
|
||||||
|
else:
|
||||||
|
draw.ellipse([cx - r, cy - r, cx + r, cy + r], fill=color)
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_lightbulb(draw, cx, cy, r, color):
|
||||||
|
bulb_r = int(r * 0.6)
|
||||||
|
bulb_cy = cy - int(r * 0.15)
|
||||||
|
draw.ellipse(
|
||||||
|
[cx - bulb_r, bulb_cy - bulb_r, cx + bulb_r, bulb_cy + bulb_r], fill=color
|
||||||
|
)
|
||||||
|
base_w = int(r * 0.35)
|
||||||
|
base_h = int(r * 0.3)
|
||||||
|
base_top = bulb_cy + bulb_r - 2
|
||||||
|
draw.rectangle(
|
||||||
|
[cx - base_w, base_top, cx + base_w, base_top + base_h], fill=color
|
||||||
|
)
|
||||||
|
for i in range(3):
|
||||||
|
yy = base_top + int(base_h * 0.2) + i * int(base_h * 0.3)
|
||||||
|
draw.line([cx - base_w, yy, cx + base_w, yy], fill=(30, 30, 30), width=1)
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_power(draw, cx, cy, r, color):
|
||||||
|
gap = int(r * 0.2)
|
||||||
|
arc_r = int(r * 0.7)
|
||||||
|
draw.line([cx, cy - r, cx, cy - gap], fill=color, width=max(2, int(r * 0.15)))
|
||||||
|
bbox = [cx - arc_r, cy - arc_r, cx + arc_r, cy + arc_r]
|
||||||
|
draw.arc(bbox, start=225, end=315, fill=color, width=max(2, int(r * 0.12)))
|
||||||
|
draw.arc(bbox, start=45, end=135, fill=color, width=max(2, int(r * 0.12)))
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_thermometer(draw, cx, cy, r, color):
|
||||||
|
bulb_r = int(r * 0.35)
|
||||||
|
bulb_cy = cy + int(r * 0.35)
|
||||||
|
draw.ellipse(
|
||||||
|
[cx - bulb_r, bulb_cy - bulb_r, cx + bulb_r, bulb_cy + bulb_r], fill=color
|
||||||
|
)
|
||||||
|
tube_w = int(r * 0.18)
|
||||||
|
tube_top = cy - int(r * 0.85)
|
||||||
|
draw.rectangle(
|
||||||
|
[cx - tube_w, tube_top, cx + tube_w, bulb_cy - bulb_r + 2], fill=color
|
||||||
|
)
|
||||||
|
fill_h = int(r * 0.5)
|
||||||
|
fill_top = bulb_cy - fill_h
|
||||||
|
draw.rectangle(
|
||||||
|
[cx - tube_w + 1, fill_top, cx + tube_w - 1, bulb_cy], fill=(220, 60, 30)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_play(draw, cx, cy, r, color):
|
||||||
|
pts = [
|
||||||
|
(cx - int(r * 0.4), cy - int(r * 0.6)),
|
||||||
|
(cx - int(r * 0.4), cy + int(r * 0.6)),
|
||||||
|
(cx + int(r * 0.6), cy),
|
||||||
|
]
|
||||||
|
draw.polygon(pts, fill=color)
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_film(draw, cx, cy, r, color):
|
||||||
|
w, h = int(r * 1.2), int(r * 0.8)
|
||||||
|
draw.rectangle([cx - w, cy - h, cx + w, cy + h], outline=color, width=2)
|
||||||
|
for yy in [cy - h, cy + h]:
|
||||||
|
for xx_off in [-w, w]:
|
||||||
|
draw.rectangle(
|
||||||
|
[cx + xx_off - 3, yy - 3, cx + xx_off + 3, yy + 3], fill=color
|
||||||
|
)
|
||||||
|
draw.rectangle(
|
||||||
|
[cx - int(w * 0.6), cy - int(h * 0.5), cx + int(w * 0.6), cy + int(h * 0.5)],
|
||||||
|
fill=color,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_door(draw, cx, cy, r, color):
|
||||||
|
w, h = int(r * 0.6), int(r * 0.9)
|
||||||
|
draw.rectangle(
|
||||||
|
[cx - w, cy - h, cx + w, cy + h], outline=color, width=max(2, int(r * 0.08))
|
||||||
|
)
|
||||||
|
knob_r = max(2, int(r * 0.08))
|
||||||
|
draw.ellipse([cx + w - knob_r * 4, cy - knob_r, cx + w - knob_r * 2, cy + knob_r], fill=color)
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_lock(draw, cx, cy, r, color):
|
||||||
|
body_w, body_h = int(r * 0.7), int(r * 0.5)
|
||||||
|
body_top = cy - int(r * 0.05)
|
||||||
|
draw.rectangle(
|
||||||
|
[cx - body_w, body_top, cx + body_w, body_top + body_h], fill=color
|
||||||
|
)
|
||||||
|
arc_r = int(r * 0.45)
|
||||||
|
arc_cy = body_top - 2
|
||||||
|
draw.arc(
|
||||||
|
[cx - arc_r, arc_cy - arc_r, cx + arc_r, arc_cy + arc_r],
|
||||||
|
start=180,
|
||||||
|
end=360,
|
||||||
|
fill=color,
|
||||||
|
width=max(2, int(r * 0.1)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_fan(draw, cx, cy, r, color):
|
||||||
|
blade_r = int(r * 0.75)
|
||||||
|
for angle_offset in [0, 90, 180, 270]:
|
||||||
|
a = math.radians(angle_offset)
|
||||||
|
x2 = cx + int(blade_r * math.cos(a))
|
||||||
|
y2 = cy + int(blade_r * math.sin(a))
|
||||||
|
perp_x = int(blade_r * 0.3 * math.cos(a + math.pi / 2))
|
||||||
|
perp_y = int(blade_r * 0.3 * math.sin(a + math.pi / 2))
|
||||||
|
pts = [(cx, cy), (x2 + perp_x, y2 + perp_y), (x2 - perp_x, y2 - perp_y)]
|
||||||
|
draw.polygon(pts, fill=color)
|
||||||
|
draw.ellipse([cx - int(r * 0.15), cy - int(r * 0.15), cx + int(r * 0.15), cy + int(r * 0.15)], fill=(40, 40, 40))
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_thermostat(draw, cx, cy, r, color):
|
||||||
|
draw.ellipse(
|
||||||
|
[cx - int(r * 0.7), cy - int(r * 0.7), cx + int(r * 0.7), cy + int(r * 0.7)],
|
||||||
|
outline=color,
|
||||||
|
width=max(2, int(r * 0.08)),
|
||||||
|
)
|
||||||
|
hand_len = int(r * 0.5)
|
||||||
|
angle = math.radians(-45)
|
||||||
|
hx = cx + int(hand_len * math.cos(angle))
|
||||||
|
hy = cy + int(hand_len * math.sin(angle))
|
||||||
|
draw.line([cx, cy, hx, hy], fill=color, width=max(2, int(r * 0.1)))
|
||||||
|
draw.ellipse([cx - 3, cy - 3, cx + 3, cy + 3], fill=color)
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_speaker(draw, cx, cy, r, color):
|
||||||
|
draw.polygon(
|
||||||
|
[
|
||||||
|
(cx - int(r * 0.5), cy - int(r * 0.25)),
|
||||||
|
(cx - int(r * 0.15), cy - int(r * 0.25)),
|
||||||
|
(cx - int(r * 0.15), cy - int(r * 0.6)),
|
||||||
|
(cx + int(r * 0.2), cy - int(r * 0.6)),
|
||||||
|
],
|
||||||
|
fill=color,
|
||||||
|
)
|
||||||
|
draw.polygon(
|
||||||
|
[
|
||||||
|
(cx - int(r * 0.5), cy + int(r * 0.25)),
|
||||||
|
(cx - int(r * 0.15), cy + int(r * 0.25)),
|
||||||
|
(cx - int(r * 0.15), cy + int(r * 0.6)),
|
||||||
|
(cx + int(r * 0.2), cy + int(r * 0.6)),
|
||||||
|
],
|
||||||
|
fill=color,
|
||||||
|
)
|
||||||
|
draw.rectangle(
|
||||||
|
[cx - int(r * 0.5), cy - int(r * 0.25), cx - int(r * 0.15), cy + int(r * 0.25)],
|
||||||
|
fill=color,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render_key_image(key_config, state_str, size=64):
|
||||||
|
icon = key_config.get("icon", "default")
|
||||||
|
label = key_config.get("label", "")
|
||||||
|
|
||||||
|
bg_color, is_on = _get_colors(icon, state_str)
|
||||||
|
|
||||||
|
img = Image.new("RGB", (size, size), (20, 20, 20))
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
if is_on:
|
||||||
|
glow = Image.new("RGB", (size, size), (0, 0, 0))
|
||||||
|
gd = ImageDraw.Draw(glow)
|
||||||
|
glow_r = int(size * 0.45)
|
||||||
|
gx, gy = size // 2, size // 2 - int(size * 0.05)
|
||||||
|
for i in range(glow_r, 0, -1):
|
||||||
|
alpha = int(40 * (i / glow_r))
|
||||||
|
c = tuple(min(255, v * alpha // 100) for v in bg_color)
|
||||||
|
gd.ellipse([gx - i, gy - i, gx + i, gy + i], fill=c)
|
||||||
|
img = Image.blend(img, glow, 0.6)
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
icon_r = int(size * 0.28)
|
||||||
|
icon_cy = size // 2 - int(size * 0.1)
|
||||||
|
_draw_icon(draw, icon, size // 2, icon_cy, icon_r, bg_color)
|
||||||
|
|
||||||
|
if label:
|
||||||
|
font_size = max(8, int(size * 0.19))
|
||||||
|
font = _get_font(font_size)
|
||||||
|
text = label[:8]
|
||||||
|
bbox = draw.textbbox((0, 0), text, font=font)
|
||||||
|
tw = bbox[2] - bbox[0]
|
||||||
|
tx = (size - tw) // 2
|
||||||
|
ty = size - int(size * 0.28)
|
||||||
|
draw.text((tx, ty), text, fill=(220, 220, 220), font=font)
|
||||||
|
|
||||||
|
state_display = _format_state(state_str, key_config)
|
||||||
|
if state_display:
|
||||||
|
font_size = max(7, int(size * 0.16))
|
||||||
|
font = _get_font(font_size)
|
||||||
|
bbox = draw.textbbox((0, 0), state_display, font=font)
|
||||||
|
tw = bbox[2] - bbox[0]
|
||||||
|
tx = (size - tw) // 2
|
||||||
|
ty = int(size * 0.06)
|
||||||
|
draw.text((tx, ty), state_display, fill=(180, 180, 180), font=font)
|
||||||
|
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def _format_state(state_str, key_config):
|
||||||
|
if not state_str:
|
||||||
|
return ""
|
||||||
|
s = state_str.lower()
|
||||||
|
if s in ("on", "off", "unavailable", "unknown"):
|
||||||
|
return s.capitalize()
|
||||||
|
if s in ("playing", "paused", "idle", "standby"):
|
||||||
|
return s.capitalize()
|
||||||
|
if s in ("open", "closed"):
|
||||||
|
return s.capitalize()
|
||||||
|
if s in ("home", "away", "not_home"):
|
||||||
|
return s.capitalize()
|
||||||
|
if any(unit in state_str for unit in ["°", "%", "V", "A", "W", "kW", "mph", "km/h", "Hz"]):
|
||||||
|
return state_str
|
||||||
|
return state_str[:7]
|
||||||
|
|
||||||
|
|
||||||
|
def render_background(pages, active_page, page_num=1, total_pages=1, size=(320, 240)):
|
||||||
|
img = Image.new("RGB", size, (25, 25, 30))
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
font = _get_font(max(10, int(size[1] * 0.07)))
|
||||||
|
small_font = _get_font(max(8, int(size[1] * 0.05)))
|
||||||
|
draw.text((10, 10), active_page, fill=(255, 255, 255), font=font)
|
||||||
|
if total_pages > 1:
|
||||||
|
dot_r = 3
|
||||||
|
dot_spacing = 14
|
||||||
|
total_width = total_pages * dot_spacing
|
||||||
|
start_x = (size[0] - total_width) // 2
|
||||||
|
page_label_y = size[1] - 20
|
||||||
|
for i in range(total_pages):
|
||||||
|
cx = start_x + i * dot_spacing + dot_spacing // 2
|
||||||
|
if i == page_num - 1:
|
||||||
|
draw.ellipse([cx - dot_r, page_label_y - dot_r, cx + dot_r, page_label_y + dot_r], fill=(255, 255, 255))
|
||||||
|
else:
|
||||||
|
draw.ellipse([cx - dot_r, page_label_y - dot_r, cx + dot_r, page_label_y + dot_r], fill=(80, 80, 80))
|
||||||
|
return img
|
||||||
423
streamdock_ha.py
Normal file
423
streamdock_ha.py
Normal file
@ -0,0 +1,423 @@
|
|||||||
|
import asyncio
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import signal
|
||||||
|
|
||||||
|
from homeassistant_api import AsyncWebsocketClient, State
|
||||||
|
from StreamDock.DeviceManager import DeviceManager
|
||||||
|
from StreamDock.ImageHelpers.PILHelper import to_native_key_format, to_native_touchscreen_format
|
||||||
|
from StreamDock.InputTypes import EventType, Direction, KnobId
|
||||||
|
from config import load_config
|
||||||
|
from key_renderer import render_key_image, render_background
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
|
datefmt="%H:%M:%S",
|
||||||
|
)
|
||||||
|
log = logging.getLogger("streamdock-ha")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_button_ref(ref):
|
||||||
|
if not ref:
|
||||||
|
return None
|
||||||
|
ref = str(ref).lower().strip()
|
||||||
|
mapping = {
|
||||||
|
"button_7": 7, "key_7": 7,
|
||||||
|
"button_8": 8, "key_8": 8,
|
||||||
|
"button_9": 9, "key_9": 9,
|
||||||
|
}
|
||||||
|
return mapping.get(ref, int(ref) if ref.isdigit() else None)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_knob_ref(ref):
|
||||||
|
if not ref:
|
||||||
|
return None
|
||||||
|
ref = str(ref).lower().strip().replace("-", "_")
|
||||||
|
mapping = {
|
||||||
|
"knob_1": KnobId.KNOB_1, "knob1": KnobId.KNOB_1,
|
||||||
|
"knob_2": KnobId.KNOB_2, "knob2": KnobId.KNOB_2,
|
||||||
|
"knob_3": KnobId.KNOB_3, "knob3": KnobId.KNOB_3,
|
||||||
|
"knob_4": KnobId.KNOB_4, "knob4": KnobId.KNOB_4,
|
||||||
|
"left": KnobId.KNOB_1, "bottom_left": KnobId.KNOB_1,
|
||||||
|
"right": KnobId.KNOB_2, "bottom_right": KnobId.KNOB_2,
|
||||||
|
"top": KnobId.KNOB_3,
|
||||||
|
}
|
||||||
|
if ref in mapping:
|
||||||
|
return mapping[ref]
|
||||||
|
for knob in KnobId:
|
||||||
|
if knob.value == ref:
|
||||||
|
return knob
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class StreamDockHA:
|
||||||
|
def __init__(self, config_path=None):
|
||||||
|
self.cfg = load_config(config_path)
|
||||||
|
self.ha_url = self.cfg["homeassistant"]["url"]
|
||||||
|
self.ha_token = self.cfg["homeassistant"]["token"]
|
||||||
|
self.pages = self.cfg.get("pages", [])
|
||||||
|
self.active_page_idx = 0
|
||||||
|
|
||||||
|
nav = self.cfg.get("navigation", {})
|
||||||
|
self.page_cycle_key = _parse_button_ref(nav.get("page_cycle", "button_8"))
|
||||||
|
self.nav_knob_left = _parse_knob_ref(nav.get("knob_left"))
|
||||||
|
self.nav_knob_right = _parse_knob_ref(nav.get("knob_right"))
|
||||||
|
self.nav_knob_press_left = _parse_knob_ref(nav.get("knob_press_left"))
|
||||||
|
self.nav_knob_press_right = _parse_knob_ref(nav.get("knob_press_right"))
|
||||||
|
|
||||||
|
self.device = None
|
||||||
|
self.ha_client = None
|
||||||
|
self.entity_states = {}
|
||||||
|
self._all_key_configs = []
|
||||||
|
self._all_knob_configs = []
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
def _build_all_configs(self):
|
||||||
|
self._all_key_configs = []
|
||||||
|
self._all_knob_configs = []
|
||||||
|
for page in self.pages:
|
||||||
|
page_keys = {}
|
||||||
|
raw_keys = page.get("keys", {})
|
||||||
|
for key_str, cfg in raw_keys.items():
|
||||||
|
key_id = int(key_str) if isinstance(key_str, str) else key_str
|
||||||
|
page_keys[key_id] = cfg
|
||||||
|
self._all_key_configs.append(page_keys)
|
||||||
|
|
||||||
|
page_knobs = {}
|
||||||
|
raw_knobs = page.get("knobs", {})
|
||||||
|
for knob_ref, knob_cfg in raw_knobs.items():
|
||||||
|
knob_id = _parse_knob_ref(knob_ref)
|
||||||
|
if knob_id:
|
||||||
|
page_knobs[knob_id] = knob_cfg
|
||||||
|
self._all_knob_configs.append(page_knobs)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def active_keys(self):
|
||||||
|
if not self._all_key_configs:
|
||||||
|
return {}
|
||||||
|
return self._all_key_configs[self.active_page_idx]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def active_knobs(self):
|
||||||
|
if not self._all_knob_configs:
|
||||||
|
return {}
|
||||||
|
return self._all_knob_configs[self.active_page_idx]
|
||||||
|
|
||||||
|
def _all_entities(self):
|
||||||
|
entities = set()
|
||||||
|
for page_keys in self._all_key_configs:
|
||||||
|
for cfg in page_keys.values():
|
||||||
|
entity = cfg.get("entity")
|
||||||
|
if entity:
|
||||||
|
entities.add(entity)
|
||||||
|
for page_knobs in self._all_knob_configs:
|
||||||
|
for knob_cfg in page_knobs.values():
|
||||||
|
for action_key in ("press", "rotate_left", "rotate_right"):
|
||||||
|
action = knob_cfg.get(action_key, {})
|
||||||
|
if isinstance(action, dict):
|
||||||
|
entity = action.get("entity")
|
||||||
|
if entity:
|
||||||
|
entities.add(entity)
|
||||||
|
entity = knob_cfg.get("entity")
|
||||||
|
if entity:
|
||||||
|
entities.add(entity)
|
||||||
|
return entities
|
||||||
|
|
||||||
|
async def _fetch_initial_states(self):
|
||||||
|
if not self.ha_client:
|
||||||
|
return
|
||||||
|
entities = self._all_entities()
|
||||||
|
if not entities:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
states = await self.ha_client.get_states()
|
||||||
|
for state in states:
|
||||||
|
if state.entity_id in entities:
|
||||||
|
self.entity_states[state.entity_id] = state.state
|
||||||
|
log.info(f"Fetched states for {len(entities)} entities")
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Failed to fetch initial states: {e}")
|
||||||
|
|
||||||
|
async def _render_page(self):
|
||||||
|
if not self.device:
|
||||||
|
return
|
||||||
|
await self._render_background()
|
||||||
|
for key_id, cfg in self.active_keys.items():
|
||||||
|
await self._update_key_image(key_id)
|
||||||
|
self.device.refresh()
|
||||||
|
log.info(f"Page rendered: {self.pages[self.active_page_idx].get('name', '')}")
|
||||||
|
|
||||||
|
async def _render_background(self):
|
||||||
|
if not self.device:
|
||||||
|
return
|
||||||
|
active_name = self.pages[self.active_page_idx].get("name", "")
|
||||||
|
total = len(self.pages)
|
||||||
|
bg = render_background(
|
||||||
|
self.pages, active_name,
|
||||||
|
page_num=self.active_page_idx + 1,
|
||||||
|
total_pages=total,
|
||||||
|
)
|
||||||
|
bg_processed = to_native_touchscreen_format(self.device, bg)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
bg_processed.save(buf, format="JPEG", quality=85)
|
||||||
|
self.device.transport.set_background_image_stream(buf.getvalue())
|
||||||
|
|
||||||
|
async def _update_key_image(self, key_id):
|
||||||
|
cfg = self.active_keys.get(key_id)
|
||||||
|
if not cfg:
|
||||||
|
return
|
||||||
|
entity = cfg.get("entity")
|
||||||
|
state = self.entity_states.get(entity, "unknown") if entity else "off"
|
||||||
|
try:
|
||||||
|
img = render_key_image(cfg, state, size=64)
|
||||||
|
img_processed = to_native_key_format(self.device, img)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img_processed.save(buf, format="JPEG", quality=90)
|
||||||
|
jpeg_data = buf.getvalue()
|
||||||
|
self.device.transport.set_key_image_stream(jpeg_data, key_id)
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Failed to update key {key_id}: {e}")
|
||||||
|
|
||||||
|
async def _cycle_page(self, direction=1):
|
||||||
|
if len(self.pages) <= 1:
|
||||||
|
return
|
||||||
|
old_idx = self.active_page_idx
|
||||||
|
self.active_page_idx = (self.active_page_idx + direction) % len(self.pages)
|
||||||
|
if self.active_page_idx == old_idx:
|
||||||
|
return
|
||||||
|
page_name = self.pages[self.active_page_idx].get("name", "")
|
||||||
|
log.info(f"Page: {page_name} ({self.active_page_idx + 1}/{len(self.pages)})")
|
||||||
|
self.device.clearAllIcon()
|
||||||
|
await self._render_page()
|
||||||
|
|
||||||
|
async def _handle_event(self, device, event):
|
||||||
|
if event.event_type == EventType.KNOB_ROTATE:
|
||||||
|
await self._handle_knob_rotate(event)
|
||||||
|
return
|
||||||
|
|
||||||
|
if event.event_type == EventType.KNOB_PRESS:
|
||||||
|
await self._handle_knob_press(event)
|
||||||
|
return
|
||||||
|
|
||||||
|
if event.event_type != EventType.BUTTON:
|
||||||
|
return
|
||||||
|
if event.state != 1:
|
||||||
|
return
|
||||||
|
|
||||||
|
key_id = event.key.value if event.key else None
|
||||||
|
if key_id is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if key_id == self.page_cycle_key:
|
||||||
|
await self._cycle_page(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
cfg = self.active_keys.get(key_id)
|
||||||
|
if not cfg:
|
||||||
|
return
|
||||||
|
await self._call_service_from_cfg(cfg, f"Key {key_id}")
|
||||||
|
|
||||||
|
async def _handle_knob_rotate(self, event):
|
||||||
|
knob_id = event.knob_id
|
||||||
|
if not knob_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
is_right = event.direction == Direction.RIGHT
|
||||||
|
|
||||||
|
if is_right and knob_id == self.nav_knob_right:
|
||||||
|
await self._cycle_page(1)
|
||||||
|
return
|
||||||
|
if not is_right and knob_id == self.nav_knob_left:
|
||||||
|
await self._cycle_page(-1)
|
||||||
|
return
|
||||||
|
|
||||||
|
knob_cfg = self.active_knobs.get(knob_id)
|
||||||
|
if not knob_cfg:
|
||||||
|
return
|
||||||
|
|
||||||
|
action_key = "rotate_right" if is_right else "rotate_left"
|
||||||
|
action = knob_cfg.get(action_key)
|
||||||
|
if not action:
|
||||||
|
return
|
||||||
|
await self._call_service_from_cfg(action, f"Knob {knob_id.value} {action_key}")
|
||||||
|
|
||||||
|
async def _handle_knob_press(self, event):
|
||||||
|
knob_id = event.knob_id
|
||||||
|
if not knob_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
if event.state != 1:
|
||||||
|
return
|
||||||
|
|
||||||
|
if knob_id == self.nav_knob_press_left:
|
||||||
|
await self._cycle_page(-1)
|
||||||
|
return
|
||||||
|
if knob_id == self.nav_knob_press_right:
|
||||||
|
await self._cycle_page(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
knob_cfg = self.active_knobs.get(knob_id)
|
||||||
|
if not knob_cfg:
|
||||||
|
return
|
||||||
|
|
||||||
|
press_action = knob_cfg.get("press")
|
||||||
|
if not press_action:
|
||||||
|
entity = knob_cfg.get("entity")
|
||||||
|
service = knob_cfg.get("service")
|
||||||
|
if entity and service:
|
||||||
|
await self._call_service(entity, service, f"Knob {knob_id.value} press")
|
||||||
|
return
|
||||||
|
await self._call_service_from_cfg(press_action, f"Knob {knob_id.value} press")
|
||||||
|
|
||||||
|
async def _call_service_from_cfg(self, cfg, label=""):
|
||||||
|
if isinstance(cfg, dict):
|
||||||
|
entity = cfg.get("entity")
|
||||||
|
service = cfg.get("service")
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
if not entity or not service:
|
||||||
|
return
|
||||||
|
await self._call_service(entity, service, label)
|
||||||
|
|
||||||
|
async def _call_service(self, entity, service, label=""):
|
||||||
|
domain = entity.split(".")[0]
|
||||||
|
log.info(f"{label}: calling {domain}.{service} on {entity}")
|
||||||
|
try:
|
||||||
|
await self.ha_client.trigger_service(domain, service, entity_id=entity)
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Failed to call service {domain}.{service}: {e}")
|
||||||
|
|
||||||
|
async def _listen_state_changes(self):
|
||||||
|
if not self.ha_client:
|
||||||
|
return
|
||||||
|
log.info("Listening for HA state changes...")
|
||||||
|
try:
|
||||||
|
async with self.ha_client.listen_events("state_changed") as events:
|
||||||
|
async for event in events:
|
||||||
|
if not self._running:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
data = event.data if hasattr(event, "data") else {}
|
||||||
|
entity_id = data.get("entity_id", "")
|
||||||
|
new_state = data.get("new_state")
|
||||||
|
if not entity_id or not new_state:
|
||||||
|
continue
|
||||||
|
if isinstance(new_state, State):
|
||||||
|
state_val = new_state.state
|
||||||
|
elif isinstance(new_state, dict):
|
||||||
|
state_val = new_state.get("state", "")
|
||||||
|
else:
|
||||||
|
state_val = str(new_state)
|
||||||
|
old_val = self.entity_states.get(entity_id)
|
||||||
|
self.entity_states[entity_id] = state_val
|
||||||
|
if old_val != state_val:
|
||||||
|
log.info(f"State changed: {entity_id} = {state_val}")
|
||||||
|
await self._update_visible_keys(entity_id)
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Error processing state change: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"HA event listener error: {e}")
|
||||||
|
|
||||||
|
async def _update_visible_keys(self, entity_id):
|
||||||
|
for key_id, cfg in self.active_keys.items():
|
||||||
|
if cfg.get("entity") == entity_id:
|
||||||
|
await self._update_key_image(key_id)
|
||||||
|
self.device.refresh()
|
||||||
|
|
||||||
|
async def _setup_device(self):
|
||||||
|
manager = DeviceManager()
|
||||||
|
devices = manager.enumerate()
|
||||||
|
if not devices:
|
||||||
|
log.error("No StreamDock device found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.device = devices[0]
|
||||||
|
log.info(
|
||||||
|
f"Found {type(self.device).__name__} at {self.device.path} (serial: {self.device.serial_number})"
|
||||||
|
)
|
||||||
|
self.device.open()
|
||||||
|
self.device.init()
|
||||||
|
self.device.set_key_callback(self._make_sync_callback())
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _make_sync_callback(self):
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
|
def callback(device, event):
|
||||||
|
asyncio.run_coroutine_threadsafe(self._handle_event(device, event), loop)
|
||||||
|
|
||||||
|
return callback
|
||||||
|
|
||||||
|
async def run(self):
|
||||||
|
if not self.pages:
|
||||||
|
log.error("No pages configured in config.yaml")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not await self._setup_device():
|
||||||
|
return
|
||||||
|
|
||||||
|
self._build_all_configs()
|
||||||
|
|
||||||
|
async with AsyncWebsocketClient(self.ha_url, self.ha_token) as client:
|
||||||
|
self.ha_client = client
|
||||||
|
log.info("Connected to Home Assistant")
|
||||||
|
self._running = True
|
||||||
|
|
||||||
|
await self._fetch_initial_states()
|
||||||
|
await self._render_page()
|
||||||
|
|
||||||
|
listen_task = asyncio.create_task(self._listen_state_changes())
|
||||||
|
|
||||||
|
nav_info = []
|
||||||
|
if self.page_cycle_key:
|
||||||
|
nav_info.append(f"Key {self.page_cycle_key}=next page")
|
||||||
|
if self.nav_knob_left or self.nav_knob_right:
|
||||||
|
nav_info.append("knob rotation=prev/next page")
|
||||||
|
log.info(f"StreamDock-HA running ({len(self.pages)} pages). {'; '.join(nav_info)}. Ctrl+C to exit.")
|
||||||
|
try:
|
||||||
|
while self._running:
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
except (KeyboardInterrupt, asyncio.CancelledError):
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
self._running = False
|
||||||
|
listen_task.cancel()
|
||||||
|
try:
|
||||||
|
await listen_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
if self.device:
|
||||||
|
self.device.close()
|
||||||
|
log.info("Shutdown complete")
|
||||||
|
|
||||||
|
def shutdown(self):
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
config_path = None
|
||||||
|
for i, arg in enumerate(sys.argv):
|
||||||
|
if arg in ("-c", "--config") and i + 1 < len(sys.argv):
|
||||||
|
config_path = sys.argv[i + 1]
|
||||||
|
|
||||||
|
app = StreamDockHA(config_path)
|
||||||
|
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
|
||||||
|
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||||
|
loop.add_signal_handler(sig, app.shutdown)
|
||||||
|
|
||||||
|
try:
|
||||||
|
loop.run_until_complete(app.run())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
app.shutdown()
|
||||||
|
loop.run_until_complete(app.run())
|
||||||
|
finally:
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Loading…
x
Reference in New Issue
Block a user