Skip to content

Add the MiraBox StreamDock device family - #1

Open
joegoldin wants to merge 2 commits into
masterfrom
feat/streamdock-devices
Open

Add the MiraBox StreamDock device family#1
joegoldin wants to merge 2 commits into
masterfrom
feat/streamdock-devices

Conversation

@joegoldin

@joegoldin joegoldin commented Aug 12, 2026

Copy link
Copy Markdown
Owner

This PR targets my own fork's master, so I can review it before anything goes upstream. Nothing here has been proposed to StreamController yet.

What this is

Native support for the MiraBox StreamDock family: 12 models across 26 USB product ids. The existing Stream Dock 293S moves onto the same base class.

These devices are sold as Stream Deck alternatives but do not speak Elgato's protocol. Commands are text-tagged and sit in fixed-size HID reports: a 5-byte CRT\x00\x00 header, a three-letter ASCII command, then big-endian parameters. Key images are JPEGs streamed as a run of further reports between a BAT (begin) and an STP (show it).

The per-model differences are all data, so StreamDock holds the protocol and each model is a subclass of tables: key grid, image key ids, and the input codes for buttons, knobs and swipes.

Verified on hardware StreamDock N3 (mine), 293s (already merged, by its author)
Untested, from protocol tables 293, 293 V3, 293s V3, K1 Pro, M3, M18, N1, N4, N4 Pro, XL
OS tested Linux (NixOS)
Transport tested LibUSBHIDAPI

The N3 path has been my daily driver deck through StreamController: six LCD keys, three screenless buttons, three knobs, reconnect, suspend, and repeated lock/unlock cycles.

Every untested model says so in its own class docstring. I would rather ship them labelled than sit on the tables.

Where this came from

I built StreamDock support inside StreamController first, as an in-tree HID transport, and tested it against an N3 over several weeks. This PR is the half that belongs in the library: the transport and the device classes, ported onto StreamDeck so they arrive through DeviceManager.enumerate() like any other deck.

The protocol is undocumented by MiraBox. The work rests on independent reverse engineering by rescbr (the 293S, whose framing the whole family shares), Phaeilo (the pure-Python transport), and stevemurr (the MOD mode select, the connect handshake, and the CONNECT keepalive and HAN semantics). I corroborated the command frames against 4ndv/mirajazz, which produces byte-identical MOD frames on N-series hardware, and compared them with rigor789/mirabox-streamdock-node. All four are credited in the module and in the README.

This overlaps StreamController#12, which added N3/N4/N4 Pro and was closed by its author without review, and StreamController#8, which adds the Ajazz AKP03E on the same protocol. If either is preferred, I am happy to fold this into it instead.

The 293S migration

The second commit moves Mirabox293S onto the shared base class as StreamDock293s, so the family shares one implementation of the protocol. Mirabox293S still resolves to it and is marked deprecated, so nothing that imports it breaks.

Its key maps, geometry, blank image and connect sequence carry over untouched. The handshake is an override, not the inherited one, and that is deliberate: the 293S is the only StreamDock here that has been in users' hands, so it keeps the bytes it shipped with. The MOD mode select the rest of the family sends has never been run against 293S hardware, and this is not the place to find out.

Two things did change on its wire. I would like a second opinion on both.

Reports now go out at the 513 bytes the model declares. The original built them with payload[1:len(payload_data)] = payload_data, and a bytearray slice assignment of mismatched length resizes, so every report was 514 bytes:

DIS (8 byte command) -> report length 514 (declared 513)
image chunk (512 B)  -> report length 514 (declared 513)

The payload bytes landed correctly, so this only drops a stray trailing zero. I do not have a 293S to confirm the firmware does not care.

CLE now puts its key id in the fourth parameter byte:

293S, merged:   43 52 54 00 00  43 4c 45  00 00 00 ff
family, before: 43 52 54 00 00  43 4c 45  00 00 ff

