From dcd7c8cf99cd3b3c439bd92efc9cca611fd7072c Mon Sep 17 00:00:00 2001 From: Brayan Ksenhuck Date: Thu, 30 Jul 2026 22:27:33 -0300 Subject: [PATCH] Add support for Ulanzi Stream Controller D200 Adds a UlanziD200 Device class (USB VID:PID 2207:0019) alongside the existing Elgato and Mirabox support, following the same pattern as the Mirabox293S integration. Protocol notes (see docstring in UlanziD200.py for details): - 14 physical keys in a 5x3 grid; the 15th grid slot has no LCD (it's the small stats/clock window), modeled as an inert phantom key so the library's KEY_ROWS x KEY_COLS grid math stays rectangular. - The device only accepts a button image upload for a short window after opening; a periodic "priming" upload keeps that window open until the caller's first real upload arrives. - Button image uploads for multiple keys are combined into a single multi-packet upload, matching how other D200 clients (e.g. homedeck) operate; the device merges an upload into what's currently displayed rather than replacing it wholesale. - open() is made idempotent per connection, since callers such as StreamController call it more than once during their own startup with no is_open() guard, and doing that repeatedly within milliseconds left the device unable to render images for the rest of the session. Also exposes hidapi's interface_number on LibUSBHIDAPI enumeration results (previously unused) and lets DeviceManager._default_factory() filter by it, since the D200 exposes a second, unrelated keyboard-emulation HID interface under the same VID:PID that must not be wrapped as a StreamDeck. Protocol reverse-engineered by redphx: https://github.com/redphx/strmdck https://github.com/redphx/homedeck --- src/StreamDeck/DeviceManager.py | 35 +- src/StreamDeck/Devices/UlanziD200.py | 518 +++++++++++++++++++++++ src/StreamDeck/ProductIDs.py | 2 + src/StreamDeck/Transport/LibUSBHIDAPI.py | 6 +- 4 files changed, 552 insertions(+), 9 deletions(-) create mode 100644 src/StreamDeck/Devices/UlanziD200.py diff --git a/src/StreamDeck/DeviceManager.py b/src/StreamDeck/DeviceManager.py index 24e07bcd..3316f19a 100644 --- a/src/StreamDeck/DeviceManager.py +++ b/src/StreamDeck/DeviceManager.py @@ -17,6 +17,7 @@ from .Devices.StreamDeckPlus import StreamDeckPlus from .Transport import Transport from .Devices.Mirabox293S import Mirabox293S +from .Devices.UlanziD200 import UlanziD200 from .Transport.Dummy import Dummy from .Transport.LibUSBHIDAPI import LibUSBHIDAPI from .ProductIDs import USBVendorIDs, USBProductIDs @@ -24,8 +25,8 @@ # Module-level registry of additional controller factories. These are intended # for non-USB / virtual controllers (e.g. the TouchyDeck shim) that cannot be -# discovered via the standard USB transport. The built-in Elgato and Mirabox -# devices are always enumerated regardless of this registry. +# discovered via the standard USB transport. The built-in Elgato, Mirabox, and +# Ulanzi devices are always enumerated regardless of this registry. _controller_factories: list[Callable[[], "list[StreamDeck]"]] = [] @@ -37,8 +38,8 @@ def register_controllers_factory(factory: Callable[[], "list[StreamDeck]"]) -> N A factory is a zero-argument callable that returns a list of :class:`StreamDeck` (or subclass) instances. Factories are intended for non-USB / virtual controllers that can't be discovered via the standard - USB transport; the built-in Elgato and Mirabox devices are always - enumerated regardless. + USB transport; the built-in Elgato, Mirabox, and Ulanzi devices are + always enumerated regardless. Registration is global and cumulative. It is typically called once at application startup, before any :class:`DeviceManager` is instantiated. @@ -119,8 +120,16 @@ def __init__(self, transport: str | None = None): def _default_factory(self) -> list[StreamDeck]: """ Discover the built-in, USB-attached StreamDeck controllers via this - manager's transport. Returns the Elgato and Mirabox devices that the - library knows about natively. + manager's transport. Returns the Elgato, Mirabox, and Ulanzi devices + that the library knows about natively. + + Each entry in ``products`` is ``(vid, pid, class_type)``, optionally + followed by a USB interface number. The interface number is only + needed for composite devices that expose more than one HID + interface under the same VID/PID where only one of them speaks the + deck protocol - e.g. the Ulanzi D200, whose second interface is an + unrelated keyboard-emulation HID device that must not be wrapped as + a StreamDeck. :rtype: list(StreamDeck) :return: list of :class:`StreamDeck` instances, one for each detected @@ -143,13 +152,23 @@ def _default_factory(self) -> list[StreamDeck]: (USBVendorIDs.USB_VID_ELGATO, USBProductIDs.USB_PID_STREAMDECK_XL_V2, StreamDeckXL), (USBVendorIDs.USB_VID_ELGATO, USBProductIDs.USB_PID_STREAMDECK_XL_V2_MODULE, StreamDeckXL), (USBVendorIDs.USB_VID_ELGATO, USBProductIDs.USB_PID_STREAMDECK_PLUS, StreamDeckPlus), - (USBVendorIDs.USB_VID_MIRABOX, USBProductIDs.USB_PID_MIRABOX_STREAMDOCK_293S, Mirabox293S) + (USBVendorIDs.USB_VID_MIRABOX, USBProductIDs.USB_PID_MIRABOX_STREAMDOCK_293S, Mirabox293S), + (USBVendorIDs.USB_VID_ULANZI, USBProductIDs.USB_PID_ULANZI_D200, UlanziD200, 0), ] streamdecks = list() - for vid, pid, class_type in products: + for entry in products: + vid, pid, class_type = entry[0], entry[1], entry[2] + interface_number = entry[3] if len(entry) > 3 else None + found_devices = self.transport.enumerate(vid=vid, pid=pid) + if interface_number is not None: + found_devices = [ + d for d in found_devices + if getattr(d, "interface_number", lambda: interface_number)() == interface_number + ] + streamdecks.extend([class_type(d) for d in found_devices]) return streamdecks diff --git a/src/StreamDeck/Devices/UlanziD200.py b/src/StreamDeck/Devices/UlanziD200.py new file mode 100644 index 00000000..f29ad869 --- /dev/null +++ b/src/StreamDeck/Devices/UlanziD200.py @@ -0,0 +1,518 @@ +# Python Stream Deck Library +# Released under the MIT license +# +# dean [at] fourwalledcubicle [dot] com +# www.fourwalledcubicle.com +# +# Ulanzi Stream Controller D200 support +# Protocol reverse-engineered by redphx (github.com/redphx/strmdck, +# github.com/redphx/homedeck) + +from __future__ import annotations + +import io +import json +import logging +import queue +import threading +import time +import zipfile +from datetime import datetime + +from .StreamDeck import ControlType, StreamDeck +from ..Transport.Transport import TransportError + + +class UlanziD200(StreamDeck): + """ + Represents a physically attached Ulanzi Stream Controller D200 device. + + Protocol notes (USB VID:PID 2207:0019). None of this is documented by + the vendor; all of it was learned empirically against real hardware. + + - The device exposes two HID interfaces. Interface 0 carries the deck + protocol used here; interface 1 is a keyboard-emulation HID device + (unrelated, not touched by this class). DeviceManager filters for + interface 0 specifically when discovering this device - see the + ``interface_number`` handling in ``DeviceManager._default_factory``. + - 14 physical keys, laid out row-major in a 5 col x 3 row grid (index // + 5 = row, index % 5 = col). The 15th grid slot (row 2, col 4) has no + physical LCD - it's occupied by a small stats/clock window. StreamDeck + key layout math generally assumes a fully rectangular grid, so + KEY_COUNT is 15 with that slot treated as an inert phantom key that + set_key_image() silently ignores. + - The device stops sending button-press reports unless it receives a + periodic "keep alive" packet (a small-window-data update); without it + the device falls back to standalone/disconnected behavior. + - The device only accepts a button image upload for a short window after + the connection opens. If nothing arrives within roughly a few seconds + it silently ignores every future upload for the rest of the session. + This class works around that by periodically sending a harmless + "priming" upload (to the invisible phantom slot) until the caller's + first real upload arrives, keeping the window open indefinitely. + - Button image uploads for multiple keys are combined into a single + multi-packet upload rather than one upload per key (matching how + other Ulanzi D200 clients such as homedeck operate); the device merges + an upload into the currently displayed buttons rather than replacing + them, so a partial-page upload never clears keys it doesn't mention. + - Icons appear to be cached by filename on the device, not by content: + re-uploading a key's icon under the same ".png" name can leave + the old image on screen even though the upload was accepted without + error. Each update therefore uses a fresh, never-reused filename + ("_.png"). + + Threading model: a single background "send worker" thread owns every + write to the device - the periodic keep-alive/priming pings as well as + button image uploads. Earlier iterations sent the keep-alive from the + StreamDeck base class's own reader thread while a separate thread + handled image uploads; even though every individual write was already + byte-correct and serialized under a lock, that interleaving alone was + enough to make the device silently stop rendering uploaded images. + Funneling everything through one thread and one queue avoided that + entirely. The worker is started in open() and stopped in close(), so it + doesn't leak across a close()/open() reconnect cycle on the same + instance (StreamDeck._read_with_resume_from_suspend() reuses the same + object across a disconnect/reconnect rather than constructing a new + one). + """ + + KEY_COUNT = 15 + PHYSICAL_KEY_COUNT = 14 + PHANTOM_KEY = 14 + KEY_COLS = 5 + KEY_ROWS = 3 + + KEY_PIXEL_WIDTH = 196 + KEY_PIXEL_HEIGHT = 196 + KEY_IMAGE_FORMAT = "PNG" + KEY_FLIP = (False, False) + KEY_ROTATION = 0 + + DECK_TYPE = "Ulanzi Stream Controller D200" + DECK_VISUAL = True + DECK_TOUCH = False + + # Protocol command IDs, sent as the second field of every packet header + # (see _build_packet()). Only the commands this class actually needs are + # listed; the device almost certainly supports more (label styling, + # partial-button updates, etc.) that weren't required to get a working + # driver and so weren't reverse-engineered. + _OUT_SET_BUTTONS = 0x0001 + _OUT_SET_SMALL_WINDOW_DATA = 0x0006 + _OUT_SET_BRIGHTNESS = 0x000A + + _IN_BUTTON = 0x0101 + + _CHUNK_SIZE = 1024 + _HEADER_SIZE = 8 + _FIRST_PACKET_PAYLOAD = _CHUNK_SIZE - _HEADER_SIZE # 1016 + + _KEEPALIVE_INTERVAL = 1.0 + _PRIMING_INTERVAL = 1.0 + _FLUSH_DEBOUNCE_SECONDS = 0.3 + _SEND_POLL_SECONDS = 0.2 + + # Sentinel command values (no real protocol command is negative), used + # to ask the send worker to do something other than write a plain + # packet. See the threading model note in the class docstring for why + # everything is funneled through this one queue. + _FORCE_KEEPALIVE = -1 + _FLUSH_BUTTONS = -2 + + # Shared across all instances on purpose: it's a fixed, content-free + # payload (see _priming_zip()), so there's nothing instance-specific to + # cache separately per device. + _priming_zip_cache: bytes | None = None + + def __init__(self, device): + super().__init__(device) + + self._current_key_states = [False] * self.KEY_COUNT + self._opened_once = False + + self._manifest_lock = threading.Lock() + self._manifest: dict[str, dict] = {} + self._icons: dict[str, bytes] = {} + self._icon_revision: dict[int, int] = {} + self._flush_timer: threading.Timer | None = None + + self._last_keepalive = 0.0 + self._last_priming = 0.0 + self._send_queue: "queue.Queue[tuple[int, bytes]]" = queue.Queue() + self._send_stop = threading.Event() + self._send_thread: threading.Thread | None = None + + # -- send worker ---------------------------------------------------------- + + def _start_send_worker(self) -> None: + """(Re)start the background send worker thread. Called from open(); + safe to call again on a reconnect since it always creates a fresh + Thread object rather than assuming the old one is still usable.""" + # Reset the timers here rather than in __init__: this can run again + # on a reconnect (see close()/open()), and stale timestamps from a + # previous connection would make the very first loop iteration think + # a keep-alive/priming send is already overdue. + self._last_keepalive = time.monotonic() + self._last_priming = time.monotonic() + self._send_stop.clear() + self._send_thread = threading.Thread( + target=self._send_worker, name="UlanziD200SendWorker", daemon=True + ) + self._send_thread.start() + + def _send_worker(self) -> None: + """Body of the background send thread (see _start_send_worker() and + the threading model note in the class docstring). Drains + self._send_queue, dispatching sentinel commands to their handlers + and everything else to _write_now(); falls back to the periodic + keep-alive/priming checks whenever the queue is empty. Runs until + self._send_stop is set by close().""" + while not self._send_stop.is_set(): + try: + command, data = self._send_queue.get(timeout=self._SEND_POLL_SECONDS) + except queue.Empty: + command, data = None, None + + try: + if command == self._FORCE_KEEPALIVE: + self._write_keepalive_now() + continue + + if command == self._FLUSH_BUTTONS: + self._flush_buttons_now() + continue + + if command is not None: + self._write_now(command, data) + continue + + now = time.monotonic() + if now - self._last_keepalive >= self._KEEPALIVE_INTERVAL: + self._write_keepalive_now() + if now - self._last_priming >= self._PRIMING_INTERVAL: + self._write_priming_now() + except Exception: + # A single bad iteration must never take the whole worker - + # and with it, every future write to the device - down. + logging.exception("UlanziD200: send worker iteration failed") + continue + + # -- low level packet helpers -------------------------------------------- + + @classmethod + def _build_packet(cls, command: int, length: int, first_chunk: bytes) -> bytes: + """Build the first (and possibly only) packet of an upload: an + 8-byte header - magic bytes, command ID, total payload length - + followed by up to _FIRST_PACKET_PAYLOAD bytes of data, zero-padded + to fill the packet.""" + header = b"\x7c\x7c" + command.to_bytes(2, "big") + length.to_bytes(4, "little") + return header + first_chunk.ljust(cls._FIRST_PACKET_PAYLOAD, b"\x00")[:cls._FIRST_PACKET_PAYLOAD] + + @classmethod + def _build_packets(cls, command: int, data: bytes) -> list[bytes]: + """Split `data` into the full sequence of 1024-byte packets the + device expects: one header packet (see _build_packet()) followed by + as many raw, zero-padded continuation packets as needed.""" + packets = [cls._build_packet(command, len(data), data[:cls._FIRST_PACKET_PAYLOAD])] + for i in range(cls._FIRST_PACKET_PAYLOAD, len(data), cls._CHUNK_SIZE): + chunk = data[i:i + cls._CHUNK_SIZE] + packets.append(chunk.ljust(cls._CHUNK_SIZE, b"\x00")) + return packets + + def _write_now(self, command: int, data: bytes) -> None: + """Frame `data` into packets and write them to the device + synchronously. Only ever called from the send worker thread - never + call this directly from set_key_image()/set_brightness()/etc., + which must go through _enqueue() instead so every write stays + serialized on that one thread.""" + try: + packets = self._build_packets(command, data) + with self.update_lock: + for packet in packets: + self.device.write(packet) + except TransportError: + # Expected during a disconnect/reconnect cycle; the caller + # doesn't need every transient write failure logged as an + # error, only unexpected bugs (handled by the worker's own + # except Exception, above). + logging.debug("UlanziD200: write failed (device likely disconnected)", exc_info=True) + + def _write_keepalive_now(self) -> None: + """Send one keep-alive packet immediately and reset the periodic + timer. Always called from the send worker thread - see the + threading model note in the class docstring.""" + self._last_keepalive = time.monotonic() + # Small-window-data payload format is "|||