From 25cfbf03537ca1223bcb3858ae54e2ab2ea86ce4 Mon Sep 17 00:00:00 2001 From: Joe Goldin Date: Wed, 12 Aug 2026 13:16:19 -0700 Subject: [PATCH 1/2] Add the MiraBox StreamDock device family StreamDock hardware is sold as a Stream Deck alternative but speaks a different protocol: text-tagged commands in fixed-size HID reports rather than Elgato's binary one. Every command is a 5-byte CRT header, a three-letter command and big-endian parameters, and key images are JPEGs streamed between a BAT and an STP. The per-model differences are all data - key grids, image key ids, event codes - so StreamDock holds the protocol and each model is a subclass of tables. Eleven models across 25 product ids are registered. Only the N3 has been verified against hardware, and its docstring says so; the rest are built from the reverse-engineered tables credited in the StreamDock module and README, and are marked untested. Three details are worth calling out, all of them found on the N3: * Its six keys are cutouts in one 320x240 panel that the firmware tiles at an 85 pixel pitch, and each cutout sits off its tile, so an image sent as-is lands visibly off-centre. The device publishes the framing in its key image format and PILHelper applies it, keeping PIL out of the device classes. to_native_key_format() therefore takes an optional key index; every existing caller and device is unaffected. * Closing must not send the vendor disconnect command. It latches the panel into acknowledging display writes and rendering none of them until the device is replugged, which looks like a deck that responds to buttons but never redraws. Releasing the handle leaves the panel writable. * The firmware treats a quiet host as gone, so a keepalive thread sends CONNECT every two seconds. A superseded keepalive is retired by generation rather than joined: it sends under the deck's update lock, which the caller of open() may already hold. Reports are padded to the model's exact length. The firmware reads fixed-size reports and a short one desynchronises the image stream. test/test_streamdock.py covers the wire format, image chunking including zero-leading payloads, the connect handshake, input decoding and the panel framing against a recording transport. --- CHANGELOG | 10 + README.md | 30 ++ doc/source/modules/devices.rst | 58 +++ src/StreamDeck/DeviceManager.py | 40 ++ src/StreamDeck/Devices/StreamDock.py | 408 +++++++++++++++++++++ src/StreamDeck/Devices/StreamDock293.py | 52 +++ src/StreamDeck/Devices/StreamDock293V3.py | 52 +++ src/StreamDeck/Devices/StreamDock293sV3.py | 52 +++ src/StreamDeck/Devices/StreamDockK1Pro.py | 62 ++++ src/StreamDeck/Devices/StreamDockM18.py | 52 +++ src/StreamDeck/Devices/StreamDockM3.py | 59 +++ src/StreamDeck/Devices/StreamDockN1.py | 56 +++ src/StreamDeck/Devices/StreamDockN3.py | 151 ++++++++ src/StreamDeck/Devices/StreamDockN4.py | 50 +++ src/StreamDeck/Devices/StreamDockN4Pro.py | 64 ++++ src/StreamDeck/Devices/StreamDockXL.py | 65 ++++ src/StreamDeck/ImageHelpers/PILHelper.py | 44 ++- src/StreamDeck/ProductIDs.py | 34 ++ test/test_streamdock.py | 342 +++++++++++++++++ 19 files changed, 1677 insertions(+), 4 deletions(-) create mode 100644 src/StreamDeck/Devices/StreamDock.py create mode 100644 src/StreamDeck/Devices/StreamDock293.py create mode 100644 src/StreamDeck/Devices/StreamDock293V3.py create mode 100644 src/StreamDeck/Devices/StreamDock293sV3.py create mode 100644 src/StreamDeck/Devices/StreamDockK1Pro.py create mode 100644 src/StreamDeck/Devices/StreamDockM18.py create mode 100644 src/StreamDeck/Devices/StreamDockM3.py create mode 100644 src/StreamDeck/Devices/StreamDockN1.py create mode 100644 src/StreamDeck/Devices/StreamDockN3.py create mode 100644 src/StreamDeck/Devices/StreamDockN4.py create mode 100644 src/StreamDeck/Devices/StreamDockN4Pro.py create mode 100644 src/StreamDeck/Devices/StreamDockXL.py create mode 100644 test/test_streamdock.py diff --git a/CHANGELOG b/CHANGELOG index d3b1f56..22f60cd 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,13 @@ +Unreleased: + - Added the MiraBox StreamDock family: 293, 293 V3, 293s V3, K1 Pro, M3, M18, + N1, N3, N4, N4 Pro and XL, across 25 product ids. They speak a text-tagged + HID protocol rather than Elgato's binary one, so they share a StreamDock + base class that holds the protocol and per-model data tables. Only the N3 + has been verified against hardware. + - to_native_key_format() takes an optional key index, for devices whose keys + are cutouts in one shared panel and are framed per key. Existing callers + are unaffected. + Fork version 0.2.0: - Merged upstream up to the 0.10.0 changes listed below. - Added RotatedDeck, a wrapper presenting a deck as if it had been turned by diff --git a/README.md b/README.md index 92a6856..8a62bf0 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,23 @@ This fork additionally supports: * Mirabox Stream Dock 293S +And the MiraBox StreamDock family, which speaks its own protocol rather than +Elgato's: + +* StreamDock 293 and 293 V3 +* StreamDock 293s V3 +* StreamDock K1 Pro +* StreamDock M3 and M18 +* StreamDock N1 +* StreamDock N3 +* StreamDock N4 and N4 Pro +* StreamDock XL + +Of these, the N3 has been verified against hardware. The rest are built from +the reverse-engineered protocol tables credited in `StreamDeck/Devices/StreamDock.py` +and are untested, so reports either way are welcome. Their key grids and knobs +are mapped; secondary LCD strips are not. + ## Package Installation: Install the library via pip: @@ -107,6 +124,19 @@ development and maintenance of this library: If you've contributed in some manner, but I've accidentally missed you in the list above, please let me know. +The MiraBox StreamDock protocol is undocumented by the vendor. The support in +this fork rests on independent reverse engineering by: + +- [Phaeilo](https://github.com/Phaeilo), for the pure-Python HID transport + ([StreamDock-Device-SDK#76](https://github.com/MiraboxSpace/StreamDock-Device-SDK/pull/76)) +- [stevemurr](https://github.com/stevemurr), for the MOD mode select, the connect + handshake, and the CONNECT keepalive and HAN semantics + ([streamdock](https://github.com/stevemurr/streamdock)) +- [4ndv](https://github.com/4ndv), for [mirajazz](https://github.com/4ndv/mirajazz), + against which the command frames were corroborated +- [rigor789](https://github.com/rigor789), for + [mirabox-streamdock-node](https://github.com/rigor789/mirabox-streamdock-node) + ## License: diff --git a/doc/source/modules/devices.rst b/doc/source/modules/devices.rst index 7dc603e..0c8038e 100644 --- a/doc/source/modules/devices.rst +++ b/doc/source/modules/devices.rst @@ -92,6 +92,64 @@ Mirabox Stream Dock 293S :show-inheritance: +========================= +StreamDock (Abstract Base) +========================= + +.. automodule:: StreamDeck.Devices.StreamDock + :members: + :show-inheritance: + + +========================= +StreamDock Models +========================= + +.. automodule:: StreamDeck.Devices.StreamDockN3 + :members: + :show-inheritance: + +.. automodule:: StreamDeck.Devices.StreamDockN1 + :members: + :show-inheritance: + +.. automodule:: StreamDeck.Devices.StreamDockN4 + :members: + :show-inheritance: + +.. automodule:: StreamDeck.Devices.StreamDockN4Pro + :members: + :show-inheritance: + +.. automodule:: StreamDeck.Devices.StreamDock293 + :members: + :show-inheritance: + +.. automodule:: StreamDeck.Devices.StreamDock293V3 + :members: + :show-inheritance: + +.. automodule:: StreamDeck.Devices.StreamDock293sV3 + :members: + :show-inheritance: + +.. automodule:: StreamDeck.Devices.StreamDockXL + :members: + :show-inheritance: + +.. automodule:: StreamDeck.Devices.StreamDockM3 + :members: + :show-inheritance: + +.. automodule:: StreamDeck.Devices.StreamDockM18 + :members: + :show-inheritance: + +.. automodule:: StreamDeck.Devices.StreamDockK1Pro + :members: + :show-inheritance: + + ============ Rotated Deck ============ diff --git a/src/StreamDeck/DeviceManager.py b/src/StreamDeck/DeviceManager.py index 747d533..4f97f7e 100644 --- a/src/StreamDeck/DeviceManager.py +++ b/src/StreamDeck/DeviceManager.py @@ -18,6 +18,17 @@ from .Devices.StreamDeckPlusXL import StreamDeckPlusXL from .Devices.StreamDeckStudio import StreamDeckStudio from .Devices.StreamDeckXL import StreamDeckXL +from .Devices.StreamDock293 import StreamDock293 +from .Devices.StreamDock293sV3 import StreamDock293sV3 +from .Devices.StreamDock293V3 import StreamDock293V3 +from .Devices.StreamDockK1Pro import StreamDockK1Pro +from .Devices.StreamDockM3 import StreamDockM3 +from .Devices.StreamDockM18 import StreamDockM18 +from .Devices.StreamDockN1 import StreamDockN1 +from .Devices.StreamDockN3 import StreamDockN3 +from .Devices.StreamDockN4 import StreamDockN4 +from .Devices.StreamDockN4Pro import StreamDockN4Pro +from .Devices.StreamDockXL import StreamDockXL from .ProductIDs import USBProductIDs, USBVendorIDs from .Transport import Transport from .Transport.Dummy import Dummy @@ -149,6 +160,35 @@ def _default_factory(self) -> list[StreamDeck]: (USBVendorIDs.USB_VID_ELGATO, USBProductIDs.USB_PID_STREAMDECK_PLUS, StreamDeckPlus), (USBVendorIDs.USB_VID_ELGATO, USBProductIDs.USB_PID_STREAMDECK_PLUS_XL, StreamDeckPlusXL), (USBVendorIDs.USB_VID_MIRABOX, USBProductIDs.USB_PID_MIRABOX_STREAMDOCK_293S, Mirabox293S), + + # MiraBox StreamDock. The 293s is not listed here: it is the same + # device as the Mirabox293S entry above, which predates these + # classes and keeps driving it. + (USBVendorIDs.USB_VID_STREAMDOCK_5500, USBProductIDs.USB_PID_STREAMDOCK_293, StreamDock293), + (USBVendorIDs.USB_VID_STREAMDOCK_6603, USBProductIDs.USB_PID_STREAMDOCK_293_V3, StreamDock293V3), + (USBVendorIDs.USB_VID_STREAMDOCK_6603, USBProductIDs.USB_PID_STREAMDOCK_293_V3_B, StreamDock293V3), + (USBVendorIDs.USB_VID_STREAMDOCK_6603, USBProductIDs.USB_PID_STREAMDOCK_293_V3_C, StreamDock293V3), + (USBVendorIDs.USB_VID_STREAMDOCK_6603, USBProductIDs.USB_PID_STREAMDOCK_293S_V3, StreamDock293sV3), + (USBVendorIDs.USB_VID_STREAMDOCK_6603, USBProductIDs.USB_PID_STREAMDOCK_N1, StreamDockN1), + (USBVendorIDs.USB_VID_STREAMDOCK_6603, USBProductIDs.USB_PID_STREAMDOCK_N1_B, StreamDockN1), + (USBVendorIDs.USB_VID_STREAMDOCK_1500, USBProductIDs.USB_PID_STREAMDOCK_N3_D, StreamDockN3), + (USBVendorIDs.USB_VID_STREAMDOCK_6602, USBProductIDs.USB_PID_STREAMDOCK_N3, StreamDockN3), + (USBVendorIDs.USB_VID_STREAMDOCK_6602, USBProductIDs.USB_PID_STREAMDOCK_N3_B, StreamDockN3), + (USBVendorIDs.USB_VID_STREAMDOCK_6602, USBProductIDs.USB_PID_STREAMDOCK_N3_C, StreamDockN3), + (USBVendorIDs.USB_VID_STREAMDOCK_6603, USBProductIDs.USB_PID_STREAMDOCK_N3, StreamDockN3), + (USBVendorIDs.USB_VID_STREAMDOCK_6603, USBProductIDs.USB_PID_STREAMDOCK_N3_B, StreamDockN3), + (USBVendorIDs.USB_VID_STREAMDOCK_6602, USBProductIDs.USB_PID_STREAMDOCK_N4, StreamDockN4), + (USBVendorIDs.USB_VID_STREAMDOCK_6603, USBProductIDs.USB_PID_STREAMDOCK_N4_B, StreamDockN4), + (USBVendorIDs.USB_VID_MIRABOX, USBProductIDs.USB_PID_STREAMDOCK_N4_PRO, StreamDockN4Pro), + (USBVendorIDs.USB_VID_MIRABOX, USBProductIDs.USB_PID_STREAMDOCK_N4_PRO_B, StreamDockN4Pro), + (USBVendorIDs.USB_VID_MIRABOX, USBProductIDs.USB_PID_STREAMDOCK_N4_PRO_C, StreamDockN4Pro), + (USBVendorIDs.USB_VID_MIRABOX, USBProductIDs.USB_PID_STREAMDOCK_XL, StreamDockXL), + (USBVendorIDs.USB_VID_MIRABOX, USBProductIDs.USB_PID_STREAMDOCK_XL_B, StreamDockXL), + (USBVendorIDs.USB_VID_MIRABOX, USBProductIDs.USB_PID_STREAMDOCK_M3, StreamDockM3), + (USBVendorIDs.USB_VID_STREAMDOCK_6603, USBProductIDs.USB_PID_STREAMDOCK_M18, StreamDockM18), + (USBVendorIDs.USB_VID_STREAMDOCK_6603, USBProductIDs.USB_PID_STREAMDOCK_M18_B, StreamDockM18), + (USBVendorIDs.USB_VID_STREAMDOCK_6603, USBProductIDs.USB_PID_STREAMDOCK_K1_PRO, StreamDockK1Pro), + (USBVendorIDs.USB_VID_STREAMDOCK_6603, USBProductIDs.USB_PID_STREAMDOCK_K1_PRO_B, StreamDockK1Pro), ] streamdecks = list() diff --git a/src/StreamDeck/Devices/StreamDock.py b/src/StreamDeck/Devices/StreamDock.py new file mode 100644 index 0000000..5c9eb4c --- /dev/null +++ b/src/StreamDeck/Devices/StreamDock.py @@ -0,0 +1,408 @@ +# Python Stream Deck Library +# Released under the MIT license +# +# dean [at] fourwalledcubicle [dot] com +# www.fourwalledcubicle.com +# +# MiraBox StreamDock support, non-official. + +""" +Shared base class for MiraBox StreamDock devices. + +StreamDock hardware speaks a text-tagged HID protocol rather than the binary +one Elgato uses. Every command is a fixed-size output report holding a 5-byte +header (``CRT\\x00\\x00``), a three-letter ASCII command and big-endian +parameters. Key images are JPEGs streamed as a run of further reports between a +``BAT`` (begin) and an ``STP`` (end, also "show what you sent"). + +The per-model differences are all data, so each model is a subclass that fills +in the tables below rather than code: + +``KEY_IMAGE_MAP`` + grid index (row-major, 0-based) to the hardware key id that ``BAT`` + addresses. Grid indices missing from the map have no screen behind them. +``BUTTON_MAP`` + input report event code to grid index. +``DIAL_TURN_MAP`` + input report event code to ``(dial index, +1 clockwise / -1 counter)``. +``DIAL_PRESS_MAP`` + input report event code to dial index. +``SWIPE_MAP`` + input report event code to ``+1`` right / ``-1`` left. + +Protocol credits +---------------- +The pure-Python transport this is based on was reverse engineered by Philip +Huppert (https://github.com/MiraboxSpace/StreamDock-Device-SDK/pull/76). The +``MOD`` mode select, its place in the connect handshake, and the ``CONNECT`` +keepalive and ``HAN`` semantics come from Steve Murr's independent work +(https://github.com/stevemurr/streamdock), corroborated against 4ndv's mirajazz +(https://github.com/4ndv/mirajazz), which produces byte-identical ``MOD`` frames +on N-series hardware. +""" + +import struct +import threading +import time +from typing import ClassVar + +from ..Transport.Transport import TransportError +from .StreamDeck import ControlType, DialEventType, StreamDeck, TouchscreenEventType + + +class StreamDock(StreamDeck): + """ + Represents a physically attached MiraBox StreamDock device. + """ + + #: Header every command report starts with. + CRT_HEADER: ClassVar[bytes] = b"CRT\x00\x00" + + #: Report id written as the first byte of every output report. Models that + #: use numbered reports also shift their input event bytes by one, which is + #: what EVENT_CODE_OFFSET accounts for. + REPORT_ID: ClassVar[int] = 0 + + #: Total output report size in bytes, report id included. Writes are padded + #: to exactly this length: the firmware reads fixed-size reports and treats + #: a short one as a framing error, which desynchronises the image stream. + OUTPUT_REPORT_LENGTH: ClassVar[int] = 513 + + #: Maximum input report size to ask the transport for. + INPUT_REPORT_LENGTH: ClassVar[int] = 513 + + #: Byte offset of the event code in an input report. The state byte follows + #: it. + EVENT_CODE_OFFSET: ClassVar[int] = 9 + + KEY_IMAGE_MAP: ClassVar[dict] = {} + BUTTON_MAP: ClassVar[dict] = {} + DIAL_TURN_MAP: ClassVar[dict] = {} + DIAL_PRESS_MAP: ClassVar[dict] = {} + SWIPE_MAP: ClassVar[dict] = {} + + #: Dial indices that are physically larger than the rest. Consumers can use + #: this to size their on-screen representation; it has no protocol meaning. + DIAL_LARGE_INDICES: ClassVar[tuple] = () + + #: Grid indices that are physical buttons with no screen behind them. They + #: report presses like any other key and are absent from KEY_IMAGE_MAP. + SCREENLESS_KEY_INDICES: ClassVar[tuple] = () + + #: Painted over every key on connect for panels where ``CLE`` clears the + #: framebuffer without visibly wiping the glass. Sized to cover the gaps + #: between the bezel cutouts, so it is usually larger than a key image. + SCREEN_CLEAR_IMAGE: ClassVar[list] = [] + + #: Framing for models whose keys are cutouts in one shared panel rather than + #: separate screens. See :meth:`key_image_format`. + KEY_CELL_SIZE: ClassVar[tuple] = () + KEY_ICON_SIZE: ClassVar[tuple] = () + KEY_COL_X_OFFSETS: ClassVar[tuple] = () + KEY_ROW_Y_OFFSETS: ClassVar[tuple] = () + + #: Device operating modes, selected with ``MOD``. Some models boot into + #: KEYBOARD mode and ignore host-drawn images until told otherwise. + MODE_KEYBOARD: ClassVar[int] = 1 + MODE_CALCULATOR: ClassVar[int] = 2 + MODE_SOFTWARE: ClassVar[int] = 3 + + #: The firmware treats a quiet host as gone and falls back to its onboard + #: behaviour, so CONNECT is sent well inside that window. + KEEPALIVE_INTERVAL: ClassVar[float] = 2.0 + + def __init__(self, device): + super().__init__(device) + + self._brightness = 100 + self._control_states = [False] * (self.KEY_COUNT + self.TOUCH_KEY_COUNT) + self._dial_press_states = [False] * self.DIAL_COUNT + + self._keepalive_thread = None + self._run_keepalive = False + self._keepalive_generation = 0 + + # ------------------------------------------------------------------ # + # Report encoding + # ------------------------------------------------------------------ # + @property + def _payload_length(self) -> int: + return self.OUTPUT_REPORT_LENGTH - 1 + + def _make_report(self, payload: bytes) -> bytes: + """ + Wraps a payload in a full output report: report id, payload, zero + padding out to the model's fixed report length. + """ + if len(payload) > self._payload_length: + raise ValueError(f"StreamDock payload is {len(payload)} bytes, report holds {self._payload_length}.") + + return bytes([self.REPORT_ID]) + bytes(payload).ljust(self._payload_length, b"\x00") + + def _command(self, command: str, params: bytes = b"", bulk: bytes = b"") -> None: + """ + Sends one command report, optionally followed by a bulk payload split + across further reports. + + The whole exchange is held under the deck's update lock. A keepalive or + a second image landing between a ``BAT`` and its ``STP`` is read by the + firmware as image data and corrupts the transfer. + """ + with self.update_lock: + self.device.write(self._make_report(self.CRT_HEADER + command.encode("ascii") + params)) + + for offset in range(0, len(bulk), self._payload_length): + self.device.write(self._make_report(bulk[offset:offset + self._payload_length])) + + # ------------------------------------------------------------------ # + # Lifecycle + # ------------------------------------------------------------------ # + def open(self, resume_from_suspend: bool = True) -> None: + super().open(resume_from_suspend) + self._setup_keepalive() + + def close(self) -> None: + self._run_keepalive = False + + # Note that the vendor "disconnect" command (CLE..DC) is deliberately + # not sent here. On the N3 it latches the panel into a state where it + # acknowledges display writes and renders none of them, until the device + # is physically replugged - input keeps working the whole time, so the + # deck looks alive but never redraws. Releasing the handle on its own + # leaves the panel writable for the next open(). + super().close() + + def _setup_keepalive(self) -> None: + """ + Starts the keepalive thread, retiring a previous one if the deck is + being reopened after a transport error. + + A superseded thread is not joined. It sends under the deck's update + lock, and open() may well be called by someone already holding that + lock, so waiting for it here could deadlock. It is retired by bumping + the generation instead and exits on its own; a send that lands in the + meantime fails harmlessly against the closed handle. + """ + self._keepalive_generation += 1 + self._run_keepalive = True + + self._keepalive_thread = threading.Thread(target=self._keepalive, args=(self._keepalive_generation,)) + self._keepalive_thread.daemon = True + self._keepalive_thread.start() + + def _keepalive(self, generation: int) -> None: + while self._run_keepalive and generation == self._keepalive_generation: + # Slept in slices so close() does not have to wait out a full interval. + deadline = time.monotonic() + self.KEEPALIVE_INTERVAL + while self._run_keepalive and time.monotonic() < deadline: + time.sleep(0.1) + + if not self._run_keepalive or generation != self._keepalive_generation: + return + + try: + self._command("CONNECT") + except (TransportError, ValueError): + # The read thread owns reconnecting; it sees the same error. + return + + def _reset_key_stream(self) -> None: + """ + Runs the connect handshake and repaints the panel. + + Called by :meth:`~StreamDeck.open` before the read thread starts, and + again by every reconnect, so it has to be safe to repeat. The order + matches what the vendor software sends: select software mode, wake the + panel, set brightness, then clear. + """ + self.set_mode(self.MODE_SOFTWARE) + self.wake_screen() + self.set_brightness(self._brightness) + self._command("CLE", struct.pack(">HB", 0, 0xFF)) + + if self.SCREEN_CLEAR_IMAGE: + blank = bytes(self.SCREEN_CLEAR_IMAGE) + for hardware_key in sorted(set(self.KEY_IMAGE_MAP.values())): + self._command("BAT", struct.pack(">IB", len(blank), hardware_key), blank) + + self.refresh_screen() + + def reset(self) -> None: + self._reset_key_stream() + + # ------------------------------------------------------------------ # + # Panel control + # ------------------------------------------------------------------ # + def set_mode(self, mode: int) -> None: + """ + Selects the device operating mode. Host-drawn images are only honoured + in :attr:`MODE_SOFTWARE`. + + :param int mode: one of MODE_KEYBOARD, MODE_CALCULATOR, MODE_SOFTWARE. + """ + self._command("MOD", b"\x00\x00" + bytes([0x30 + (mode & 0x0F)])) + + def wake_screen(self) -> None: + """ + Wakes the panel (``DIS``), reversing :meth:`sleep_screen`. + """ + self._command("DIS") + + def sleep_screen(self) -> None: + """ + Turns the panel off (``HAN``). + + This is an explicit request, not something to reach for on a screen + lock: some N3 firmware latches after ``HAN`` and ignores every later + display write until the device is replugged. Drawing black keys is the + safe way to blank a StreamDock. + """ + self._command("HAN") + + def refresh_screen(self) -> None: + """ + Shows everything written since the last refresh (``STP``). StreamDock + panels hold written pixels back until this arrives, so a full page of + key images can be flushed with one call. + """ + self._command("STP") + + def set_brightness(self, percent) -> None: + if isinstance(percent, float): + percent = int(100.0 * percent) + + percent = min(max(percent, 0), 100) + self._brightness = percent + + self._command("LIG", struct.pack(">HB", 0, percent)) + + # ------------------------------------------------------------------ # + # Images + # ------------------------------------------------------------------ # + def key_image_format(self) -> dict: + """ + Extends the base format with the framing a shared-panel model needs. + + On those models the keys are cutouts in one screen that the firmware + tiles at a fixed pitch, and the cutout is not centred on its tile. The + extra entries tell :mod:`~StreamDeck.ImageHelpers.PILHelper` to scale + the icon to ``icon_size`` and paste it into a ``cell_size`` canvas, + shifted per column and row, before the usual rotation. Models with + genuinely separate key screens leave them empty and are unaffected. + """ + image_format = super().key_image_format() + + if self.KEY_CELL_SIZE: + image_format.update({ + 'cell_size': self.KEY_CELL_SIZE, + 'icon_size': self.KEY_ICON_SIZE, + 'col_x_offsets': self.KEY_COL_X_OFFSETS, + 'row_y_offsets': self.KEY_ROW_Y_OFFSETS, + 'cols': self.KEY_COLS, + }) + + return image_format + + def set_key_image(self, key: int, image) -> None: + if min(max(key, 0), self.KEY_COUNT - 1) != key: + raise IndexError(f"Invalid key index {key}.") + + hardware_key = self.KEY_IMAGE_MAP.get(key) + if hardware_key is None: + # A physical button with no screen behind it. + return + + if image is None: + self._command("CLE", struct.pack(">HB", 0, hardware_key)) + return + + image = bytes(image) + self._command("BAT", struct.pack(">IB", len(image), hardware_key), image) + + def set_key_color(self, key: int, r: int, g: int, b: int) -> None: + # StreamDock keys are JPEG screens; the screenless buttons have no LED. + pass + + def set_touchscreen_image(self, image, x_pos: int = 0, y_pos: int = 0, width: int = 0, height: int = 0): + pass + + def set_screen_image(self, image): + pass + + # ------------------------------------------------------------------ # + # Device information + # ------------------------------------------------------------------ # + def large_dial_indices(self) -> set: + """ + Retrieves the dials that are physically larger than the rest, so a + consumer can draw them to match the hardware. + + :rtype: set(int) + :return: Indices of the oversized dials, empty if they are all alike. + """ + return set(self.DIAL_LARGE_INDICES) + + def screenless_key_indices(self) -> set: + """ + Retrieves the keys that are plain buttons with no screen, so a consumer + can offer press actions for them without an image editor. + + :rtype: set(int) + :return: Indices of the screenless keys, empty if every key has one. + """ + return set(self.SCREENLESS_KEY_INDICES) + + def get_serial_number(self) -> str: + return self.device.serial_number() + + def get_firmware_version(self) -> str: + version = self.device.read_input(self.REPORT_ID, self._payload_length) + return self._extract_string(version[1:]) + + # ------------------------------------------------------------------ # + # Input + # ------------------------------------------------------------------ # + def _read_control_states(self): + report = self.device.read(self.INPUT_REPORT_LENGTH) + if report is None: + return None + + offset = self.EVENT_CODE_OFFSET + if len(report) < offset + 2: + return None + + if report[9] == 0xFF: + # Write acknowledgement rather than an input event. + return None + + code, state = report[offset], report[offset + 1] + + if code in self.BUTTON_MAP: + index = self.BUTTON_MAP[code] + if index < len(self._control_states): + self._control_states[index] = (state == 0x01) + return {ControlType.KEY: list(self._control_states)} + + if code in self.DIAL_TURN_MAP: + dial, amount = self.DIAL_TURN_MAP[code] + turns = [0] * self.DIAL_COUNT + if dial < self.DIAL_COUNT: + turns[dial] = amount + return {ControlType.DIAL: {DialEventType.TURN: turns}} + + if code in self.DIAL_PRESS_MAP: + dial = self.DIAL_PRESS_MAP[code] + if dial < self.DIAL_COUNT: + self._dial_press_states[dial] = (state == 0x01) + return {ControlType.DIAL: {DialEventType.PUSH: list(self._dial_press_states)}} + + if code in self.SWIPE_MAP: + # Reported as a drag across the full width, which is all a consumer + # needs to tell left from right. + if self.SWIPE_MAP[code] < 0: + value = {"x": 1, "x_out": 0, "y": 0} + else: + value = {"x": 0, "x_out": 1, "y": 0} + return {ControlType.TOUCHSCREEN: (TouchscreenEventType.DRAG, value)} + + return None diff --git a/src/StreamDeck/Devices/StreamDock293.py b/src/StreamDeck/Devices/StreamDock293.py new file mode 100644 index 0000000..acd15be --- /dev/null +++ b/src/StreamDeck/Devices/StreamDock293.py @@ -0,0 +1,52 @@ +# Python Stream Deck Library +# Released under the MIT license +# +# dean [at] fourwalledcubicle [dot] com +# www.fourwalledcubicle.com +# +# MiraBox StreamDock support, non-official. + +from typing import ClassVar + +from .StreamDock import StreamDock + + +class StreamDock293(StreamDock): + """ + Represents a physically attached MiraBox StreamDock 293 device. + + Not verified against hardware. The key grid, image mapping and input + codes come from the reverse-engineered protocol tables credited in + :mod:`~StreamDeck.Devices.StreamDock`. + """ + + KEY_COUNT = 15 + KEY_COLS = 5 + KEY_ROWS = 3 + + KEY_PIXEL_WIDTH = 100 + KEY_PIXEL_HEIGHT = 100 + KEY_IMAGE_FORMAT = "JPEG" + KEY_FLIP = (False, False) + KEY_ROTATION = 180 + + DIAL_COUNT = 0 + + DECK_TYPE = "StreamDock 293" + DECK_VISUAL = True + DECK_TOUCH = False + + OUTPUT_REPORT_LENGTH = 513 + INPUT_REPORT_LENGTH = 513 + + KEY_IMAGE_MAP: ClassVar[dict] = { + 0x00: 11, 0x01: 12, 0x02: 13, 0x03: 14, 0x04: 15, + 0x05: 6, 0x06: 7, 0x07: 8, 0x08: 9, 0x09: 10, + 0x0a: 1, 0x0b: 2, 0x0c: 3, 0x0d: 4, 0x0e: 5, + } + + BUTTON_MAP: ClassVar[dict] = { + 0x01: 10, 0x02: 11, 0x03: 12, 0x04: 13, 0x05: 14, + 0x06: 5, 0x07: 6, 0x08: 7, 0x09: 8, 0x0a: 9, + 0x0b: 0, 0x0c: 1, 0x0d: 2, 0x0e: 3, 0x0f: 4, + } diff --git a/src/StreamDeck/Devices/StreamDock293V3.py b/src/StreamDeck/Devices/StreamDock293V3.py new file mode 100644 index 0000000..62b0ab1 --- /dev/null +++ b/src/StreamDeck/Devices/StreamDock293V3.py @@ -0,0 +1,52 @@ +# Python Stream Deck Library +# Released under the MIT license +# +# dean [at] fourwalledcubicle [dot] com +# www.fourwalledcubicle.com +# +# MiraBox StreamDock support, non-official. + +from typing import ClassVar + +from .StreamDock import StreamDock + + +class StreamDock293V3(StreamDock): + """ + Represents a physically attached MiraBox StreamDock 293 V3 device. + + Not verified against hardware. The key grid, image mapping and input + codes come from the reverse-engineered protocol tables credited in + :mod:`~StreamDeck.Devices.StreamDock`. + """ + + KEY_COUNT = 15 + KEY_COLS = 5 + KEY_ROWS = 3 + + KEY_PIXEL_WIDTH = 112 + KEY_PIXEL_HEIGHT = 112 + KEY_IMAGE_FORMAT = "JPEG" + KEY_FLIP = (False, False) + KEY_ROTATION = 180 + + DIAL_COUNT = 0 + + DECK_TYPE = "StreamDock 293 V3" + DECK_VISUAL = True + DECK_TOUCH = False + + OUTPUT_REPORT_LENGTH = 1025 + INPUT_REPORT_LENGTH = 513 + + KEY_IMAGE_MAP: ClassVar[dict] = { + 0x00: 11, 0x01: 12, 0x02: 13, 0x03: 14, 0x04: 15, + 0x05: 6, 0x06: 7, 0x07: 8, 0x08: 9, 0x09: 10, + 0x0a: 1, 0x0b: 2, 0x0c: 3, 0x0d: 4, 0x0e: 5, + } + + BUTTON_MAP: ClassVar[dict] = { + 0x01: 10, 0x02: 11, 0x03: 12, 0x04: 13, 0x05: 14, + 0x06: 5, 0x07: 6, 0x08: 7, 0x09: 8, 0x0a: 9, + 0x0b: 0, 0x0c: 1, 0x0d: 2, 0x0e: 3, 0x0f: 4, + } diff --git a/src/StreamDeck/Devices/StreamDock293sV3.py b/src/StreamDeck/Devices/StreamDock293sV3.py new file mode 100644 index 0000000..046b22e --- /dev/null +++ b/src/StreamDeck/Devices/StreamDock293sV3.py @@ -0,0 +1,52 @@ +# Python Stream Deck Library +# Released under the MIT license +# +# dean [at] fourwalledcubicle [dot] com +# www.fourwalledcubicle.com +# +# MiraBox StreamDock support, non-official. + +from typing import ClassVar + +from .StreamDock import StreamDock + + +class StreamDock293sV3(StreamDock): + """ + Represents a physically attached MiraBox StreamDock 293s V3 device. + + Not verified against hardware. The key grid, image mapping and input + codes come from the reverse-engineered protocol tables credited in + :mod:`~StreamDeck.Devices.StreamDock`. + """ + + KEY_COUNT = 15 + KEY_COLS = 5 + KEY_ROWS = 3 + + KEY_PIXEL_WIDTH = 96 + KEY_PIXEL_HEIGHT = 96 + KEY_IMAGE_FORMAT = "JPEG" + KEY_FLIP = (False, False) + KEY_ROTATION = 90 + + DIAL_COUNT = 0 + + DECK_TYPE = "StreamDock 293s V3" + DECK_VISUAL = True + DECK_TOUCH = False + + OUTPUT_REPORT_LENGTH = 1025 + INPUT_REPORT_LENGTH = 513 + + KEY_IMAGE_MAP: ClassVar[dict] = { + 0x00: 13, 0x01: 10, 0x02: 7, 0x03: 4, 0x04: 1, + 0x05: 14, 0x06: 11, 0x07: 8, 0x08: 5, 0x09: 2, + 0x0a: 15, 0x0b: 12, 0x0c: 9, 0x0d: 6, 0x0e: 3, + } + + BUTTON_MAP: ClassVar[dict] = { + 0x01: 4, 0x02: 9, 0x03: 14, 0x04: 3, 0x05: 8, + 0x06: 13, 0x07: 2, 0x08: 7, 0x09: 12, 0x0a: 1, + 0x0b: 6, 0x0c: 11, 0x0d: 0, 0x0e: 5, 0x0f: 10, + } diff --git a/src/StreamDeck/Devices/StreamDockK1Pro.py b/src/StreamDeck/Devices/StreamDockK1Pro.py new file mode 100644 index 0000000..5ed4a41 --- /dev/null +++ b/src/StreamDeck/Devices/StreamDockK1Pro.py @@ -0,0 +1,62 @@ +# Python Stream Deck Library +# Released under the MIT license +# +# dean [at] fourwalledcubicle [dot] com +# www.fourwalledcubicle.com +# +# MiraBox StreamDock support, non-official. + +from typing import ClassVar + +from .StreamDock import StreamDock + + +class StreamDockK1Pro(StreamDock): + """ + Represents a physically attached MiraBox StreamDock K1 Pro device. + + The only model in the family that uses a numbered output report, which + also shifts its input event bytes along by one. + + Not verified against hardware. The key grid, image mapping and input + codes come from the reverse-engineered protocol tables credited in + :mod:`~StreamDeck.Devices.StreamDock`. + """ + + KEY_COUNT = 6 + KEY_COLS = 3 + KEY_ROWS = 2 + + KEY_PIXEL_WIDTH = 64 + KEY_PIXEL_HEIGHT = 64 + KEY_IMAGE_FORMAT = "JPEG" + KEY_FLIP = (False, False) + KEY_ROTATION = -90 + + DIAL_COUNT = 3 + + DECK_TYPE = "StreamDock K1 Pro" + DECK_VISUAL = True + DECK_TOUCH = False + + OUTPUT_REPORT_LENGTH = 1025 + INPUT_REPORT_LENGTH = 513 + REPORT_ID = 4 + EVENT_CODE_OFFSET = 10 + + KEY_IMAGE_MAP: ClassVar[dict] = { + 0x00: 5, 0x01: 3, 0x02: 1, 0x03: 6, 0x04: 4, + 0x05: 2, + } + + BUTTON_MAP: ClassVar[dict] = { + 0x01: 2, 0x02: 5, 0x03: 1, 0x04: 4, 0x05: 0, + 0x06: 3, + } + + DIAL_TURN_MAP: ClassVar[dict] = { + 0x50: (0, -1), 0x51: (0, +1), 0x60: (1, -1), + 0x61: (1, +1), 0x90: (2, -1), 0x91: (2, +1), + } + + DIAL_PRESS_MAP: ClassVar[dict] = {0x25: 0, 0x30: 1, 0x31: 2} diff --git a/src/StreamDeck/Devices/StreamDockM18.py b/src/StreamDeck/Devices/StreamDockM18.py new file mode 100644 index 0000000..ae722fa --- /dev/null +++ b/src/StreamDeck/Devices/StreamDockM18.py @@ -0,0 +1,52 @@ +# Python Stream Deck Library +# Released under the MIT license +# +# dean [at] fourwalledcubicle [dot] com +# www.fourwalledcubicle.com +# +# MiraBox StreamDock support, non-official. + +from typing import ClassVar + +from .StreamDock import StreamDock + + +class StreamDockM18(StreamDock): + """ + Represents a physically attached MiraBox StreamDock M18 device. + + Not verified against hardware. The key grid, image mapping and input + codes come from the reverse-engineered protocol tables credited in + :mod:`~StreamDeck.Devices.StreamDock`. + """ + + KEY_COUNT = 15 + KEY_COLS = 5 + KEY_ROWS = 3 + + KEY_PIXEL_WIDTH = 64 + KEY_PIXEL_HEIGHT = 64 + KEY_IMAGE_FORMAT = "JPEG" + KEY_FLIP = (False, False) + KEY_ROTATION = 0 + + DIAL_COUNT = 0 + + DECK_TYPE = "StreamDock M18" + DECK_VISUAL = True + DECK_TOUCH = False + + OUTPUT_REPORT_LENGTH = 1025 + INPUT_REPORT_LENGTH = 513 + + KEY_IMAGE_MAP: ClassVar[dict] = { + 0x00: 11, 0x01: 12, 0x02: 13, 0x03: 14, 0x04: 15, + 0x05: 6, 0x06: 7, 0x07: 8, 0x08: 9, 0x09: 10, + 0x0a: 1, 0x0b: 2, 0x0c: 3, 0x0d: 4, 0x0e: 5, + } + + BUTTON_MAP: ClassVar[dict] = { + 0x01: 10, 0x02: 11, 0x03: 12, 0x04: 13, 0x05: 14, + 0x06: 5, 0x07: 6, 0x08: 7, 0x09: 8, 0x0a: 9, + 0x0b: 0, 0x0c: 1, 0x0d: 2, 0x0e: 3, 0x0f: 4, + } diff --git a/src/StreamDeck/Devices/StreamDockM3.py b/src/StreamDeck/Devices/StreamDockM3.py new file mode 100644 index 0000000..ff50c30 --- /dev/null +++ b/src/StreamDeck/Devices/StreamDockM3.py @@ -0,0 +1,59 @@ +# Python Stream Deck Library +# Released under the MIT license +# +# dean [at] fourwalledcubicle [dot] com +# www.fourwalledcubicle.com +# +# MiraBox StreamDock support, non-official. + +from typing import ClassVar + +from .StreamDock import StreamDock + + +class StreamDockM3(StreamDock): + """ + Represents a physically attached MiraBox StreamDock M3 device. + + Not verified against hardware. The key grid, image mapping and input + codes come from the reverse-engineered protocol tables credited in + :mod:`~StreamDeck.Devices.StreamDock`. + """ + + KEY_COUNT = 15 + KEY_COLS = 5 + KEY_ROWS = 3 + + KEY_PIXEL_WIDTH = 96 + KEY_PIXEL_HEIGHT = 96 + KEY_IMAGE_FORMAT = "JPEG" + KEY_FLIP = (False, False) + KEY_ROTATION = 90 + + DIAL_COUNT = 3 + + DECK_TYPE = "StreamDock M3" + DECK_VISUAL = True + DECK_TOUCH = False + + OUTPUT_REPORT_LENGTH = 1025 + INPUT_REPORT_LENGTH = 513 + + KEY_IMAGE_MAP: ClassVar[dict] = { + 0x00: 11, 0x01: 12, 0x02: 13, 0x03: 14, 0x04: 15, + 0x05: 6, 0x06: 7, 0x07: 8, 0x08: 9, 0x09: 10, + 0x0a: 1, 0x0b: 2, 0x0c: 3, 0x0d: 4, 0x0e: 5, + } + + BUTTON_MAP: ClassVar[dict] = { + 0x01: 10, 0x02: 11, 0x03: 12, 0x04: 13, 0x05: 14, + 0x06: 5, 0x07: 6, 0x08: 7, 0x09: 8, 0x0a: 9, + 0x0b: 0, 0x0c: 1, 0x0d: 2, 0x0e: 3, 0x0f: 4, + } + + DIAL_TURN_MAP: ClassVar[dict] = { + 0x50: (0, -1), 0x51: (0, +1), 0x90: (1, -1), + 0x91: (1, +1), 0xa0: (2, -1), 0xa1: (2, +1), + } + + DIAL_PRESS_MAP: ClassVar[dict] = {0x33: 1, 0x35: 0, 0x37: 2} diff --git a/src/StreamDeck/Devices/StreamDockN1.py b/src/StreamDeck/Devices/StreamDockN1.py new file mode 100644 index 0000000..1f4cb50 --- /dev/null +++ b/src/StreamDeck/Devices/StreamDockN1.py @@ -0,0 +1,56 @@ +# Python Stream Deck Library +# Released under the MIT license +# +# dean [at] fourwalledcubicle [dot] com +# www.fourwalledcubicle.com +# +# MiraBox StreamDock support, non-official. + +from typing import ClassVar + +from .StreamDock import StreamDock + + +class StreamDockN1(StreamDock): + """ + Represents a physically attached MiraBox StreamDock N1 device. + + Not verified against hardware. The key grid, image mapping and input + codes come from the reverse-engineered protocol tables credited in + :mod:`~StreamDeck.Devices.StreamDock`. + """ + + KEY_COUNT = 15 + KEY_COLS = 5 + KEY_ROWS = 3 + + KEY_PIXEL_WIDTH = 96 + KEY_PIXEL_HEIGHT = 96 + KEY_IMAGE_FORMAT = "JPEG" + KEY_FLIP = (False, False) + KEY_ROTATION = 0 + + DIAL_COUNT = 1 + + DECK_TYPE = "StreamDock N1" + DECK_VISUAL = True + DECK_TOUCH = False + + OUTPUT_REPORT_LENGTH = 1025 + INPUT_REPORT_LENGTH = 513 + + KEY_IMAGE_MAP: ClassVar[dict] = { + 0x00: 1, 0x01: 2, 0x02: 3, 0x03: 4, 0x04: 5, + 0x05: 6, 0x06: 7, 0x07: 8, 0x08: 9, 0x09: 10, + 0x0a: 11, 0x0b: 12, 0x0c: 13, 0x0d: 14, 0x0e: 15, + } + + BUTTON_MAP: ClassVar[dict] = { + 0x01: 0, 0x02: 1, 0x03: 2, 0x04: 3, 0x05: 4, + 0x06: 5, 0x07: 6, 0x08: 7, 0x09: 8, 0x0a: 9, + 0x0b: 10, 0x0c: 11, 0x0d: 12, 0x0e: 13, 0x0f: 14, + } + + DIAL_TURN_MAP: ClassVar[dict] = {0x32: (0, -1), 0x33: (0, +1)} + + DIAL_PRESS_MAP: ClassVar[dict] = {0x23: 0} diff --git a/src/StreamDeck/Devices/StreamDockN3.py b/src/StreamDeck/Devices/StreamDockN3.py new file mode 100644 index 0000000..ddd6052 --- /dev/null +++ b/src/StreamDeck/Devices/StreamDockN3.py @@ -0,0 +1,151 @@ +# Python Stream Deck Library +# Released under the MIT license +# +# dean [at] fourwalledcubicle [dot] com +# www.fourwalledcubicle.com +# +# MiraBox StreamDock support, non-official. + +from typing import ClassVar + +from .StreamDock import StreamDock + + +class StreamDockN3(StreamDock): + """ + Represents a physically attached MiraBox StreamDock N3 device. + + Verified against hardware. The N3 has six LCD keys, three screenless + buttons below them and three knobs, the topmost of which is larger + than the other two. + + The six keys are not separate screens. They are cutouts in a single + 320x240 panel that the firmware tiles at an 85 pixel pitch, and each + cutout sits slightly off its tile, so an image sent as-is lands + visibly off-centre behind the bezel. KEY_CELL_SIZE and the offsets + below correct for that; the values were tuned on the device. + + Grid indices 6 to 8 are the screenless buttons. They report presses + but have no entry in KEY_IMAGE_MAP, so images addressed to them are + dropped rather than sent to a key that cannot show them. + """ + + KEY_COUNT = 9 + KEY_COLS = 3 + KEY_ROWS = 3 + + KEY_PIXEL_WIDTH = 85 + KEY_PIXEL_HEIGHT = 85 + KEY_IMAGE_FORMAT = "JPEG" + KEY_FLIP = (False, False) + KEY_ROTATION = 270 + + DIAL_COUNT = 3 + + DECK_TYPE = "StreamDock N3" + DECK_VISUAL = True + DECK_TOUCH = False + + OUTPUT_REPORT_LENGTH = 1025 + INPUT_REPORT_LENGTH = 513 + + KEY_IMAGE_MAP: ClassVar[dict] = { + 0x00: 1, 0x01: 2, 0x02: 3, 0x03: 4, 0x04: 5, + 0x05: 6, + } + + BUTTON_MAP: ClassVar[dict] = { + 0x01: 0, 0x02: 1, 0x03: 2, 0x04: 3, 0x05: 4, + 0x06: 5, 0x25: 6, 0x30: 7, 0x31: 8, + } + + DIAL_TURN_MAP: ClassVar[dict] = { + 0x50: (2, -1), 0x51: (2, +1), 0x60: (1, -1), + 0x61: (1, +1), 0x90: (0, -1), 0x91: (0, +1), + } + + DIAL_PRESS_MAP: ClassVar[dict] = {0x33: 0, 0x34: 1, 0x35: 2} + + DIAL_LARGE_INDICES: ClassVar[tuple] = (2,) + SCREENLESS_KEY_INDICES: ClassVar[tuple] = (6, 7, 8) + + # Each key is a cutout in one shared panel: scale the icon to 72x72 so a + # margin of bezel shows around it, centre it in the 85 pixel tile, then + # nudge it per column and row onto the cutout. + KEY_CELL_SIZE: ClassVar[tuple] = (85, 85) + KEY_ICON_SIZE: ClassVar[tuple] = (72, 72) + KEY_COL_X_OFFSETS: ClassVar[tuple] = (-6, -6, -1) + KEY_ROW_Y_OFFSETS: ClassVar[tuple] = (2, 8) + + # 112 x 112 black JPEG + SCREEN_CLEAR_IMAGE: ClassVar[list] = [ + 0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, + 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xff, 0xdb, 0x00, 0x43, + 0x00, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0xff, 0xdb, 0x00, 0x43, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0xff, 0xc0, 0x00, 0x11, 0x08, 0x00, 0x70, 0x00, 0x70, 0x03, + 0x01, 0x22, 0x00, 0x02, 0x11, 0x01, 0x03, 0x11, 0x01, 0xff, 0xc4, 0x00, + 0x1f, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, + 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0xff, 0xc4, 0x00, 0xb5, 0x10, 0x00, + 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, + 0x00, 0x01, 0x7d, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, + 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, + 0x91, 0xa1, 0x08, 0x23, 0x42, 0xb1, 0xc1, 0x15, 0x52, 0xd1, 0xf0, 0x24, + 0x33, 0x62, 0x72, 0x82, 0x09, 0x0a, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x25, + 0x26, 0x27, 0x28, 0x29, 0x2a, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, + 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4a, 0x53, 0x54, 0x55, 0x56, + 0x57, 0x58, 0x59, 0x5a, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, + 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7a, 0x83, 0x84, 0x85, 0x86, + 0x87, 0x88, 0x89, 0x8a, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, + 0x9a, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xb2, 0xb3, + 0xb4, 0xb5, 0xb6, 0xb7, 0xb8, 0xb9, 0xba, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, + 0xc7, 0xc8, 0xc9, 0xca, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, 0xd8, 0xd9, + 0xda, 0xe1, 0xe2, 0xe3, 0xe4, 0xe5, 0xe6, 0xe7, 0xe8, 0xe9, 0xea, 0xf1, + 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, 0xf9, 0xfa, 0xff, 0xc4, 0x00, + 0x1f, 0x01, 0x00, 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, + 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0xff, 0xc4, 0x00, 0xb5, 0x11, 0x00, + 0x02, 0x01, 0x02, 0x04, 0x04, 0x03, 0x04, 0x07, 0x05, 0x04, 0x04, 0x00, + 0x01, 0x02, 0x77, 0x00, 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31, + 0x06, 0x12, 0x41, 0x51, 0x07, 0x61, 0x71, 0x13, 0x22, 0x32, 0x81, 0x08, + 0x14, 0x42, 0x91, 0xa1, 0xb1, 0xc1, 0x09, 0x23, 0x33, 0x52, 0xf0, 0x15, + 0x62, 0x72, 0xd1, 0x0a, 0x16, 0x24, 0x34, 0xe1, 0x25, 0xf1, 0x17, 0x18, + 0x19, 0x1a, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x35, 0x36, 0x37, 0x38, 0x39, + 0x3a, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4a, 0x53, 0x54, 0x55, + 0x56, 0x57, 0x58, 0x59, 0x5a, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, + 0x6a, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7a, 0x82, 0x83, 0x84, + 0x85, 0x86, 0x87, 0x88, 0x89, 0x8a, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, + 0x98, 0x99, 0x9a, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, + 0xb2, 0xb3, 0xb4, 0xb5, 0xb6, 0xb7, 0xb8, 0xb9, 0xba, 0xc2, 0xc3, 0xc4, + 0xc5, 0xc6, 0xc7, 0xc8, 0xc9, 0xca, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, + 0xd8, 0xd9, 0xda, 0xe2, 0xe3, 0xe4, 0xe5, 0xe6, 0xe7, 0xe8, 0xe9, 0xea, + 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, 0xf9, 0xfa, 0xff, 0xda, 0x00, + 0x0c, 0x03, 0x01, 0x00, 0x02, 0x11, 0x03, 0x11, 0x00, 0x3f, 0x00, 0xff, + 0x00, 0x3f, 0xfa, 0x28, 0xa2, 0x80, 0x0a, 0x28, 0xa2, 0x80, 0x0a, 0x28, + 0xa2, 0x80, 0x0a, 0x28, 0xa2, 0x80, 0x0a, 0x28, 0xa2, 0x80, 0x0a, 0x28, + 0xa2, 0x80, 0x0a, 0x28, 0xa2, 0x80, 0x0a, 0x28, 0xa2, 0x80, 0x0a, 0x28, + 0xa2, 0x80, 0x0a, 0x28, 0xa2, 0x80, 0x0a, 0x28, 0xa2, 0x80, 0x0a, 0x28, + 0xa2, 0x80, 0x0a, 0x28, 0xa2, 0x80, 0x0a, 0x28, 0xa2, 0x80, 0x0a, 0x28, + 0xa2, 0x80, 0x0a, 0x28, 0xa2, 0x80, 0x0a, 0x28, 0xa2, 0x80, 0x0a, 0x28, + 0xa2, 0x80, 0x0a, 0x28, 0xa2, 0x80, 0x0a, 0x28, 0xa2, 0x80, 0x0a, 0x28, + 0xa2, 0x80, 0x0a, 0x28, 0xa2, 0x80, 0x0a, 0x28, 0xa2, 0x80, 0x0a, 0x28, + 0xa2, 0x80, 0x0a, 0x28, 0xa2, 0x80, 0x0a, 0x28, 0xa2, 0x80, 0x0a, 0x28, + 0xa2, 0x80, 0x0a, 0x28, 0xa2, 0x80, 0x0a, 0x28, 0xa2, 0x80, 0x0a, 0x28, + 0xa2, 0x80, 0x0a, 0x28, 0xa2, 0x80, 0x0a, 0x28, 0xa2, 0x80, 0x0a, 0x28, + 0xa2, 0x80, 0x0a, 0x28, 0xa2, 0x80, 0x0a, 0x28, 0xa2, 0x80, 0x0a, 0x28, + 0xa2, 0x80, 0x0a, 0x28, 0xa2, 0x80, 0x0a, 0x28, 0xa2, 0x80, 0x0a, 0x28, + 0xa2, 0x80, 0x0a, 0x28, 0xa2, 0x80, 0x0a, 0x28, 0xa2, 0x80, 0x0a, 0x28, + 0xa2, 0x80, 0x0a, 0x28, 0xa2, 0x80, 0x0a, 0x28, 0xa2, 0x80, 0x0a, 0x28, + 0xa2, 0x80, 0x0a, 0x28, 0xa2, 0x80, 0x0a, 0x28, 0xa2, 0x80, 0x0a, 0x28, + 0xa2, 0x80, 0x0a, 0x28, 0xa2, 0x80, 0x3f, 0xff, 0xd9 + ] diff --git a/src/StreamDeck/Devices/StreamDockN4.py b/src/StreamDeck/Devices/StreamDockN4.py new file mode 100644 index 0000000..9667b2d --- /dev/null +++ b/src/StreamDeck/Devices/StreamDockN4.py @@ -0,0 +1,50 @@ +# Python Stream Deck Library +# Released under the MIT license +# +# dean [at] fourwalledcubicle [dot] com +# www.fourwalledcubicle.com +# +# MiraBox StreamDock support, non-official. + +from typing import ClassVar + +from .StreamDock import StreamDock + + +class StreamDockN4(StreamDock): + """ + Represents a physically attached MiraBox StreamDock N4 device. + + Not verified against hardware. The key grid, image mapping and input + codes come from the reverse-engineered protocol tables credited in + :mod:`~StreamDeck.Devices.StreamDock`. + """ + + KEY_COUNT = 10 + KEY_COLS = 5 + KEY_ROWS = 2 + + KEY_PIXEL_WIDTH = 112 + KEY_PIXEL_HEIGHT = 112 + KEY_IMAGE_FORMAT = "JPEG" + KEY_FLIP = (False, False) + KEY_ROTATION = 180 + + DIAL_COUNT = 0 + + DECK_TYPE = "StreamDock N4" + DECK_VISUAL = True + DECK_TOUCH = False + + OUTPUT_REPORT_LENGTH = 1025 + INPUT_REPORT_LENGTH = 513 + + KEY_IMAGE_MAP: ClassVar[dict] = { + 0x00: 11, 0x01: 12, 0x02: 13, 0x03: 14, 0x04: 15, + 0x05: 6, 0x06: 7, 0x07: 8, 0x08: 9, 0x09: 10, + } + + BUTTON_MAP: ClassVar[dict] = { + 0x06: 5, 0x07: 6, 0x08: 7, 0x09: 8, 0x0a: 9, + 0x0b: 0, 0x0c: 1, 0x0d: 2, 0x0e: 3, 0x0f: 4, + } diff --git a/src/StreamDeck/Devices/StreamDockN4Pro.py b/src/StreamDeck/Devices/StreamDockN4Pro.py new file mode 100644 index 0000000..ad5e8eb --- /dev/null +++ b/src/StreamDeck/Devices/StreamDockN4Pro.py @@ -0,0 +1,64 @@ +# Python Stream Deck Library +# Released under the MIT license +# +# dean [at] fourwalledcubicle [dot] com +# www.fourwalledcubicle.com +# +# MiraBox StreamDock support, non-official. + +from typing import ClassVar + +from .StreamDock import StreamDock + + +class StreamDockN4Pro(StreamDock): + """ + Represents a physically attached MiraBox StreamDock N4 Pro device. + + The touch strip reports left and right swipes, delivered through the + touchscreen callback as drags. It cannot be drawn on, so DECK_TOUCH + stays False and there is no touchscreen image format. + + Not verified against hardware. The key grid, image mapping and input + codes come from the reverse-engineered protocol tables credited in + :mod:`~StreamDeck.Devices.StreamDock`. + """ + + KEY_COUNT = 10 + KEY_COLS = 5 + KEY_ROWS = 2 + + KEY_PIXEL_WIDTH = 112 + KEY_PIXEL_HEIGHT = 112 + KEY_IMAGE_FORMAT = "JPEG" + KEY_FLIP = (False, False) + KEY_ROTATION = 180 + + DIAL_COUNT = 4 + + DECK_TYPE = "StreamDock N4 Pro" + DECK_VISUAL = True + DECK_TOUCH = False + + OUTPUT_REPORT_LENGTH = 1025 + INPUT_REPORT_LENGTH = 513 + + KEY_IMAGE_MAP: ClassVar[dict] = { + 0x00: 11, 0x01: 12, 0x02: 13, 0x03: 14, 0x04: 15, + 0x05: 6, 0x06: 7, 0x07: 8, 0x08: 9, 0x09: 10, + } + + BUTTON_MAP: ClassVar[dict] = { + 0x06: 5, 0x07: 6, 0x08: 7, 0x09: 8, 0x0a: 9, + 0x0b: 0, 0x0c: 1, 0x0d: 2, 0x0e: 3, 0x0f: 4, + } + + DIAL_TURN_MAP: ClassVar[dict] = { + 0x50: (1, -1), 0x51: (1, +1), 0x70: (3, -1), + 0x71: (3, +1), 0x90: (2, -1), 0x91: (2, +1), + 0xa0: (0, -1), 0xa1: (0, +1), + } + + DIAL_PRESS_MAP: ClassVar[dict] = {0x33: 2, 0x35: 1, 0x36: 3, 0x37: 0} + + SWIPE_MAP: ClassVar[dict] = {0x38: -1, 0x39: 1} diff --git a/src/StreamDeck/Devices/StreamDockXL.py b/src/StreamDeck/Devices/StreamDockXL.py new file mode 100644 index 0000000..6eb770c --- /dev/null +++ b/src/StreamDeck/Devices/StreamDockXL.py @@ -0,0 +1,65 @@ +# Python Stream Deck Library +# Released under the MIT license +# +# dean [at] fourwalledcubicle [dot] com +# www.fourwalledcubicle.com +# +# MiraBox StreamDock support, non-official. + +from typing import ClassVar + +from .StreamDock import StreamDock + + +class StreamDockXL(StreamDock): + """ + Represents a physically attached MiraBox StreamDock XL device. + + Not verified against hardware. The key grid, image mapping and input + codes come from the reverse-engineered protocol tables credited in + :mod:`~StreamDeck.Devices.StreamDock`. + """ + + KEY_COUNT = 32 + KEY_COLS = 8 + KEY_ROWS = 4 + + KEY_PIXEL_WIDTH = 80 + KEY_PIXEL_HEIGHT = 80 + KEY_IMAGE_FORMAT = "JPEG" + KEY_FLIP = (False, False) + KEY_ROTATION = 180 + + DIAL_COUNT = 2 + + DECK_TYPE = "StreamDock XL" + DECK_VISUAL = True + DECK_TOUCH = False + + OUTPUT_REPORT_LENGTH = 1025 + INPUT_REPORT_LENGTH = 513 + + KEY_IMAGE_MAP: ClassVar[dict] = { + 0x00: 25, 0x01: 26, 0x02: 27, 0x03: 28, 0x04: 29, + 0x05: 30, 0x06: 31, 0x07: 32, 0x08: 17, 0x09: 18, + 0x0a: 19, 0x0b: 20, 0x0c: 21, 0x0d: 22, 0x0e: 23, + 0x0f: 24, 0x10: 9, 0x11: 10, 0x12: 11, 0x13: 12, + 0x14: 13, 0x15: 14, 0x16: 15, 0x17: 16, 0x18: 1, + 0x19: 2, 0x1a: 3, 0x1b: 4, 0x1c: 5, 0x1d: 6, + 0x1e: 7, 0x1f: 8, + } + + BUTTON_MAP: ClassVar[dict] = { + 0x01: 24, 0x02: 25, 0x03: 26, 0x04: 27, 0x05: 28, + 0x06: 29, 0x07: 30, 0x08: 31, 0x09: 16, 0x0a: 17, + 0x0b: 18, 0x0c: 19, 0x0d: 20, 0x0e: 21, 0x0f: 22, + 0x10: 23, 0x11: 8, 0x12: 9, 0x13: 10, 0x14: 11, + 0x15: 12, 0x16: 13, 0x17: 14, 0x18: 15, 0x19: 0, + 0x1a: 1, 0x1b: 2, 0x1c: 3, 0x1d: 4, 0x1e: 5, + 0x1f: 6, 0x20: 7, + } + + DIAL_TURN_MAP: ClassVar[dict] = { + 0x21: (0, +1), 0x23: (0, -1), 0x24: (1, -1), + 0x26: (1, +1), + } diff --git a/src/StreamDeck/ImageHelpers/PILHelper.py b/src/StreamDeck/ImageHelpers/PILHelper.py index 1f2eb72..fb84f14 100644 --- a/src/StreamDeck/ImageHelpers/PILHelper.py +++ b/src/StreamDeck/ImageHelpers/PILHelper.py @@ -36,8 +36,41 @@ def _scale_image(image, image_format, margins=(0, 0, 0, 0), background='black'): return final_image -def _to_native_format(image, image_format): - if image.size != image_format['size']: +def _frame_in_panel_cell(image, image_format, key): + """ + Frames a key image for a device whose keys are cutouts in one shared panel. + + The firmware tiles key images at a fixed pitch, but the cutout a key shows + through is not centred on its tile, so an image sent as-is lands off-centre + behind the bezel. The image is scaled to 'icon_size', centred in a + 'cell_size' canvas and shifted by the per-column and per-row offsets that + put it back under its cutout. Only devices that publish 'cell_size' in their + key image format go through this; everything else is untouched. + """ + cell_size = image_format['cell_size'] + icon_size = image_format.get('icon_size') + cols = image_format.get('cols') or 1 + + content = image.convert("RGB") + if icon_size and content.size != tuple(icon_size): + content = content.resize(tuple(icon_size)) + + col_offsets = image_format.get('col_x_offsets') or () + row_offsets = image_format.get('row_y_offsets') or () + col, row = key % cols, key // cols + + canvas = _create_image({'size': tuple(cell_size)}, 'black') + x = (col_offsets[col] if col < len(col_offsets) else 0) + (canvas.width - content.width) // 2 + y = (row_offsets[row] if row < len(row_offsets) else 0) + (canvas.height - content.height) // 2 + canvas.paste(content, (x, y)) + + return canvas + + +def _to_native_format(image, image_format, key=None): + if image_format.get('cell_size') and key is not None: + image = _frame_in_panel_cell(image, image_format, key) + elif image.size != image_format['size']: image.thumbnail(image_format['size']) if image_format['rotation']: @@ -205,7 +238,7 @@ def to_native_format(deck: StreamDeck, image: Image.Image) -> bytes: return to_native_key_format(deck, image) -def to_native_key_format(deck: StreamDeck, image: Image.Image) -> bytes: +def to_native_key_format(deck: StreamDeck, image: Image.Image, key: int | None = None) -> bytes: """ Converts a given PIL image to the native key image format for a StreamDeck, suitable for passing to :func:`~StreamDeck.set_key_image`. @@ -215,11 +248,14 @@ def to_native_key_format(deck: StreamDeck, image: Image.Image) -> bytes: :param StreamDeck deck: StreamDeck device to generate a compatible native image for. :param PIL.Image image: PIL Image to convert to the native StreamDeck image format + :param int key: Key the image is destined for. Only needed on devices whose + keys are cutouts in one shared panel, where each key is + framed differently; ignored by every other device. :rtype: enumerable() :return: Image converted to the given StreamDeck's native format """ - return _to_native_format(image, deck.key_image_format()) + return _to_native_format(image, deck.key_image_format(), key) def to_native_touchscreen_format(deck: StreamDeck, image: Image.Image) -> bytes: diff --git a/src/StreamDeck/ProductIDs.py b/src/StreamDeck/ProductIDs.py index 04fd696..030f2bc 100644 --- a/src/StreamDeck/ProductIDs.py +++ b/src/StreamDeck/ProductIDs.py @@ -14,6 +14,14 @@ class USBVendorIDs: USB_VID_ELGATO = 0x0fd9 USB_VID_MIRABOX = 0x5548 + # StreamDock hardware ships under several vendor ids rather than one. They + # are not registered with the USB-IF and do not map to distinct companies, + # so they are named after the devices that use them. + USB_VID_STREAMDOCK_1500 = 0x1500 + USB_VID_STREAMDOCK_5500 = 0x5500 + USB_VID_STREAMDOCK_6602 = 0x6602 + USB_VID_STREAMDOCK_6603 = 0x6603 + class USBProductIDs: """ @@ -40,3 +48,29 @@ class USBProductIDs: USB_PID_STREAMDECK_XL_V2_MODULE = 0x00ba USB_PID_MIRABOX_STREAMDOCK_293S = 0x6670 + + # MiraBox StreamDock. Several models answer to more than one product id, + # across more than one vendor id, depending on the firmware revision. + USB_PID_STREAMDOCK_293 = 0x1001 + USB_PID_STREAMDOCK_293_V3 = 0x1005 + USB_PID_STREAMDOCK_293_V3_B = 0x1006 + USB_PID_STREAMDOCK_293_V3_C = 0x1010 + USB_PID_STREAMDOCK_293S_V3 = 0x1014 + USB_PID_STREAMDOCK_K1_PRO = 0x1015 + USB_PID_STREAMDOCK_K1_PRO_B = 0x1019 + USB_PID_STREAMDOCK_M3 = 0x1020 + USB_PID_STREAMDOCK_M18 = 0x1009 + USB_PID_STREAMDOCK_M18_B = 0x1012 + USB_PID_STREAMDOCK_N1 = 0x1000 + USB_PID_STREAMDOCK_N1_B = 0x1011 + USB_PID_STREAMDOCK_N3 = 0x1002 + USB_PID_STREAMDOCK_N3_B = 0x1003 + USB_PID_STREAMDOCK_N3_C = 0x2929 + USB_PID_STREAMDOCK_N3_D = 0x3001 + USB_PID_STREAMDOCK_N4 = 0x1001 + USB_PID_STREAMDOCK_N4_B = 0x1007 + USB_PID_STREAMDOCK_N4_PRO = 0x1008 + USB_PID_STREAMDOCK_N4_PRO_B = 0x1021 + USB_PID_STREAMDOCK_N4_PRO_C = 0x1023 + USB_PID_STREAMDOCK_XL = 0x1028 + USB_PID_STREAMDOCK_XL_B = 0x1031 diff --git a/test/test_streamdock.py b/test/test_streamdock.py new file mode 100644 index 0000000..61e97b2 --- /dev/null +++ b/test/test_streamdock.py @@ -0,0 +1,342 @@ +#!/usr/bin/env python3 + +# Python Stream Deck Library +# Released under the MIT license +# +# dean [at] fourwalledcubicle [dot] com +# www.fourwalledcubicle.com +# +# MiraBox StreamDock support, non-official. + +""" +Checks the StreamDock wire format and input decoding against a recording +transport, so the parts that are hard to eyeball stay correct: report framing, +image chunking, and the per-model event tables. + +Run it the same way as test.py: + + python3 test/test_streamdock.py +""" + +import io +import logging +import os +import struct +import sys + +from PIL import Image, ImageDraw + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../src')) +from StreamDeck.DeviceManager import DeviceManager +from StreamDeck.Devices.StreamDock import StreamDock +from StreamDeck.Devices.StreamDockN3 import StreamDockN3 +from StreamDeck.Devices.StreamDockN4Pro import StreamDockN4Pro +from StreamDeck.Devices.StreamDeck import ControlType, DialEventType, TouchscreenEventType +from StreamDeck.ImageHelpers import PILHelper +from StreamDeck.Transport.Transport import Transport + +logger = logging.getLogger(__name__) + + +class RecordingDevice(Transport.Device): + """Transport device that keeps every report written to it.""" + + def __init__(self, vid=0x6603, pid=0x1002): + self.vid = vid + self.pid = pid + self.writes = [] + self.reads = [] + self.opened = False + + def open(self): + self.opened = True + + def close(self): + self.opened = False + + def is_open(self): + return self.opened + + def connected(self): + return True + + def vendor_id(self): + return self.vid + + def product_id(self): + return self.pid + + def serial_number(self): + return "RECORDING" + + def path(self): + return f"{self.vid}:{self.pid}" + + def write_feature(self, payload): + return len(payload) + + def read_feature(self, report_id, length): + return bytearray(length) + + def read_input(self, report_id, length): + return bytearray(length) + + def write(self, payload): + self.writes.append(bytes(payload)) + return len(payload) + + def read(self, length): + return self.reads.pop(0) if self.reads else None + + +def commands(device): + """Report payloads that start with the CRT header, as (command, params).""" + found = [] + for report in device.writes: + payload = report[1:] + if payload.startswith(StreamDock.CRT_HEADER): + body = payload[len(StreamDock.CRT_HEADER):] + found.append((body[:3].decode('ascii', 'replace'), body[3:])) + return found + + +def check(condition, description): + if not condition: + raise AssertionError(description) + logger.info(" ok: %s", description) + + +def test_report_framing(): + """Every report is exactly one report id plus a full-length payload.""" + device = RecordingDevice() + deck = StreamDockN3(device) + deck.device.open() + + deck.set_brightness(50) + + check( + all(len(w) == StreamDockN3.OUTPUT_REPORT_LENGTH for w in device.writes), + f"every report is {StreamDockN3.OUTPUT_REPORT_LENGTH} bytes", + ) + check( + all(w[0] == StreamDockN3.REPORT_ID for w in device.writes), + "every report leads with the report id", + ) + sent = commands(device) + check(len(sent) == 1 and sent[0][0] == "LIG", "brightness is sent as a LIG command") + check(sent[0][1][:3] == struct.pack(">HB", 0, 50), "LIG carries the percentage") + check(set(sent[0][1][3:]) == {0}, "the rest of the report is zero padding") + + +def test_numbered_report_id(): + """The K1 Pro's non-zero report id reaches the wire.""" + from StreamDeck.Devices.StreamDockK1Pro import StreamDockK1Pro + + device = RecordingDevice(vid=0x6603, pid=0x1015) + deck = StreamDockK1Pro(device) + deck.device.open() + + deck.set_brightness(10) + + check(device.writes[0][0] == 4, "K1 Pro reports carry report id 4") + + +def test_image_chunking(): + """An image is framed by BAT, split across whole reports, then shown by STP.""" + device = RecordingDevice() + deck = StreamDockN3(device) + deck.device.open() + + payload_length = StreamDockN3.OUTPUT_REPORT_LENGTH - 1 + # Leading zero bytes in a chunk are the interesting case: they must survive + # rather than be read as padding or framing. + image = b"\x00" * payload_length + b"\x00\xab\xcd" + b"\xff" * 40 + deck.set_key_image(0, image) + deck.refresh_screen() + + sent = commands(device) + check(sent[0][0] == "BAT", "the transfer opens with BAT") + check(sent[0][1][:5] == struct.pack(">IB", len(image), StreamDockN3.KEY_IMAGE_MAP[0]), + "BAT carries the image length and the hardware key id") + check(sent[-1][0] == "STP", "the transfer is shown with STP") + + # Reports between BAT and STP are the image, in order and unpadded until the end. + body = b"".join(w[1:] for w in device.writes[1:-1]) + check(body[:len(image)] == image, "the image arrives byte for byte, leading zeros included") + check(len(device.writes) == 1 + 2 + 1, "the image splits into exactly two reports") + + +def test_screenless_keys_drop_images(): + """A key with no screen behind it is not sent an image.""" + device = RecordingDevice() + deck = StreamDockN3(device) + deck.device.open() + + for key in StreamDockN3.SCREENLESS_KEY_INDICES: + deck.set_key_image(key, b"\xff" * 32) + + check(device.writes == [], "images for the three screenless buttons are dropped") + + deck.set_key_image(0, b"\xff" * 32) + check(len(device.writes) > 0, "images for a key with a screen are still sent") + + +def test_connect_handshake(): + """Opening a deck runs the vendor connect sequence in order.""" + device = RecordingDevice() + deck = StreamDockN3(device) + deck.open() + try: + order = [name for name, _ in commands(device)] + check(order[0] == "MOD", "software mode is selected first") + check(order[1] == "DIS", "the panel is woken next") + check(order[2] == "LIG", "brightness follows") + check("CLE" in order, "the panel is cleared") + check(order[-1] == "STP", "the handshake ends by showing the cleared panel") + finally: + deck.reconnect_after_suspend = False + deck.close() + + +def test_close_sends_no_disconnect(): + """Closing must not send the vendor disconnect, which latches the panel.""" + device = RecordingDevice() + deck = StreamDockN3(device) + deck.open() + deck.reconnect_after_suspend = False + device.writes.clear() + deck.close() + + check(all(b"DC" not in payload for _, payload in commands(device)), + "close() sends no DC disconnect command") + + +def test_input_decoding(): + """Report codes land on the right key, dial and swipe events.""" + device = RecordingDevice() + deck = StreamDockN3(device) + deck.device.open() + + def report(code, state): + data = bytearray(StreamDockN3.INPUT_REPORT_LENGTH) + data[StreamDockN3.EVENT_CODE_OFFSET] = code + data[StreamDockN3.EVENT_CODE_OFFSET + 1] = state + return bytes(data) + + device.reads = [report(0x01, 0x01)] + states = deck._read_control_states() + check(states[ControlType.KEY][0] is True, "key code 0x01 presses grid key 0") + + device.reads = [report(0x31, 0x01)] + states = deck._read_control_states() + check(states[ControlType.KEY][8] is True, "key code 0x31 presses the last screenless button") + + device.reads = [report(0x51, 0x00)] + states = deck._read_control_states() + check(states[ControlType.DIAL][DialEventType.TURN] == [0, 0, 1], + "code 0x51 turns the top dial clockwise") + + device.reads = [report(0x34, 0x01)] + states = deck._read_control_states() + check(states[ControlType.DIAL][DialEventType.PUSH] == [False, True, False], + "code 0x34 presses the middle dial") + + # A write acknowledgement is not an input event. + ack = bytearray(StreamDockN3.INPUT_REPORT_LENGTH) + ack[9] = 0xFF + device.reads = [bytes(ack)] + check(deck._read_control_states() is None, "write acknowledgements are ignored") + + +def test_swipe_decoding(): + """The N4 Pro touch strip reports swipes as drags.""" + device = RecordingDevice(vid=0x5548, pid=0x1008) + deck = StreamDockN4Pro(device) + deck.device.open() + + data = bytearray(StreamDockN4Pro.INPUT_REPORT_LENGTH) + data[StreamDockN4Pro.EVENT_CODE_OFFSET] = 0x39 + device.reads = [bytes(data)] + + event_type, value = deck._read_control_states()[ControlType.TOUCHSCREEN] + check(event_type is TouchscreenEventType.DRAG, "a swipe is reported as a drag") + check(value["x_out"] > value["x"], "code 0x39 drags to the right") + check(deck.is_touch() is False, "the strip has no drawable surface, so is_touch() is False") + + +def test_shared_panel_framing(): + """N3 key images are framed into their cutout before being rotated.""" + device = RecordingDevice() + deck = StreamDockN3(device) + + image_format = deck.key_image_format() + check(image_format['cell_size'] == (85, 85), "the N3 publishes its 85 pixel tile") + check(image_format['icon_size'] == (72, 72), "the icon is scaled to leave a margin") + + source = PILHelper.create_key_image(deck) + draw = ImageDraw.Draw(source) + draw.rectangle((0, 0) + source.size, fill=(255, 0, 0)) + + native = PILHelper.to_native_key_format(deck, source, key=0) + framed = Image.open(io.BytesIO(native)) + + check(framed.size == (85, 85), "the framed image is one tile") + + # The icon is 72x72 inside an 85x85 tile, offset by (-6, +2) for key 0, and + # the whole tile is then rotated 270 degrees. Sample well inside the icon + # and well outside it to confirm both the scale and the shift. + pixels = framed.convert("RGB") + corner = pixels.getpixel((0, 0)) + check(sum(corner) < 60, "the tile margin around the icon stays black") + check(pixels.getpixel((42, 42))[0] > 200, "the icon itself is drawn in the middle of the tile") + + # A key on another column and row must be framed differently. + other = PILHelper.to_native_key_format(deck, source, key=5) + check(other != native, "keys in different tiles are framed differently") + + +def test_unframed_models_unchanged(): + """Models with separate key screens are not touched by the framing path.""" + from StreamDeck.Devices.StreamDock293 import StreamDock293 + + deck = StreamDock293(RecordingDevice(vid=0x5500, pid=0x1001)) + check('cell_size' not in deck.key_image_format(), "the 293 publishes no tile framing") + + source = PILHelper.create_key_image(deck) + with_key = PILHelper.to_native_key_format(deck, source, key=3) + without_key = PILHelper.to_native_key_format(deck, PILHelper.create_key_image(deck)) + check(with_key == without_key, "passing a key index changes nothing for the 293") + + +def test_enumeration(): + """Every registered StreamDock product id builds its device class.""" + decks = DeviceManager(transport="dummy").enumerate() + docks = [d for d in decks if isinstance(d, StreamDock)] + + check(len(docks) == 25, f"25 StreamDock product ids enumerate (got {len(docks)})") + + models = {d.deck_type() for d in docks} + expected = { + "StreamDock 293", "StreamDock 293 V3", "StreamDock 293s V3", "StreamDock N1", + "StreamDock N3", "StreamDock N4", "StreamDock N4 Pro", "StreamDock XL", + "StreamDock M18", "StreamDock M3", "StreamDock K1 Pro", + } + check(models == expected, f"all 11 models are reachable (missing {expected - models})") + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO, format="%(message)s") + + tests = [value for name, value in sorted(globals().items()) if name.startswith("test_")] + + failures = 0 + for test in tests: + logger.info("Running: %s", test.__name__) + try: + test() + except AssertionError as failure: + logger.error(" FAILED: %s", failure) + failures += 1 + + logger.info("%d of %d tests passed.", len(tests) - failures, len(tests)) + sys.exit(1 if failures else 0) From 15e8670dc9088dc861d7621fdb8edb2836d27341 Mon Sep 17 00:00:00 2001 From: Joe Goldin Date: Wed, 12 Aug 2026 14:16:20 -0700 Subject: [PATCH 2/2] Move the Stream Dock 293S onto the shared StreamDock base The 293S was the first of these devices supported here and arrived with its own standalone implementation. It speaks the same protocol as the rest of the StreamDock family, so it now derives from StreamDock like the others, as StreamDock293s. Mirabox293S still resolves to it, deprecated, so existing imports keep working. Its key maps, geometry, blank image and connect sequence are carried over as they were. The handshake is overridden rather than inherited: this is the one StreamDock here that has been in users' hands, so it keeps the bytes it shipped with instead of gaining the MOD mode select the rest of the family sends but nobody has run against 293S hardware. Two things did change on the wire, both of them the base class being right where the standalone version was not: * Reports go out at the 513 bytes the model declares. The original built them with payload[1:len(data)] = data, and a bytearray slice assignment of mismatched length resizes, so every report was 514 bytes. The payload itself was correct, so this only drops a stray trailing byte. * CLE now puts its key id in the fourth parameter byte, which is where the 293S was already putting it, and where the rest of the family was not. The others were addressing it one byte early, so a request to clear key n cleared key 0. It reads as CLE quietly doing nothing, which is consistent with the N3 needing black JPEGs painted over its panel to blank it - that workaround stays, since it also covers the gaps between the bezel cutouts. Migrating also picked up two things the base class was missing, both now supported for any model that needs them: firmware that reports a key once on release with no press event, which the 293S has and which the base synthesises the pair for, and blanking a key by drawing a black JPEG rather than clearing it, which is what the Elgato devices here already do. --- CHANGELOG | 10 +- README.md | 21 ++- doc/source/modules/devices.rst | 4 + src/StreamDeck/DeviceManager.py | 9 +- src/StreamDeck/Devices/Mirabox293S.py | 202 ++--------------------- src/StreamDeck/Devices/StreamDock.py | 90 +++++++--- src/StreamDeck/Devices/StreamDock293s.py | 148 +++++++++++++++++ src/StreamDeck/Devices/StreamDockN3.py | 4 +- test/test_streamdock.py | 98 ++++++++++- 9 files changed, 358 insertions(+), 228 deletions(-) create mode 100644 src/StreamDeck/Devices/StreamDock293s.py diff --git a/CHANGELOG b/CHANGELOG index 22f60cd..5f6cebb 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,9 +1,17 @@ Unreleased: - Added the MiraBox StreamDock family: 293, 293 V3, 293s V3, K1 Pro, M3, M18, - N1, N3, N4, N4 Pro and XL, across 25 product ids. They speak a text-tagged + N1, N3, N4, N4 Pro and XL, across 26 product ids. They speak a text-tagged HID protocol rather than Elgato's binary one, so they share a StreamDock base class that holds the protocol and per-model data tables. Only the N3 has been verified against hardware. + - Moved the Stream Dock 293S onto that base class as StreamDock293s, since it + speaks the same protocol. Mirabox293S still resolves to it and is + deprecated. The connect sequence and the key maps are unchanged; its + reports now go out at the 513 bytes it declares, rather than the 514 an + off-by-one slice assignment was producing. + - Fixed the CLE clear command addressing its key id one byte early, which + made it clear key 0 instead of the key asked for. The 293S, which had the + parameter right, is unaffected. - to_native_key_format() takes an optional key index, for devices whose keys are cutouts in one shared panel and are framed per key. Existing callers are unaffected. diff --git a/README.md b/README.md index 8a62bf0..9b7ca0c 100644 --- a/README.md +++ b/README.md @@ -34,15 +34,11 @@ variants: * StreamDeck Studio * StreamDeck XL -This fork additionally supports: - -* Mirabox Stream Dock 293S - -And the MiraBox StreamDock family, which speaks its own protocol rather than -Elgato's: +This fork additionally supports the MiraBox StreamDock family, which speaks its +own protocol rather than Elgato's: * StreamDock 293 and 293 V3 -* StreamDock 293s V3 +* StreamDock 293s (also known as the Mirabox Stream Dock 293S) and 293s V3 * StreamDock K1 Pro * StreamDock M3 and M18 * StreamDock N1 @@ -50,10 +46,11 @@ Elgato's: * StreamDock N4 and N4 Pro * StreamDock XL -Of these, the N3 has been verified against hardware. The rest are built from -the reverse-engineered protocol tables credited in `StreamDeck/Devices/StreamDock.py` -and are untested, so reports either way are welcome. Their key grids and knobs -are mapped; secondary LCD strips are not. +Of these, the 293s and the N3 have been verified against hardware. The rest are +built from the reverse-engineered protocol tables credited below and are +untested, so reports either way are welcome. Their key grids and knobs are +mapped; secondary LCD strips are not, except on the 293s, where the side strip +is driven as a sixth column of keys. ## Package Installation: @@ -127,6 +124,8 @@ list above, please let me know. The MiraBox StreamDock protocol is undocumented by the vendor. The support in this fork rests on independent reverse engineering by: +- [rescbr](https://github.com/rescbr), for the Stream Dock 293S, the first of + these devices supported here and the source of the shared protocol's framing - [Phaeilo](https://github.com/Phaeilo), for the pure-Python HID transport ([StreamDock-Device-SDK#76](https://github.com/MiraboxSpace/StreamDock-Device-SDK/pull/76)) - [stevemurr](https://github.com/stevemurr), for the MOD mode select, the connect diff --git a/doc/source/modules/devices.rst b/doc/source/modules/devices.rst index 0c8038e..8608736 100644 --- a/doc/source/modules/devices.rst +++ b/doc/source/modules/devices.rst @@ -87,6 +87,10 @@ StreamDeck XL Mirabox Stream Dock 293S ========================= +.. automodule:: StreamDeck.Devices.StreamDock293s + :members: + :show-inheritance: + .. automodule:: StreamDeck.Devices.Mirabox293S :members: :show-inheritance: diff --git a/src/StreamDeck/DeviceManager.py b/src/StreamDeck/DeviceManager.py index 4f97f7e..5710600 100644 --- a/src/StreamDeck/DeviceManager.py +++ b/src/StreamDeck/DeviceManager.py @@ -7,7 +7,6 @@ from collections.abc import Callable -from .Devices.Mirabox293S import Mirabox293S from .Devices.StreamDeck import StreamDeck from .Devices.StreamDeckMini import StreamDeckMini from .Devices.StreamDeckNeo import StreamDeckNeo @@ -19,6 +18,7 @@ from .Devices.StreamDeckStudio import StreamDeckStudio from .Devices.StreamDeckXL import StreamDeckXL from .Devices.StreamDock293 import StreamDock293 +from .Devices.StreamDock293s import StreamDock293s from .Devices.StreamDock293sV3 import StreamDock293sV3 from .Devices.StreamDock293V3 import StreamDock293V3 from .Devices.StreamDockK1Pro import StreamDockK1Pro @@ -159,11 +159,8 @@ def _default_factory(self) -> list[StreamDeck]: (USBVendorIDs.USB_VID_ELGATO, USBProductIDs.USB_PID_STREAMDECK_STUDIO, StreamDeckStudio), (USBVendorIDs.USB_VID_ELGATO, USBProductIDs.USB_PID_STREAMDECK_PLUS, StreamDeckPlus), (USBVendorIDs.USB_VID_ELGATO, USBProductIDs.USB_PID_STREAMDECK_PLUS_XL, StreamDeckPlusXL), - (USBVendorIDs.USB_VID_MIRABOX, USBProductIDs.USB_PID_MIRABOX_STREAMDOCK_293S, Mirabox293S), - - # MiraBox StreamDock. The 293s is not listed here: it is the same - # device as the Mirabox293S entry above, which predates these - # classes and keeps driving it. + # MiraBox StreamDock. + (USBVendorIDs.USB_VID_MIRABOX, USBProductIDs.USB_PID_MIRABOX_STREAMDOCK_293S, StreamDock293s), (USBVendorIDs.USB_VID_STREAMDOCK_5500, USBProductIDs.USB_PID_STREAMDOCK_293, StreamDock293), (USBVendorIDs.USB_VID_STREAMDOCK_6603, USBProductIDs.USB_PID_STREAMDOCK_293_V3, StreamDock293V3), (USBVendorIDs.USB_VID_STREAMDOCK_6603, USBProductIDs.USB_PID_STREAMDOCK_293_V3_B, StreamDock293V3), diff --git a/src/StreamDeck/Devices/Mirabox293S.py b/src/StreamDeck/Devices/Mirabox293S.py index e82dc22..4f7f58d 100644 --- a/src/StreamDeck/Devices/Mirabox293S.py +++ b/src/StreamDeck/Devices/Mirabox293S.py @@ -4,197 +4,25 @@ # dean [at] fourwalledcubicle [dot] com # www.fourwalledcubicle.com # -# Mirabox Stream Dock 293S non-official support +# Mirabox Stream Dock 293S non-official support # by Renato Schmidt (github.com/rescbr) -from .StreamDeck import StreamDeck, ControlType +""" +The 293S under its original name. +Its implementation now lives in :mod:`~StreamDeck.Devices.StreamDock293s`, +alongside the rest of the StreamDock family, which shares the protocol this +device was the first here to speak. This module keeps the name importable for +code that already depends on it. +""" -class Mirabox293S(StreamDeck): - """ - Represents a physically attached Mirabox Stream Dock 293S device. - """ - - KEY_COUNT = 18 - KEY_COLS = 6 - KEY_ROWS = 3 - - KEY_PIXEL_WIDTH = 85 # TODO: check if this is the correct value - KEY_PIXEL_HEIGHT = 85 # TODO: check if this is the correct value - KEY_IMAGE_FORMAT = "JPEG" - KEY_FLIP = (False, False) - KEY_ROTATION = 90 - - DECK_TYPE = "Mirabox Stream Dock 293S" - DECK_VISUAL = True - DECK_TOUCH = False # kind of... it could be used for the side display. - - PACKET_LENGHT = 512 - - # the side display uses key ids 0x10, 0x11, 0x12 with 80x80 images. - KEY_NUM_TO_DEVICE_KEY_ID = [0x0d, 0x0a, 0x07, 0x04, 0x01, 0x10, 0xe, 0xb, 0x08, 0x05, 0x02, 0x11, 0x0f, 0x0c, 0x09, 0x06, 0x03, 0x12] - KEY_DEVICE_KEY_ID_TO_NUM = {value: index for index, value in enumerate(KEY_NUM_TO_DEVICE_KEY_ID)} - - # see note in _read_control_states() method. - _key_triggered_last_read = False - - # 72 x 72 black JPEG - BLANK_KEY_IMAGE = [ - 0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, - 0x00, 0xff, 0xdb, 0x00, 0x43, 0x00, 0x08, 0x06, 0x06, 0x07, 0x06, 0x05, 0x08, 0x07, 0x07, 0x07, 0x09, 0x09, 0x08, - 0x0a, 0x0c, 0x14, 0x0d, 0x0c, 0x0b, 0x0b, 0x0c, 0x19, 0x12, 0x13, 0x0f, 0x14, 0x1d, 0x1a, 0x1f, 0x1e, 0x1d, 0x1a, - 0x1c, 0x1c, 0x20, 0x24, 0x2e, 0x27, 0x20, 0x22, 0x2c, 0x23, 0x1c, 0x1c, 0x28, 0x37, 0x29, 0x2c, 0x30, 0x31, 0x34, - 0x34, 0x34, 0x1f, 0x27, 0x39, 0x3d, 0x38, 0x32, 0x3c, 0x2e, 0x33, 0x34, 0x32, 0xff, 0xdb, 0x00, 0x43, 0x01, 0x09, - 0x09, 0x09, 0x0c, 0x0b, 0x0c, 0x18, 0x0d, 0x0d, 0x18, 0x32, 0x21, 0x1c, 0x21, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, - 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, - 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, - 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0xff, 0xc0, 0x00, 0x11, 0x08, 0x00, 0x48, 0x00, 0x48, 0x03, 0x01, 0x22, 0x00, - 0x02, 0x11, 0x01, 0x03, 0x11, 0x01, 0xff, 0xc4, 0x00, 0x1f, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, - 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, - 0x0b, 0xff, 0xc4, 0x00, 0xb5, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, - 0x00, 0x01, 0x7d, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, - 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xa1, 0x08, 0x23, 0x42, 0xb1, 0xc1, 0x15, 0x52, 0xd1, 0xf0, 0x24, 0x33, 0x62, - 0x72, 0x82, 0x09, 0x0a, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x34, 0x35, 0x36, 0x37, - 0x38, 0x39, 0x3a, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4a, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5a, - 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7a, 0x83, 0x84, 0x85, - 0x86, 0x87, 0x88, 0x89, 0x8a, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, - 0xa7, 0xa8, 0xa9, 0xaa, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6, 0xb7, 0xb8, 0xb9, 0xba, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7, - 0xc8, 0xc9, 0xca, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, 0xd8, 0xd9, 0xda, 0xe1, 0xe2, 0xe3, 0xe4, 0xe5, 0xe6, 0xe7, - 0xe8, 0xe9, 0xea, 0xf1, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, 0xf9, 0xfa, 0xff, 0xc4, 0x00, 0x1f, 0x01, 0x00, - 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, - 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0xff, 0xc4, 0x00, 0xb5, 0x11, 0x00, 0x02, 0x01, 0x02, 0x04, 0x04, - 0x03, 0x04, 0x07, 0x05, 0x04, 0x04, 0x00, 0x01, 0x02, 0x77, 0x00, 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31, - 0x06, 0x12, 0x41, 0x51, 0x07, 0x61, 0x71, 0x13, 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, 0xa1, 0xb1, 0xc1, 0x09, - 0x23, 0x33, 0x52, 0xf0, 0x15, 0x62, 0x72, 0xd1, 0x0a, 0x16, 0x24, 0x34, 0xe1, 0x25, 0xf1, 0x17, 0x18, 0x19, 0x1a, - 0x26, 0x27, 0x28, 0x29, 0x2a, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4a, - 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5a, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, 0x73, 0x74, 0x75, - 0x76, 0x77, 0x78, 0x79, 0x7a, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8a, 0x92, 0x93, 0x94, 0x95, 0x96, - 0x97, 0x98, 0x99, 0x9a, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6, 0xb7, - 0xb8, 0xb9, 0xba, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7, 0xc8, 0xc9, 0xca, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, 0xd8, - 0xd9, 0xda, 0xe2, 0xe3, 0xe4, 0xe5, 0xe6, 0xe7, 0xe8, 0xe9, 0xea, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, 0xf9, - 0xfa, 0xff, 0xda, 0x00, 0x0c, 0x03, 0x01, 0x00, 0x02, 0x11, 0x03, 0x11, 0x00, 0x3f, 0x00, 0xf9, 0xfe, 0x8a, 0x28, - 0xa0, 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, - 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, 0x02, - 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, - 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, - 0xa0, 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, - 0x28, 0xa0, 0x0f, 0xff, 0xd9 - ] - - def _convert_key_num_to_device_key_id(self, key): - return self.KEY_NUM_TO_DEVICE_KEY_ID[key] - - def _convert_device_key_id_to_key_num(self, key): - return self.KEY_DEVICE_KEY_ID_TO_NUM[key] - - - def _make_payload_for_report_id(self, report_id, payload_data): - payload = bytearray(self.PACKET_LENGHT + 1) - payload[0] = report_id - payload[1:len(payload_data)] = payload_data - return payload - - def _read_control_states(self): - states = [False] * self.KEY_COUNT - - # _key_triggered_last_read exists since 293S only triggers an HID event when a button is released. - # there are no key down and key up events, so we have to simulate the key being pressed and released. - # if a firmware upgrade that supports key down/up events is released, this variable can be removed from the code. - - if not self._key_triggered_last_read: - device_input_data = self.device.read(self.PACKET_LENGHT) - if device_input_data is None: - return None - - if(device_input_data.startswith(bytes([0x41, 0x43, 0x4b, 0x00, 0x00, 0x4f, 0x4b, 0x00]))): # ACK\0\0OK\0 - triggered_key = self._convert_device_key_id_to_key_num(int.from_bytes(device_input_data[9:10], 'big', signed=False)) - else: - # we don't know how to handle the response - return None - - states = [False] * self.KEY_COUNT - states[triggered_key] = True - self._key_triggered_last_read = True - else: - self._key_triggered_last_read = False - - return { - ControlType.KEY: states - } - - def _reset_key_stream(self): - self.reset() - - def reset(self): - # disconnect # CRT\0\0DIS - payload = self._make_payload_for_report_id(0x00, [0x43, 0x52, 0x54, 0x00, 0x00, 0x44, 0x49, 0x53]) - self.device.write(payload) - - # connect/ping # CRT\0\0CONNECT - payload = self._make_payload_for_report_id(0x00, [0x43, 0x52, 0x54, 0x00, 0x00, 0x43, 0x4f, 0x4e, 0x4e, 0x45, 0x43, 0x54]) - self.device.write(payload) +from .StreamDock293s import StreamDock293s - # clear contents # CRT\0\0CLE #0x00 0x00 0x00 - payload = self._make_payload_for_report_id(0x00, [0x43, 0x52, 0x54, 0x00, 0x00, 0x43, 0x4c, 0x45, 0x00, 0x00, 0x00, 0xff]) - self.device.write(payload) - def set_brightness(self, percent): - if isinstance(percent, float): - percent = int(100.0 * percent) - - percent = min(max(percent, 0), 100) - - # set brightness # CRT\0\0LIG #0x00 0x00 0x00 - payload = self._make_payload_for_report_id(0x00, [0x43, 0x52, 0x54, 0x00, 0x00, 0x4c, 0x49, 0x47, 0x00, 0x00, percent, 0x00]) - self.device.write(payload) - - def get_serial_number(self): - return self.device.serial_number() - - def get_firmware_version(self): - version = self.device.read_input(0x00, self.PACKET_LENGHT + 1) - return self._extract_string(version[1:]) - - def set_key_image(self, key, image): - if min(max(key, 0), self.KEY_COUNT) != key: - raise IndexError("Invalid key index {}.".format(key)) - - image = bytes(image or self.BLANK_KEY_IMAGE) - image_payload_page_length = self.PACKET_LENGHT - - key = self._convert_key_num_to_device_key_id(key) - - image_size_uint16_be = int.to_bytes(len(image), 2, 'big', signed=False) - - # start batch # CRT\0\0BAT #0x00 0x00 - command = bytes([0x43, 0x52, 0x54, 0x00, 0x00, 0x42, 0x41, 0x54, 0x00, 0x00]) + image_size_uint16_be + bytes([key]) - payload = self._make_payload_for_report_id(0x00, command) - self.device.write(payload) - - page_number = 0 - bytes_remaining = len(image) - while bytes_remaining > 0: - this_length = min(bytes_remaining, image_payload_page_length) - bytes_sent = page_number * image_payload_page_length - - #send data - payload = self._make_payload_for_report_id(0x00, image[bytes_sent:bytes_sent + this_length]) - self.device.write(payload) - - bytes_remaining = bytes_remaining - this_length - page_number = page_number + 1 - - # stop batch # CRT\0\0STP - payload = self._make_payload_for_report_id(0x00, [0x43, 0x52, 0x54, 0x00, 0x00, 0x53, 0x54, 0x50]) - self.device.write(payload) - - - - def set_touchscreen_image(self, image, x_pos=0, y_pos=0, width=0, height=0): - pass - - def set_key_color(self, key, r, g, b): - pass +class Mirabox293S(StreamDock293s): + """ + .. deprecated:: 0.3.0 + Use :class:`~StreamDeck.Devices.StreamDock293s.StreamDock293s` instead. + """ - def set_screen_image(self, image): - pass + pass diff --git a/src/StreamDeck/Devices/StreamDock.py b/src/StreamDeck/Devices/StreamDock.py index 5c9eb4c..ce70fa4 100644 --- a/src/StreamDeck/Devices/StreamDock.py +++ b/src/StreamDeck/Devices/StreamDock.py @@ -75,6 +75,14 @@ class StreamDock(StreamDeck): #: it. EVENT_CODE_OFFSET: ClassVar[int] = 9 + #: Prefix an input report must carry to be an event at all. Left unset where + #: a model's reports have not been checked closely enough to insist on one. + INPUT_REPORT_PREFIX: ClassVar[bytes] = b"" + + #: Set on models whose firmware reports a key only once, on release, with no + #: separate press. See :meth:`_read_control_states`. + KEY_EVENT_IS_RELEASE_ONLY: ClassVar[bool] = False + KEY_IMAGE_MAP: ClassVar[dict] = {} BUTTON_MAP: ClassVar[dict] = {} DIAL_TURN_MAP: ClassVar[dict] = {} @@ -89,10 +97,14 @@ class StreamDock(StreamDeck): #: report presses like any other key and are absent from KEY_IMAGE_MAP. SCREENLESS_KEY_INDICES: ClassVar[tuple] = () - #: Painted over every key on connect for panels where ``CLE`` clears the - #: framebuffer without visibly wiping the glass. Sized to cover the gaps - #: between the bezel cutouts, so it is usually larger than a key image. - SCREEN_CLEAR_IMAGE: ClassVar[list] = [] + #: Black JPEG sent to blank a key, as on the Elgato devices. On shared-panel + #: models it is deliberately larger than one key image, so it covers the gaps + #: between the bezel cutouts as well. + BLANK_KEY_IMAGE: ClassVar[list] = [] + + #: Whether connecting paints BLANK_KEY_IMAGE over every key. Needed on panels + #: where ``CLE`` empties the framebuffer without visibly wiping the glass. + PAINT_BLANK_KEYS_ON_CONNECT: ClassVar[bool] = False #: Framing for models whose keys are cutouts in one shared panel rather than #: separate screens. See :meth:`key_image_format`. @@ -117,6 +129,7 @@ def __init__(self, device): self._brightness = 100 self._control_states = [False] * (self.KEY_COUNT + self.TOUCH_KEY_COUNT) self._dial_press_states = [False] * self.DIAL_COUNT + self._release_pending = False self._keepalive_thread = None self._run_keepalive = False @@ -206,26 +219,32 @@ def _keepalive(self, generation: int) -> None: # The read thread owns reconnecting; it sees the same error. return - def _reset_key_stream(self) -> None: + def _connect_handshake(self) -> None: """ - Runs the connect handshake and repaints the panel. + Announces the host to the device. - Called by :meth:`~StreamDeck.open` before the read thread starts, and - again by every reconnect, so it has to be safe to repeat. The order - matches what the vendor software sends: select software mode, wake the - panel, set brightness, then clear. + The order matches what the vendor software sends: select software mode, + wake the panel, then set brightness. Overridden by models whose own + handshake is known to differ. """ self.set_mode(self.MODE_SOFTWARE) self.wake_screen() self.set_brightness(self._brightness) - self._command("CLE", struct.pack(">HB", 0, 0xFF)) - if self.SCREEN_CLEAR_IMAGE: - blank = bytes(self.SCREEN_CLEAR_IMAGE) - for hardware_key in sorted(set(self.KEY_IMAGE_MAP.values())): - self._command("BAT", struct.pack(">IB", len(blank), hardware_key), blank) + def _reset_key_stream(self) -> None: + """ + Runs the connect handshake and blanks the panel. + + Called by :meth:`~StreamDeck.open` before the read thread starts, and + again by every reconnect, so it has to be safe to repeat. + """ + self._connect_handshake() + self.clear_key(None) - self.refresh_screen() + if self.PAINT_BLANK_KEYS_ON_CONNECT and self.BLANK_KEY_IMAGE: + for key in sorted(self.KEY_IMAGE_MAP): + self.set_key_image(key, None) + self.refresh_screen() def reset(self) -> None: self._reset_key_stream() @@ -303,6 +322,21 @@ def key_image_format(self) -> dict: return image_format + def clear_key(self, key) -> None: + """ + Clears one key, or every key when given None. + + :param int key: Key to clear, None for all of them. + """ + hardware_key = 0xFF if key is None else self.KEY_IMAGE_MAP.get(key) + if hardware_key is None: + return + + # The key id is the fourth parameter byte, not the third. Addressing it + # one byte early makes the firmware clear key 0 rather than the key that + # was asked for, which reads as CLE quietly doing nothing. + self._command("CLE", struct.pack(">IB", 0, hardware_key)[1:]) + def set_key_image(self, key: int, image) -> None: if min(max(key, 0), self.KEY_COUNT - 1) != key: raise IndexError(f"Invalid key index {key}.") @@ -313,8 +347,12 @@ def set_key_image(self, key: int, image) -> None: return if image is None: - self._command("CLE", struct.pack(">HB", 0, hardware_key)) - return + # Blanked by drawing black where the model carries a blank image, + # matching the Elgato devices; CLE is the fallback. + if not self.BLANK_KEY_IMAGE: + self.clear_key(key) + return + image = self.BLANK_KEY_IMAGE image = bytes(image) self._command("BAT", struct.pack(">IB", len(image), hardware_key), image) @@ -356,13 +394,21 @@ def get_serial_number(self) -> str: return self.device.serial_number() def get_firmware_version(self) -> str: - version = self.device.read_input(self.REPORT_ID, self._payload_length) + version = self.device.read_input(self.REPORT_ID, self.INPUT_REPORT_LENGTH) return self._extract_string(version[1:]) # ------------------------------------------------------------------ # # Input # ------------------------------------------------------------------ # def _read_control_states(self): + if self._release_pending: + # This model reports a key once, on release. The press was handed + # over on the previous call; this call takes it back again, without + # reading, so the caller sees the pair it expects. + self._release_pending = False + self._control_states = [False] * len(self._control_states) + return {ControlType.KEY: list(self._control_states)} + report = self.device.read(self.INPUT_REPORT_LENGTH) if report is None: return None @@ -371,6 +417,9 @@ def _read_control_states(self): if len(report) < offset + 2: return None + if self.INPUT_REPORT_PREFIX and not bytes(report).startswith(self.INPUT_REPORT_PREFIX): + return None + if report[9] == 0xFF: # Write acknowledgement rather than an input event. return None @@ -380,7 +429,8 @@ def _read_control_states(self): if code in self.BUTTON_MAP: index = self.BUTTON_MAP[code] if index < len(self._control_states): - self._control_states[index] = (state == 0x01) + self._control_states[index] = True if self.KEY_EVENT_IS_RELEASE_ONLY else (state == 0x01) + self._release_pending = self.KEY_EVENT_IS_RELEASE_ONLY return {ControlType.KEY: list(self._control_states)} if code in self.DIAL_TURN_MAP: diff --git a/src/StreamDeck/Devices/StreamDock293s.py b/src/StreamDeck/Devices/StreamDock293s.py new file mode 100644 index 0000000..4d0d5a4 --- /dev/null +++ b/src/StreamDeck/Devices/StreamDock293s.py @@ -0,0 +1,148 @@ +# Python Stream Deck Library +# Released under the MIT license +# +# dean [at] fourwalledcubicle [dot] com +# www.fourwalledcubicle.com +# +# Mirabox Stream Dock 293S non-official support +# by Renato Schmidt (github.com/rescbr) + +from typing import ClassVar + +from .StreamDock import StreamDock + + +class StreamDock293s(StreamDock): + """ + Represents a physically attached MiraBox StreamDock 293s device. + + Verified against hardware by Renato Schmidt, who wrote the original + standalone implementation this class replaces. It is still reachable under + the old name, :class:`~StreamDeck.Devices.Mirabox293S.Mirabox293S`. + + The right-hand strip is addressed as a sixth column of keys, hardware ids + 0x10 to 0x12. Its cells are 80x80 rather than the 85x85 the main grid uses, + which a single key image format cannot express; images for them are scaled + by the device. + + Its firmware reports a key once, when it is released, with no separate press + event, so KEY_EVENT_IS_RELEASE_ONLY is set and the base class synthesises the + pair. A firmware that grows real press and release events would only need + that flag cleared. + """ + + KEY_COUNT = 18 + KEY_COLS = 6 + KEY_ROWS = 3 + + # TODO: carried over from the original implementation, which flagged both as + # unverified. + KEY_PIXEL_WIDTH = 85 + KEY_PIXEL_HEIGHT = 85 + KEY_IMAGE_FORMAT = "JPEG" + KEY_FLIP = (False, False) + KEY_ROTATION = 90 + + DIAL_COUNT = 0 + + DECK_TYPE = "Mirabox Stream Dock 293S" + DECK_VISUAL = True + # The side strip could serve as a touch surface, but is driven as keys here. + DECK_TOUCH = False + + OUTPUT_REPORT_LENGTH = 513 + INPUT_REPORT_LENGTH = 513 + + # ACK\0\0OK\0, which every input report from this model carries. + INPUT_REPORT_PREFIX = b"\x41\x43\x4b\x00\x00\x4f\x4b\x00" + + KEY_EVENT_IS_RELEASE_ONLY = True + + KEY_IMAGE_MAP: ClassVar[dict] = { + 0x00: 0x0d, 0x01: 0x0a, 0x02: 0x07, 0x03: 0x04, 0x04: 0x01, 0x05: 0x10, + 0x06: 0x0e, 0x07: 0x0b, 0x08: 0x08, 0x09: 0x05, 0x0a: 0x02, 0x0b: 0x11, + 0x0c: 0x0f, 0x0d: 0x0c, 0x0e: 0x09, 0x0f: 0x06, 0x10: 0x03, 0x11: 0x12, + } + + BUTTON_MAP: ClassVar[dict] = { + 0x0d: 0, 0x0a: 1, 0x07: 2, 0x04: 3, 0x01: 4, 0x10: 5, + 0x0e: 6, 0x0b: 7, 0x08: 8, 0x05: 9, 0x02: 10, 0x11: 11, + 0x0f: 12, 0x0c: 13, 0x09: 14, 0x06: 15, 0x03: 16, 0x12: 17, + } + + # 72 x 72 black JPEG + BLANK_KEY_IMAGE: ClassVar[list] = [ + 0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, + 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xff, 0xdb, 0x00, 0x43, + 0x00, 0x08, 0x06, 0x06, 0x07, 0x06, 0x05, 0x08, 0x07, 0x07, 0x07, 0x09, + 0x09, 0x08, 0x0a, 0x0c, 0x14, 0x0d, 0x0c, 0x0b, 0x0b, 0x0c, 0x19, 0x12, + 0x13, 0x0f, 0x14, 0x1d, 0x1a, 0x1f, 0x1e, 0x1d, 0x1a, 0x1c, 0x1c, 0x20, + 0x24, 0x2e, 0x27, 0x20, 0x22, 0x2c, 0x23, 0x1c, 0x1c, 0x28, 0x37, 0x29, + 0x2c, 0x30, 0x31, 0x34, 0x34, 0x34, 0x1f, 0x27, 0x39, 0x3d, 0x38, 0x32, + 0x3c, 0x2e, 0x33, 0x34, 0x32, 0xff, 0xdb, 0x00, 0x43, 0x01, 0x09, 0x09, + 0x09, 0x0c, 0x0b, 0x0c, 0x18, 0x0d, 0x0d, 0x18, 0x32, 0x21, 0x1c, 0x21, + 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, + 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, + 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, + 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, + 0x32, 0x32, 0xff, 0xc0, 0x00, 0x11, 0x08, 0x00, 0x48, 0x00, 0x48, 0x03, + 0x01, 0x22, 0x00, 0x02, 0x11, 0x01, 0x03, 0x11, 0x01, 0xff, 0xc4, 0x00, + 0x1f, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, + 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0xff, 0xc4, 0x00, 0xb5, 0x10, 0x00, + 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, + 0x00, 0x01, 0x7d, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, + 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, + 0x91, 0xa1, 0x08, 0x23, 0x42, 0xb1, 0xc1, 0x15, 0x52, 0xd1, 0xf0, 0x24, + 0x33, 0x62, 0x72, 0x82, 0x09, 0x0a, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x25, + 0x26, 0x27, 0x28, 0x29, 0x2a, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, + 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4a, 0x53, 0x54, 0x55, 0x56, + 0x57, 0x58, 0x59, 0x5a, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, + 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7a, 0x83, 0x84, 0x85, 0x86, + 0x87, 0x88, 0x89, 0x8a, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, + 0x9a, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xb2, 0xb3, + 0xb4, 0xb5, 0xb6, 0xb7, 0xb8, 0xb9, 0xba, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, + 0xc7, 0xc8, 0xc9, 0xca, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, 0xd8, 0xd9, + 0xda, 0xe1, 0xe2, 0xe3, 0xe4, 0xe5, 0xe6, 0xe7, 0xe8, 0xe9, 0xea, 0xf1, + 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, 0xf9, 0xfa, 0xff, 0xc4, 0x00, + 0x1f, 0x01, 0x00, 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, + 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0xff, 0xc4, 0x00, 0xb5, 0x11, 0x00, + 0x02, 0x01, 0x02, 0x04, 0x04, 0x03, 0x04, 0x07, 0x05, 0x04, 0x04, 0x00, + 0x01, 0x02, 0x77, 0x00, 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31, + 0x06, 0x12, 0x41, 0x51, 0x07, 0x61, 0x71, 0x13, 0x22, 0x32, 0x81, 0x08, + 0x14, 0x42, 0x91, 0xa1, 0xb1, 0xc1, 0x09, 0x23, 0x33, 0x52, 0xf0, 0x15, + 0x62, 0x72, 0xd1, 0x0a, 0x16, 0x24, 0x34, 0xe1, 0x25, 0xf1, 0x17, 0x18, + 0x19, 0x1a, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x35, 0x36, 0x37, 0x38, 0x39, + 0x3a, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4a, 0x53, 0x54, 0x55, + 0x56, 0x57, 0x58, 0x59, 0x5a, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, + 0x6a, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7a, 0x82, 0x83, 0x84, + 0x85, 0x86, 0x87, 0x88, 0x89, 0x8a, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, + 0x98, 0x99, 0x9a, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, + 0xb2, 0xb3, 0xb4, 0xb5, 0xb6, 0xb7, 0xb8, 0xb9, 0xba, 0xc2, 0xc3, 0xc4, + 0xc5, 0xc6, 0xc7, 0xc8, 0xc9, 0xca, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, + 0xd8, 0xd9, 0xda, 0xe2, 0xe3, 0xe4, 0xe5, 0xe6, 0xe7, 0xe8, 0xe9, 0xea, + 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, 0xf9, 0xfa, 0xff, 0xda, 0x00, + 0x0c, 0x03, 0x01, 0x00, 0x02, 0x11, 0x03, 0x11, 0x00, 0x3f, 0x00, 0xf9, + 0xfe, 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, + 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, + 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, + 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, + 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, + 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, + 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, + 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, + 0x02, 0x8a, 0x28, 0xa0, 0x0f, 0xff, 0xd9 + ] + + def _connect_handshake(self) -> None: + """ + Sends this model's own connect sequence. + + It predates the MOD mode select the rest of the family uses, and is the + one StreamDock here that has been in users' hands, so it keeps the bytes + it shipped with rather than gaining commands no one has run against the + hardware. + """ + self.wake_screen() + self._command("CONNECT") diff --git a/src/StreamDeck/Devices/StreamDockN3.py b/src/StreamDeck/Devices/StreamDockN3.py index ddd6052..4a31ff1 100644 --- a/src/StreamDeck/Devices/StreamDockN3.py +++ b/src/StreamDeck/Devices/StreamDockN3.py @@ -77,8 +77,10 @@ class StreamDockN3(StreamDock): KEY_COL_X_OFFSETS: ClassVar[tuple] = (-6, -6, -1) KEY_ROW_Y_OFFSETS: ClassVar[tuple] = (2, 8) + PAINT_BLANK_KEYS_ON_CONNECT = True + # 112 x 112 black JPEG - SCREEN_CLEAR_IMAGE: ClassVar[list] = [ + BLANK_KEY_IMAGE: ClassVar[list] = [ 0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xff, 0xdb, 0x00, 0x43, 0x00, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, diff --git a/test/test_streamdock.py b/test/test_streamdock.py index 61e97b2..7be0c79 100644 --- a/test/test_streamdock.py +++ b/test/test_streamdock.py @@ -30,6 +30,7 @@ from StreamDeck.DeviceManager import DeviceManager from StreamDeck.Devices.StreamDock import StreamDock from StreamDeck.Devices.StreamDockN3 import StreamDockN3 +from StreamDeck.Devices.StreamDock293s import StreamDock293s from StreamDeck.Devices.StreamDockN4Pro import StreamDockN4Pro from StreamDeck.Devices.StreamDeck import ControlType, DialEventType, TouchscreenEventType from StreamDeck.ImageHelpers import PILHelper @@ -308,20 +309,113 @@ def test_unframed_models_unchanged(): check(with_key == without_key, "passing a key index changes nothing for the 293") +def test_293s_handshake_unchanged(): + """The 293s keeps the exact connect bytes its standalone version shipped.""" + device = RecordingDevice(vid=0x5548, pid=0x6670) + deck = StreamDock293s(device) + deck.open() + try: + # What the standalone implementation sent, verbatim: DIS, CONNECT, CLE all. + expected = [ + b"CRT\x00\x00" + bytes([0x44, 0x49, 0x53]), + b"CRT\x00\x00" + bytes([0x43, 0x4f, 0x4e, 0x4e, 0x45, 0x43, 0x54]), + b"CRT\x00\x00" + bytes([0x43, 0x4c, 0x45, 0x00, 0x00, 0x00, 0xff]), + ] + sent = [w[1:].rstrip(b"\x00") for w in device.writes] + check(sent == [e.rstrip(b"\x00") for e in expected], + "the 293s sends DIS, CONNECT and CLE, and nothing else") + check(all(len(w) == 513 for w in device.writes), + "reports are 513 bytes, the length the model declares") + finally: + deck.reconnect_after_suspend = False + deck.close() + + +def test_293s_release_only_keys(): + """One report from the 293s becomes a press and then a release.""" + device = RecordingDevice(vid=0x5548, pid=0x6670) + deck = StreamDock293s(device) + deck.device.open() + + report = bytearray(StreamDock293s.INPUT_REPORT_LENGTH) + report[0:8] = StreamDock293s.INPUT_REPORT_PREFIX + report[9] = 0x01 # hardware key 0x01 is grid index 4 + device.reads = [bytes(report)] + + pressed = deck._read_control_states()[ControlType.KEY] + check(pressed[4] is True and sum(pressed) == 1, "the report presses grid key 4 alone") + + released = deck._read_control_states()[ControlType.KEY] + check(not any(released), "the next call releases it without reading again") + check(device.reads == [], "the release is synthesised, not read from the device") + + +def test_293s_rejects_foreign_reports(): + """A report without the model's prefix is not an event.""" + device = RecordingDevice(vid=0x5548, pid=0x6670) + deck = StreamDock293s(device) + deck.device.open() + + report = bytearray(StreamDock293s.INPUT_REPORT_LENGTH) + report[9] = 0x01 + device.reads = [bytes(report)] + + check(deck._read_control_states() is None, "a report with no ACK prefix is ignored") + + +def test_blank_image_clears_key(): + """A None image draws the model's blank JPEG where it has one.""" + device = RecordingDevice(vid=0x5548, pid=0x6670) + deck = StreamDock293s(device) + deck.device.open() + + deck.set_key_image(0, None) + + sent = commands(device) + check(sent[0][0] == "BAT", "blanking a 293s key sends its black JPEG") + check(sent[0][1][:5] == struct.pack(">IB", len(StreamDock293s.BLANK_KEY_IMAGE), + StreamDock293s.KEY_IMAGE_MAP[0]), + "the blank image is addressed to the right hardware key") + + +def test_clear_key_parameter_width(): + """CLE puts the key id in the fourth parameter byte.""" + device = RecordingDevice() + deck = StreamDockN3(device) + deck.device.open() + + deck.clear_key(None) + command, params = commands(device)[0] + + check(command == "CLE", "clearing sends CLE") + check(params[:4] == b"\x00\x00\x00\xff", + f"the all-keys id sits in the fourth byte (got {params[:4].hex(' ')})") + + +def test_mirabox293s_alias(): + """The old name still resolves, to the same device.""" + from StreamDeck.Devices.Mirabox293S import Mirabox293S + + check(issubclass(Mirabox293S, StreamDock293s), "Mirabox293S is still importable") + check(Mirabox293S.KEY_COUNT == 18 and Mirabox293S.DECK_TYPE == "Mirabox Stream Dock 293S", + "it describes the same hardware as before") + + def test_enumeration(): """Every registered StreamDock product id builds its device class.""" decks = DeviceManager(transport="dummy").enumerate() docks = [d for d in decks if isinstance(d, StreamDock)] - check(len(docks) == 25, f"25 StreamDock product ids enumerate (got {len(docks)})") + check(len(docks) == 26, f"26 StreamDock product ids enumerate (got {len(docks)})") models = {d.deck_type() for d in docks} expected = { "StreamDock 293", "StreamDock 293 V3", "StreamDock 293s V3", "StreamDock N1", + "Mirabox Stream Dock 293S", "StreamDock N3", "StreamDock N4", "StreamDock N4 Pro", "StreamDock XL", "StreamDock M18", "StreamDock M3", "StreamDock K1 Pro", } - check(models == expected, f"all 11 models are reachable (missing {expected - models})") + check(models == expected, f"all 12 models are reachable (missing {expected - models})") if __name__ == "__main__":