diff --git a/CHANGELOG b/CHANGELOG index d3b1f56..5f6cebb 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,21 @@ +Unreleased: + - Added the MiraBox StreamDock family: 293, 293 V3, 293s V3, K1 Pro, M3, M18, + 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. + 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..9b7ca0c 100644 --- a/README.md +++ b/README.md @@ -34,9 +34,23 @@ variants: * StreamDeck Studio * StreamDeck XL -This fork additionally supports: - -* Mirabox Stream Dock 293S +This fork additionally supports the MiraBox StreamDock family, which speaks its +own protocol rather than Elgato's: + +* StreamDock 293 and 293 V3 +* StreamDock 293s (also known as the Mirabox Stream Dock 293S) and 293s V3 +* StreamDock K1 Pro +* StreamDock M3 and M18 +* StreamDock N1 +* StreamDock N3 +* StreamDock N4 and N4 Pro +* StreamDock XL + +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: @@ -107,6 +121,21 @@ 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: + +- [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 + 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..8608736 100644 --- a/doc/source/modules/devices.rst +++ b/doc/source/modules/devices.rst @@ -87,11 +87,73 @@ StreamDeck XL Mirabox Stream Dock 293S ========================= +.. automodule:: StreamDeck.Devices.StreamDock293s + :members: + :show-inheritance: + .. automodule:: StreamDeck.Devices.Mirabox293S :members: :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..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 @@ -18,6 +17,18 @@ from .Devices.StreamDeckPlusXL import StreamDeckPlusXL 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 +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 @@ -148,7 +159,33 @@ 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. + (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), + (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/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 new file mode 100644 index 0000000..ce70fa4 --- /dev/null +++ b/src/StreamDeck/Devices/StreamDock.py @@ -0,0 +1,458 @@ +# 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 + + #: 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] = {} + 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] = () + + #: 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`. + 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._release_pending = False + + 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 _connect_handshake(self) -> None: + """ + Announces the host to the device. + + 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) + + 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) + + 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() + + # ------------------------------------------------------------------ # + # 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 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}.") + + 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: + # 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) + + 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.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 + + offset = self.EVENT_CODE_OFFSET + 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 + + 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] = 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: + 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/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/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..4a31ff1 --- /dev/null +++ b/src/StreamDeck/Devices/StreamDockN3.py @@ -0,0 +1,153 @@ +# 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) + + PAINT_BLANK_KEYS_ON_CONNECT = True + + # 112 x 112 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, 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..7be0c79 --- /dev/null +++ b/test/test_streamdock.py @@ -0,0 +1,436 @@ +#!/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.StreamDock293s import StreamDock293s +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_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) == 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 12 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)