Add the MiraBox StreamDock device family - #1
Open
joegoldin wants to merge 2 commits into
Open
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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\x00header, a three-letter ASCII command, then big-endian parameters. Key images are JPEGs streamed as a run of further reports between aBAT(begin) and anSTP(show it).The per-model differences are all data, so
StreamDockholds the protocol and each model is a subclass of tables: key grid, image key ids, and the input codes for buttons, knobs and swipes.LibUSBHIDAPIThe 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
StreamDeckso they arrive throughDeviceManager.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
MODmode select, the connect handshake, and theCONNECTkeepalive andHANsemantics). I corroborated the command frames against 4ndv/mirajazz, which produces byte-identicalMODframes 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
Mirabox293Sonto the shared base class asStreamDock293s, so the family shares one implementation of the protocol.Mirabox293Sstill 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
MODmode 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 abytearrayslice assignment of mismatched length resizes, so every report was 514 bytes: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.
CLEnow puts its key id in the fourth parameter byte: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:CLEappeared 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:
KEY_EVENT_IS_RELEASE_ONLYmakes 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.CLE, which is what the Elgato devices in this repo already do.BLANK_KEY_IMAGEis now the library-standard name on these devices too.One thing I did not resolve: the 293S carries
KEY_PIXEL_WIDTH = 85with aTODO: check if this is the correct valuefrom 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()andPILHelperapplies 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\x00DClatches 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 nextopen(). There is a test assertingclose()sends noDC.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
CONNECTevery two seconds. A superseded keepalive is retired by bumping a generation counter, never joined: it sends under the deck's update lock, andopen()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
test/test_streamdock.pyruns 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 noDC, 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,CONNECTandCLEon 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'sACK\0\0OK\0prefix is not an event; aNoneimage draws its black JPEG;CLEaddresses the fourth byte; andMirabox293Sstill imports and still describes the same hardware.Not in this PR
DIAL_LARGE_INDICESandSCREENLESS_KEY_INDICESare exposed on the device (the N3's top knob is larger than the other two, and its bottom row has no screens), butRotatedDeckdoes 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.