Karl 06f4c2768c 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.
2026-05-07 19:14:07 +01:00

601 lines
21 KiB
Python

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)