The 293S had this right and the rest of the family did not, so clear_key(n) was asking the firmware to clear key 0. That matches something I hit on the N3 and worked around without understanding: CLE appeared to do nothing, so the panel is blanked by painting black JPEGs over it. The workaround stays, since it also covers the gaps between the bezel cutouts, but the command should now do what it says. The 293S is unaffected by this fix. The N3 change is unverified, and I want to re-test it on hardware before this goes upstream.

Migrating also filled two gaps in my base class, now available to any model:

  • Firmware that reports a key once on release, with no separate press, which the 293S has. KEY_EVENT_IS_RELEASE_ONLY makes the base synthesise the pair, the way the standalone version did with its own flag. A firmware that grows real press and release events only needs the flag cleared.
  • Blanking a key by drawing a black JPEG instead of sending CLE, which is what the Elgato devices in this repo already do. BLANK_KEY_IMAGE is now the library-standard name on these devices too.

One thing I did not resolve: the 293S carries KEY_PIXEL_WIDTH = 85 with a TODO: check if this is the correct value from the original, and its side strip cells are 80x80 while the main grid is 85x85, which one key image format cannot express. Guessing at either seemed worse than leaving them.

Where I would look first

The N3's keys are not separate screens. They are cutouts in one 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. The device publishes the framing in key_image_format() and PILHelper applies it, which keeps PIL out of the device classes, per ad0b921. to_native_key_format() grows an optional key index for this. Existing callers and every other device are unaffected, and there is a test covering that.

Closing must not send the vendor disconnect. CLE\x00\x00DC latches the N3 into acknowledging display writes and rendering none of them, until the device is physically replugged. Input keeps working the whole time, so it presents as a deck that responds to buttons but never redraws. Releasing the handle leaves the panel writable for the next open(). There is a test asserting close() sends no DC.

The keepalive is the one piece here I wrote myself, not ported, so it has had the least scrutiny. The firmware treats a quiet host as gone, so a thread sends CONNECT every two seconds. A superseded keepalive is retired by bumping a generation counter, never joined: it sends under the deck's update lock, and open() may be called by someone already holding that lock, so joining could deadlock.

Reports are padded to the model's exact length throughout, because the firmware reads fixed-size reports and treats a short one as a framing error, which desynchronises an image transfer.

Verification

$ python3 test/test.py
132 checks across 44 decks, exit 0

$ python3 test/test_streamdock.py
17 of 17 tests passed.

$ python3 -m flake8 src/ test/test_streamdock.py
no new findings (pre-existing ones in StreamDeckPlusXL.py and LibUSBHIDAPI.py,
plus the E402 in test/ that test.py already has)

test/test_streamdock.py runs against a recording transport. It covers report framing and padding, the report id on the one model that uses a non-zero one, image chunking including payloads whose chunks start with zero bytes, the connect handshake order, close() sending no DC, key/dial/swipe decoding, write-acknowledgement rejection, the N3 panel framing, and that unframed models come out byte-identical whether or not a key index is passed.

For the migration: the 293S sends exactly DIS, CONNECT and CLE on connect and nothing else, at 513 bytes a report; one input report becomes a press and then a synthesised release without a second read; a report without the model's ACK\0\0OK\0 prefix is not an event; a None image draws its black JPEG; CLE addresses the fourth byte; and Mirabox293S still imports and still describes the same hardware.

Not in this PR

DIAL_LARGE_INDICES and SCREENLESS_KEY_INDICES are exposed on the device (the N3's top knob is larger than the other two, and its bottom row has no screens), but RotatedDeck does not forward them yet. Rotation remaps those indices, so it needs some thought before a passthrough. The consumer side also needs UI that can draw a deck that is not a uniform grid, which is StreamController's side of the line and a separate change.

Suspend and lock recovery are also absent. The N3 can enter a firmware state where display output is acknowledged and never rendered, and recovering it needs a USB port reset plus the handshake, which is more than a device class should reach for.

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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant