No description
  • Zig 98.9%
  • Nix 1.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie 0471c7bdf9
A painted panel frames every key
A photograph of an Original V2 showing the panel through its
odd-numbered keys had stripes intruding at the edges of the numbered
ones as well, which they should not have had. The same thing had shown
up on a Neo a while earlier and been written off as the key cap catching
light from its neighbours.

It is not. Paint the panel green, write a flat black image to every key,
and every key keeps a green border -- on an Original V2, a Neo and a
Plus alike. The key cap shows slightly more of the panel than the key
image covers.

Nothing here is doing it. The image occupies its documented size
exactly; the cap is simply wider than it and shows some of the gap
around it. A Classic panel is 480x272 carrying fifteen keys of 72x72,
which leaves a hundred and twenty pixels across and fifty-six down for
margins and gaps, and node-elgato-stream-deck models a key's bounds as
exactly its image size with twenty-five pixel gaps between. Neither it
nor Elgato's documentation says any of that is visible.

Worth knowing rather than worth fixing, and probably what a background
is meant to look like: whatever is on the panel becomes a frame around
every key, so artwork drawn key by key sits in it rather than against
black. A key that has to be covered edge to edge needs the panel behind
it painted too.

`zig build probe -- --inset` is the test.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01WUvf9zwMsPMwUDkUFViFbu
2026-09-20 01:01:37 -05:00
LICENSES add SPDX/REUSE license headers 2026-08-25 22:58:26 -05:00
src A painted panel frames every key 2026-09-20 01:01:37 -05:00
tests Reach the networked models, and read Elgato's own documentation 2026-09-20 00:41:44 -05:00
tools A painted panel frames every key 2026-09-20 01:01:37 -05:00
.gitignore updates 2026-08-20 20:50:10 -05:00
build.zig Reach the networked models, and read Elgato's own documentation 2026-09-20 00:41:44 -05:00
build.zig.zon Reach the networked models, and read Elgato's own documentation 2026-09-20 00:41:44 -05:00
flake.lock update 2026-09-17 14:07:01 -05:00
flake.nix Fuzz the parsers, with a loop that does not need the compiler's fuzzer 2026-09-17 19:32:19 -05:00
README.md A painted panel frames every key 2026-09-20 01:01:37 -05:00
REUSE.toml add SPDX/REUSE license headers 2026-08-25 22:58:26 -05:00

zig-streamdeck

A Zig library for driving Elgato Stream Deck devices: enumerating the decks attached to a machine, setting the image on each key, changing the brightness, and reading the events the device sends when a key is pressed, an encoder is turned, or the touchscreen is touched.

What this library knows about the hardware comes from Elgato's own HID documentation where that covers a model, from two independent reverse-engineered implementations where it does not, and from running real devices wherever one was to hand. Each row of the table below says which.

Most models plug into USB and are driven over HID. The Stream Deck Studio and the Stream Deck Network Dock do not: they take Ethernet, and are driven over TCP. Both are the same StreamDeck here — the network carries the very same HID reports, so the key tables, the image paging and the event decoding are written once and do not know which they are talking to. See Networked devices.

It talks to the hardware through zig-hidapi, which is pure Zig with backends for Linux, FreeBSD, Windows and macOS, so there is no C hidapi to link against. Every blocking operation takes a std.Io and is dispatched through the caller's I/O implementation rather than blocking a thread outright.

This is a work in progress. The tree builds and its tests pass, and the device tables, key image encoding and event decoding are largely in place, but the API is not settled and almost none of it has been exercised against real hardware. Expect it to move under you.

Images are drawn on a z2d Surface and written out by z2dimg, which encodes a surface straight to BMP or JPEG. image.transform applies the rotation and flips a particular display needs, and bmp.zig or jpeg.zig encodes the result — nothing has to be converted into a second library's image type on the way, so there is one representation from the drawing to the wire.

That the encoder understands this hardware is not incidental. The models that take BMP parse a fixed header rather than a format, so bmp.zig asks for z2dimg's .rgb24 depth, which is the twenty-four bit BI_RGB under a plain BITMAPINFOHEADER that those devices accept and nothing else. Its JPEG encoder writes a JFIF APP0 segment of its own accord, which matters because every implementation known to drive a Stream Deck emits one and none has ever been shown to work without.

Requirements

  • Zig 0.16
  • Linux with the hidraw driver (CONFIG_HIDRAW), i.e. /dev/hidraw* present

Only Linux is supported, and the reason is the image encoder rather than the HID layer: z2dimg builds for Linux only at present. zig-hidapi has backends for FreeBSD, Windows and macOS as well, and nothing in the network transport is Linux-specific either, so the rest of this would follow whenever the encoder does.

Supported devices

Each model is described by its own module under src/devices/, which declares the USB vendor and product ids, the shape of the key grid, and what the keys can display. StreamDeck.Type identifies a device from the vendor and product id pair it reports.

Model Product id Keys Key image Encoders Rests on
Stream Deck Original 0x0060 5 × 3 72×72 BMP two references
Stream Deck Original V2 0x006d 5 × 3 72×72 JPEG hardware
Stream Deck Mini 0x0063 3 × 2 80×80 BMP hardware
Stream Deck Mini (Discord) 0x00b3 3 × 2 80×80 BMP hardware
Stream Deck Mini MK.2 0x0090 3 × 2 80×80 BMP hardware
Stream Deck MK.2 0x0080 5 × 3 72×72 JPEG two references
Stream Deck MK.2 (Scissor) 0x00a5 5 × 3 72×72 JPEG two references
Stream Deck Neo 0x009a 4 × 2, plus 2 touch keys 96×96 JPEG hardware
Stream Deck XL 0x006c 8 × 4 96×96 JPEG two references
Stream Deck XL V2 0x008f 8 × 4 96×96 JPEG two references
Stream Deck Plus 0x0084 4 × 2 120×120 JPEG 4 hardware
Stream Deck Plus XL 0x00c6 9 × 4 112×112 JPEG 6 Elgato's docs
Stream Deck Studio 0x00aa 16 × 2 80×120 or 144×112 JPEG 2 they disagree
Stream Deck Pedal 0x0086 3 pedals, no displays two references
Stream Deck 6 Module 0x00b8 3 × 2 80×80 BMP three references
Stream Deck 15 Module 0x00b9 5 × 3 72×72 JPEG three references
Stream Deck 32 Module 0x00ba 8 × 4 96×96 JPEG three references
Corsair Galleon K100 0x2b18 3 × 4 160×160 JPEG 2 one reference

"Rests on" is what each row's numbers are actually good for. Hardware means the model was run against a real device and did what this says; Elgato's docs means its HID documentation states it and nothing has run it; two references means python-elgato-streamdeck and node-elgato-stream-deck agree and nothing has run it; three adds the module tables in DDRBoxman/gadgetdeck's protocol notes; one reference means a single implementation says so and nothing else mentions it at all, which is the weakest thing here. The distinction is not academic: the Plus was the first device here whose controls are not all the same width, and running it turned up a bug two references had happily agreed through — Events.decode gave every parser the body sized for the largest control, so a turn of one dial reported nine of them on a device with four.

The vendor id is 0x0fd9 throughout except for the Galleon, which is a Corsair keyboard with a Stream Deck panel built into it and reports Corsair's 0x1b1c. Corsair owns Elgato and Elgato's own SDK counts it among the Stream Decks, so it belongs here — but it is the one device whose numbers rest on a single source, it has never been run, and it is the only model that stops listening if it is left alone: it wants keepAlive about twice a second, which this library will not do for you. Touchscreen describes the screens: the Plus and the Plus XL carry one that reports where it was touched, 800×100 and 1200×100 respectively, and the Neo carries a 248×58 info strip that only displays — its two touch keys report through the key control, as keys 8 and 9. TouchScreenFeature.touch is what separates the two. A screen is driven the way the keys are: blankImage for a surface of the right size, encodeImage to see the bytes, and setImage to send a surface. The Neo's strip takes no rectangle, so writing part of it is error.PartialWriteUnsupported rather than a strip that ends up half drawn.

Adding another model is a new file in src/devices/, an entry in src/devices.zig, and a tag in StreamDeck.Type — and the exhaustive switches in StreamDeck.Type.generation, Encoders.count and Touchscreen.readSize then refuse to compile until the new model has been accounted for in each.

The Module entries are the bare panels Elgato sells for building into a panel of your own, and the two named editions are ordinary devices with a product id of their own; each is electrically the model it is derived from, so they share its key table rather than repeating it.

Elgato's HID documentation gives a key image size for six of these families, and every one of them matches what was already in the table. What it does not cover is the Studio, the Pedal, the Original and the Galleon.

  • The Studio's key resolution is still not known. python-elgato-streamdeck says 80×120 and node-elgato-stream-deck says 144×112, no third implementation drives the device, and Elgato documents no HID protocol for it. The table names both rather than a number of its own, and src/devices/ElgatoStreamDeckStudio.zig carries 96×96 as a placeholder marked as one.

    There is now a way to settle it without guessing. getUnitInfo asks a device for its own key and panel dimensions, and a Neo and a Plus both answered with exactly the sizes Elgato documents for them. Pointing it at a Studio would end the argument in one call — over USB, or over the network transport described below.

  • The Plus XL's rotation is settled. Both references agreed on a quarter turn and disagreed about which way; Elgato's documentation says 90° counterclockwise, for the keys, the panel and the touchscreen alike. This library had it turning the other way, and now does not. No hardware has confirmed it.

  • The Galleon rests on one implementation and has never been run.

What the hardware settled

Three models have been run against a real device, and each one answered something the references could not.

The Original V2 was the first: keys, events, the serial number and firmware version at the offsets the audit corrected, and both the BMP and JPEG paths.

The Neo settled what its own table used to get wrong. It reports ten key states, not the eleven the entry here once described, and the key labelled 6 arrives as index 6 — so the grid is keys 0 through 7 and the two touch keys are 8 and 9, which the audit had argued from the table contradicting itself rather than from a measurement. Those touch keys light when setTouchKeyColor is given a colour, and pressing one arrives as .down at index 8 or 9 of an ordinary key report and puts the backlight out: they report through the key control, and no touchscreen report is ever sent, which is what Touchscreen.readSize assumes for this model and had no way to check. Its 248×58 info strip takes an image through the 0x02 0x0b header and shows it the right way up — the one thing byte-level checking could not reach, since a strip written upside down is written just as successfully, and a seven segment 6 turned over is a convincing 9.

The Mini settled its own image transform, which two references had agreed on and got wrong. Putting the same figure on four keys under the four corrections a table can be wrong by, and looking at which read correctly, found that every key image on a Mini had been coming out upside down — the table carried a quarter turn with a top/bottom flip where it wants that turn with a left/right one. Elgato's documentation says to rotate ninety degrees clockwise and mentions no flip at all, so the Mini does what the documentation says and one thing more; the likeliest reason is that bmp.zig writes its rows bottom-up and the documentation does not say which row order these devices expect.

The Neo also settled what the panel behind the keys actually is. A checkerboard painted over its whole 480×320 screen appears through every key and through the info strip, with the pattern running continuously from one key to the next rather than restarting in each. The keys are windows onto one sheet of glass, which is what Panel is built on, and a marker in the image's top-left corner came out under the top-left key. fillScreen and fillKey were confirmed on it too, along with getUnitInfo, which answered with exactly the sizes Elgato documents.

The Original V2 came back for a second look and confirmed the panel. It reports its own LCD as 480 × 272 through getUnitInfo, which is exactly what Elgato documents for the Classic family and what Panel had been carrying on documentation alone. Its grid, key size and all three firmware versions check out too, and its two application slots report identically as the Plus's do.

It also settled the background, though not the way it was meant to. The HID command for storing one did nothing observable, five different ways — but painting the panel and then drawing over only some of the keys produced exactly the effect Elgato's software calls a background, on this model and on the Neo and the Plus. See Backgrounds.

The Plus exercised the two riskiest repairs from the audit and found a third fault nobody had looked for. Its 800×100 touchscreen takes an image through the v3 header, which the audit had found wrong three ways at once — a first byte of 0x2c where the protocol wants 0x02, length and page_number the wrong way round, and extern struct padding making the whole thing 18 bytes rather than 16. A drag across that screen arrives with the coordinates it was made at, which is the other repair working: the Plus used to claim a touchscreen read size of zero, so its report body was eight bytes and Touchscreen.Event.init refuses anything under ten — no touch event could ever have been decoded on this model. Its four dials turn in both directions, with the detents signed as they should be. And turning one of them reported nine dials, which is the bug described above.

Installation

Fetch the package into your build.zig.zon:

zig fetch --save git+https://git.jcollie.dev/jeff/zig-streamdeck.git

Then wire the module up in build.zig:

const streamdeck = b.dependency("streamdeck", .{
    .target = target,
    .optimize = optimize,
});

exe.root_module.addImport("streamdeck", streamdeck.module("streamdeck"));

Usage

src/main.zig is a complete example: it enumerates every attached deck, spawns a task per device, puts each key's own number on it, and logs the events that come back.

const std = @import("std");
const streamdeck = @import("streamdeck");

pub fn main(init: std.process.Init) !void {
    const io = init.io;
    const alloc = init.gpa;

    var decks: streamdeck.Enumerator = undefined;
    try decks.init(io, alloc);
    defer decks.deinit(io, alloc);

    while (try decks.next(io, alloc)) |found| {
        const sd = found.deck;
        defer found.deinit(io, alloc);

        var serial_buf: [32]u8 = undefined;
        var version_buf: [32]u8 = undefined;
        std.debug.print("{s} running firmware {s}\n", .{
            try sd.getSerialNumber(io, &serial_buf),
            try sd.getFirmwareVersion(io, &version_buf),
        });

        try sd.reset(io);
        try sd.setBrightness(io, 50);

        while (true) {
            const event = try sd.events.read(io, alloc);
            defer event.deinit(alloc);
            switch (event) {
                .keys => |e| std.debug.print("key: {any}\n", .{e.event}),
                .encoders => |e| std.debug.print("encoder: {any}\n", .{e.event}),
                .touchscreen => |e| std.debug.print("touch: {any}\n", .{e.event}),
            }
        }
    }
}

An Enumerator is initialized in place rather than returned, because it holds the scratch the enumeration works in and hands back pointers into its own storage — so it must not move once started. StreamDeckInfo carries the open deck and the DeviceInfo the system reported: the serial number, the product and manufacturer strings, and where the device sits on the bus. Keep it rather than asking the open device, which answers less — on Linux a device reports no manufacturer at all.

A StreamDeck is mostly a handle plus five control groups, each of which finds its way back to the device with @fieldParentPtr and answers according to the model:

  • keys — how many keys there are, what each can display, and setImage to put a picture on one. The surface is rotated and flipped as that model's displays are mounted and encoded as BMP or JPEG before being split across as many HID reports as it takes. Nothing is scaled: a surface has to be the size the key's KeyImageFeature asks for, and blankImage is the easy way to get one that is.
  • encoders — the rotary encoders, on the Plus (4), the Plus XL (6) and the Studio (2).
  • touchscreen — the screens: a touchscreen on the Plus and the Plus XL, and the Neo's info strip, which displays without reporting touches.
  • panel — the single screen behind the keys, which the key images are cut-outs of, and which can be painted whole.
  • events — decoding of the input reports the device sends when something is pressed, turned or touched.

events.read waits for the device to say something and returns what it said. A report it will not parse is one of ReportErrorShortReport, UnknownReportType, UnknownControlType, UnknownEventType — and is worth carrying on through; anything else means the deck is no longer reachable, which is the end of that device. The two have to be told apart, because the obvious loop around read spins forever otherwise: over USB on a deck somebody unplugged, and over TCP on a connection that will never come back.

The library writes nothing to the log. What to do about a report it will not parse is the caller's to decide: a daemon may want it logged, a one-shot tool may want to exit, and neither choice belongs to a library. src/main.zig logs them and carries on listening, which is the usual answer but not the library's to assume.

None of those errors means the device is broken. A Stream Deck can be unplugged part-way through a report, and a model whose tables here are wrong will send control and event types this library does not expect — which is exactly the case where a caller wants to see the error rather than have it swallowed.

Networked devices

The Stream Deck Studio and the Stream Deck Network Dock are not USB devices. They take Ethernet — the Studio is powered over it — and are driven over TCP, on port 5343.

const address = try std.Io.net.IpAddress.parse("192.0.2.10", streamdeck.Tcp.default_port);
const sd = try streamdeck.connect(io, alloc, address);
defer sd.close(io);

Everything above the socket is the same as for a USB deck. keys, encoders and events behave identically, setImage takes the same surface, and the image that goes across is byte for byte the one that would have gone over USB — the same kilobyte reports with the same headers, not re-chunked for the network. That is what makes one StreamDeck able to drive either.

These devices are not enumerated. Enumerator walks the HID devices attached to this machine and will never find one, because it is somewhere on a network rather than plugged in. There is no discovery here either, so the address is the caller's to know; the devices do advertise themselves over mDNS as _elg._tcp, which is a reasonable thing to add later but is a protocol of its own.

What a caller has to do

Keep reading. The device pings every few seconds and hangs up on a host that has not answered within about five. Answering happens inside events.read, and inside any question asked of the device — but nothing runs on its own, so a program that spends five seconds drawing without reading will be disconnected. An application that is doing anything with a Stream Deck sits in events.read anyway; one that does not has to say so.

The alternative was a task owning the socket, with a queue and a lock between it and the caller. That is a lot of machinery for a library that otherwise has none, and it would impose a threading model on every caller to fix a problem most of them do not have.

What differs from USB

Two things, and both are the protocol's doing rather than this library's:

  • reset is refused, with error.Unsupported. No implementation of this protocol has ever been seen to perform it, and there is nothing to copy — the reference client refuses it outright rather than sending something. Inventing an encoding for a command that clears the device would be guessing with the device's state. resetKeyStream and setBrightness work normally.
  • The serial number and firmware version come from elsewhere. Over USB they are feature reports 0x06 and 0x05; over the network they are commands 0x84 and 0x83 in a separate namespace, whose replies declare their length big-endian in a protocol that is little-endian everywhere else. getSerialNumber and getFirmwareVersion hide the difference.

A Network Dock connects and identifies itself — it reports a product id of 0xffff, which is Elgato's stand-in rather than an id anything was assigned — and is then refused with error.NetworkDockUnsupported. The dock is only a way in: the decks are behind it, each on a connection of its own, reached through a table the dock keeps. Driving one is a follow-up rather than something this does today.

How it is put together

framing.zig is the protocol with the socket taken out of it: frames in and out, the keepalive exchange, the command replies. It touches no socket and no std.Io, which is what lets it be unit tested and fuzzed without hardware — the same split Events.decode and Keys.encodeImage already make, and for the same reason, only more so: these bytes came off a network rather than out of a USB device.

Tcp.zig is the thin half that owns the socket, and Transport.zig is the tagged union that lets StreamDeck hold either a HID device or one of these. It is a union rather than a vtable because most of this library is decided by which model a device is and can be tested with no device at all — which the tests do, with a .none transport that answers every call with error.NoTransport. A vtable would make that a pointer nobody set.

How far to trust it

Not very far yet, and further than the Studio's key size.

Elgato publishes nothing about this protocol — not a specification, not a port number, not an SDK. What is here was read out of three independent reverse-engineered implementations, which the references below name, and they agree with each other on the framing, the keepalive exchange and the image path. By the standard the device table uses that is three references.

But no networked device has ever been run against this code. The device table's grades are about numbers that have a reference to rest on; this is a whole transport that nothing has exercised, and "three implementations agree" is exactly the position that was held about the Plus before running one turned up a bug all of them had agreed through. The loopback test in Tcp.zig plays a device convincingly enough to prove this library is self-consistent, and proves nothing whatever about what real hardware answers.

The screen behind the keys

A Stream Deck's keys are not separate displays. Every model that shows images has one LCD panel, and the keys are windows cut out of it — which is visible the moment you paint the whole thing:

var surface = (try sd.panel.blankImage(alloc)).?;
defer surface.deinit(alloc);
// ...draw on it...
try sd.panel.setImage(io, alloc, surface);

A checkerboard written that way on a Neo shows through every key and through the info strip, with the pattern running continuously from one key to the next instead of restarting in each. That is what Panel is for: a background that runs behind and between the keys, and one write that repaints everything instead of a report per key.

Model family Panel
Classic (Original V2, MK.2, MK.2 Scissor, 15 Module) 480 × 272 — confirmed on hardware
XL (XL, XL V2, 32 Module) 1024 × 600
Neo 480 × 320
Plus 800 × 480
Plus XL 1280 × 800

The sizes are Elgato's, and a device will confirm its own — see getUnitInfo below. The Mini family has a panel too, and Elgato documents its size, but the only output report those devices accept writes a key's image bank, so there is no command to send and getFeature reports none. The Studio and the Original have no documented size at all.

Backgrounds

Elgato's software has a feature it calls a background: an image that "sits behind your key layout", showing wherever your key artwork does not cover it. It is worth knowing two things about it. The first is that it is a feature of the desktop application, not of the device — and the second is that you can have the same effect here without it.

How to actually do it

Paint the panel, then draw over only the keys that want a foreground. A key you leave alone goes on showing the panel:

var background = (try sd.panel.blankImage(alloc)).?;
defer background.deinit(alloc);
// ...draw on it...
try sd.panel.setImage(io, alloc, background);

// Only the keys that need artwork. The rest keep showing the background.
try sd.keys.setImage(io, alloc, 0, icon);

That is the whole mechanism, and it works because the keys are windows onto one sheet of glass rather than separate displays. "The background shows where the foreground is transparent" turns out to mean nothing more than not writing that key.

Confirmed on a Stream Deck Original V2, a Neo and a Plus: panel painted with diagonal stripes, key images drawn over the even-numbered keys only, and the odd-numbered keys show the stripes.

A painted panel frames every key

A key cap shows slightly more of the panel than the key image covers, so a painted panel is visible as a border around every key, not only the ones left alone. Paint the panel green, write a flat black image to every key, and all of them keep a green edge — confirmed on the same three models.

That is the physical cap rather than anything this library does: the image occupies its documented size exactly, and the cap is wider than it, so it also shows some of the gap around it. A Classic panel is 480×272 carrying fifteen 72×72 keys, which leaves 120 pixels across and 56 down for margins and gaps. Neither Elgato's documentation nor either reference implementation mentions that any of it is visible — node-elgato-stream-deck models a key's bounds as exactly its image size.

It is worth knowing rather than worth fixing, and it is probably what a background is meant to look like: whatever is on the panel becomes a frame around every key, so artwork drawn key by key sits in it rather than against black. If you want a key covered edge to edge, paint the panel behind it too.

Why it is the application's feature and not the device's

Elgato's own description gives it away three times over. A background is set by right-clicking a key in the app and applies to a profile and a page — concepts the device knows nothing about. A screensaver is shown "when the Stream Deck app is running, and the computer is locked", which a firmware feature would not require. And the list of models supporting it includes the Stream Deck Pedal, which has no displays at all.

So the application composites your artwork over its own copy of the background and sends ordinary, flattened key images. That also explains the transparency: key images are JPEG, which has no alpha channel, so nothing transparent ever reaches the wire.

The HID command, which is something else

There is also a real, documented HID command for storing a background in the device — and it is not how the desktop feature works.

try sd.panel.setBackground(io, alloc, 0, surface);  // store it
try sd.panel.showBackground(io, 0);                  // and put it up
Family Store (0x02 0x0d) Show by index (0x03 0x13)
Classic (Original V2, MK.2, MK.2 Scissor, 15 Module) yes no
XL (XL, XL V2, 32 Module) yes yes
everything else no no

The asymmetry is Elgato's: the Classic family's page documents Update Background and its setter table has no 0x13. backgrounds() reports the two capabilities separately for that reason.

It has never been seen to do anything. A Stream Deck Original V2 — Classic family, where the command is documented — took all thirty-two pages of a background without complaint, and then:

  • Show Background by Index (0x03 0x13): accepted, nothing appeared. Expected; not a command on this family.
  • Show Logo (0x03 0x02): showed the manufacturer's logo, not the stored image.
  • A differential scan, all 256 feature reports read before and after the upload: none changed. The device admits to nothing.
  • Left to go idle with a five second sleep timeout: blanked to darkness.
  • Colour-keyed transparency: six candidates painted flat over a stored background — black, white, magenta, green, mid-grey, near-black. All six stayed flat.

So the device is either discarding the upload or displaying it by some means nobody here has found. One consequence worth being explicit about: the field order below is unvalidated too, because a header with its fields the wrong way round and a write that is simply discarded look identical from this side.

setBackground is in the API because the command is documented and costs little to offer, and it is marked untested in its own doc comment. An XL would settle it, being the one family with both halves.

Mind the field order

The background header is the key image header with the chunk index and the chunk size the other way round — page number at +0x04 and length at +0x06, where a key image has length at +0x04 and page number at +0x06. Elgato's two tables differ in exactly that and nothing else, which is easy to read past.

Getting it backwards would not fail loudly. The first page of a small image has page number zero and a size of about a kilobyte, so a swapped header asks the device to write page 1016 of nothing, and the device shows no background with nothing to say why. This library had precisely that bug in the touchscreen header once, and it looked the same there. ImageHeaderV6's test pins both layouts side by side so the difference is visible rather than implied.

getUnitInfo reports lcd_gallery, and it is tempting to read that as the number of background slots. It is not.

A Stream Deck Neo reports four LCD images in its gallery. It was sent a background and asked to show it; it accepted both without complaint and displayed nothing at all. A Stream Deck Plus reports zero and behaves identically.

The wider lesson is worth stating because it bit repeatedly while this was written: a Stream Deck accepts writes it does not implement. It answers all 256 feature report ids, and takes output reports for commands it has never heard of, in both cases without an error. Nothing about a write being accepted means it did anything. Only looking at the device settles it.

Asking a device about itself

getUnitInfo is the newer protocol's Get Unit Information, and it is the one call that checks this library's tables against the hardware rather than against another implementation:

const unit = try sd.getUnitInfo(io);
// unit.key_width, unit.key_height, unit.lcd_width, unit.lcd_height, ...

A Neo and a Plus both answered with exactly the key and panel sizes Elgato documents for them. Where a table and a device disagree, the device is right.

One trap: rows and cols describe the keypad matrix, not the number of keys. A Neo answers four by three — twelve positions — where it has eight keys and two touch buttons, the rest of its bottom row being taken up by the info strip. Pixel sizes it confirms outright; grid shapes take judgement.

The Mini family has no such report, and getUnitInfo returns error.Unsupported there rather than reading the zeroes one of them answers with.

Other whole-device commands

Call What it does
fillKey Paint one key a solid colour instead of encoding an image for it. The only way to light a Neo's touch keys, and much cheaper than a JPEG for a key that is one colour.
fillScreen Paint the whole panel a solid colour in one feature report.
setSleepTimeout / getSleepTimeout How long the device sits idle before blanking itself; zero disables it. See the note below.
getFirmwareVersionOf A device carries firmware in several parts. See below.
getFirmwareReport The same, plus the thirty-two bits the device puts in front of the version string. See below.
keepAlive Only the Galleon needs it, about twice a second, and this library will not do it for you — there is no timer here. It does nothing on every other model, so it is safe to call on whatever is attached.

fillKey, fillScreen and getUnitInfo were confirmed on a Neo and a Plus, and all three firmware versions read correctly from a Mini, a Neo and a Plus. Note that fillScreen is a fill and not a mode: the next image written over it covers it, which is easy to mistake for the command not having worked.

A device has more than one firmware version

Elgato documents three getters for the version, and they are three different programs rather than three ways of asking the same question:

Report Firmware What it is
0x05 / 0xa1 .ap2 The primary application firmware. What a user means by "the firmware version", and what getFirmwareVersion reads.
0x04 / 0xa0 .ld The bootloader.
0x07 / 0xa2 .ap1 The backup application firmware.

The Mini family numbers them 0xa00xa2 instead, and all three were read correctly off a Mini, a Neo and a Plus. They are genuinely independent: a Neo reports 1.00.013, 1.00.003 and 1.00.011 for the three. A Plus reports its two application slots identically, which is what both slots holding the same image looks like.

The Studio has two more, because its encoders are a board of their own with a bootloader of their own:

Report Firmware What it is
0x11 .encoder_ap2 The encoder module's application firmware.
0x13 .encoder_ld The encoder module's bootloader.

Those two rest on node-elgato-stream-deck, whose Studio driver asks for exactly these where every other model gets the generic three. Elgato publishes no HID documentation for the Studio at all, so there is nothing to check them against, and no hardware here has one. firmwareReportFor reports them only for the Studio and getFirmwareVersionOf answers error.Unsupported elsewhere.

Not for want of asking, either: a Stream Deck Plus was asked for 0x11 and 0x13 and answered with the same empty stub it gives any report it does not implement, and Elgato's Plus XL page lists that model's getters as 0x04 through 0x08 and 0x0a and nothing more. So having encoders is not what brings the extra firmware; being a Studio is.

Firmware.all is the lot, for a caller that wants to print them, and zig build probe does.

Reports Elgato does not document

zig build probe -- --scan asks a device for all 256 feature reports and prints the ones that answer with something. It has to work that way round because a device answers every report id rather than refusing the ones it does not implement — so "it answered" means nothing, and what separates a real report from a stub is that the stub is identical whatever you ask for. The scan derives the stub from ids nothing could plausibly use and subtracts it.

On a Neo and a Plus, nine reports differ from the stub. Six are the documented ones (0x040x08, 0x0a). Three are not documented anywhere:

Report Neo Plus
0x09 00 00 00 00 69 16 01 00 01 00 e7 34
0x0b length 2, then 0x0120 length 2, then 0x0110
0x0c length 8, then zeroes length 8, then zeroes

All three are stable across repeated reads and across separate runs, which rules out counters and anything sampled like a temperature. Beyond that this library does not guess, and does not expose them: three stable numbers of unknown meaning are a thing to write down, not an API. They are recorded here so the next person has somewhere to start.

The Mini answers only 0x03, 0x04 and 0xa00xa3, which is its documented set plus the undocumented 0x04 that duplicates 0xa1.

The firmware checksum, and why it is not checked

A firmware report carries four bytes between its length and its version string. Elgato's documentation labels them Checksum on the newer protocol, and that is all it says — not what they cover, not the algorithm, not even the endianness. On the Mini family the same four bytes are listed as "N/A".

getFirmwareReport hands them over as a u32. It does not verify them, and that is a conclusion rather than an omission.

They were attacked with the hardware to hand: three devices, six firmware reports, and a search over 41 checksum algorithms — the RevEng catalogue of 32-bit CRCs, eight 16-bit ones, Adler-32, Fletcher-32, FNV-1 and FNV-1a, djb2, sdbm, byte and word sums, and MD5, SHA-1 and SHA-256 prefixes — crossed with 23 ways of deriving an input from the report (the string field, the string cut at its terminator, with and without the report id and length, the whole report with the checksum zeroed, the version parsed back into numbers, and so on) and all 24 byte orders of the stored value. A little over twenty-two thousand combinations, each required to agree on all six observations at once. None did.

The reason turns out to be visible in the older protocol. Those same four bytes, the ones Elgato's Mini page declines to describe, read as a plain byte count on a Stream Deck Mini:

Report Version Four bytes, little-endian
0xa0 LD (bootloader) 0.01.002 329,560 — 322 KiB
0xa1 AP2 (primary) 3.00.001 3,397,852 — 3.28 MiB
0xa2 AP1 (backup) 3.00.000 3,354,892 — 3.20 MiB

A small bootloader and two application images of nearly the same size, differing by 42,960 bytes. All three are under 2²⁴, which a uniformly random 32-bit checksum would manage about once in sixteen million times.

So the four bytes in that slot describe the firmware image, not the eight ASCII characters beside them — and the newer protocol's checksum is a checksum of an image this library never sees. It cannot be verified from the host, and no further searching over the version string will change that.

It is still worth reading. The value changes when the firmware changes, so two reads that disagree mean the device was reflashed underneath you. The observed values from a Neo and a Plus are kept as a test in StreamDeck.zig, so that anyone who picks this up again starts with real data:

Device Report Version Checksum
Neo 0x04 LD 1.00.003 0x01c8a81a
Neo 0x05 AP2 1.00.013 0xe19327d5
Neo 0x07 AP1 1.00.011 0x63aa63e2
Plus 0x04 LD 2.0.0.0 0x325db0b1
Plus 0x05 AP2 2.0.3.1 0x20a0081e
Plus 0x07 AP1 2.0.3.1 0x20a0081e

The Plus reports its two application slots byte for byte identically — same version, same checksum — which is what two slots holding the same image looks like, and is why one device alone could not have settled this.

The sleep timeout lags its own write

setSleepTimeout takes a count of seconds, little-endian, in feature report 0x03 behind command 0x0d on the newer protocol and in 0x0b behind 0xa2 on the Mini family. Zero disables sleep rather than meaning "immediately".

A device does not apply it before answering the next question. Read it straight back and you get the value it held a moment ago, which looks exactly like the write having failed — and looks worse than that, because a run of writes then produces a run of answers each one step behind, as though the device were echoing. It is not; it is just slow to commit.

A Mini, a Neo and a Plus were each given two different values and asked to read them back. With no gap at all, every answer lagged. With ten milliseconds, all three were correct every time, on both values, and restored cleanly afterwards. Elgato recommends a fifty millisecond polling interval elsewhere in its documentation, and fifty is comfortably enough here.

Nothing in this library waits on your behalf — there is no timer in it — so a caller that wants to confirm the write has to leave the gap itself. The round-trip lives in zig build probe -- --sleep, which reads the current value, writes two others, checks each, and puts the original back.

Diagnostic images

diagnostic.keyIndexImage draws a key's own index as seven segment figures on a key-sized image, with a rule under them; Keys.indexImage is the same thing sized from the key's own feature, so a caller need not know how big that key's display is. diagnostic.Polarity picks yellow figures on black or the exact inverse.

The example uses them: every key shows its index at rest and inverts while it is held down, which is the quickest way to see that key numbering, image transforms and event decoding all agree with each other on a device. On a model with a screen, the same figure is echoed there when a key goes down, so one press checks the key path and the screen path against each other.

The rule is not decoration. A key image is rotated and flipped on its way to the display, so a device given the wrong transform shows a 6 where a 9 belongs and looks entirely reasonable doing it — upside down, a seven segment 6 is a 9. An underlined 6 inverted is a 9 with a line over it, which is unmistakable.

The figures are drawn from axis-aligned rectangles rather than set in a typeface. z2d renders text from a TrueType font loaded at run time, and a diagnostic that needs an asset shipped alongside it is one that stops working exactly when something else has already gone wrong. Rectangles also land on whole pixels, so the result is crisp at any key size rather than blurred by antialiasing.

Fuzzing

zig build fuzz                     # 10,000 inputs per target
zig build fuzz -- --runs 100000
zig build fuzz -- --target events-decode --seed 42

The targets are in tests/fuzz.zig and cover everything that turns bytes the hardware said into something this library believes: the input report framing in Events.decode, the key, encoder and touchscreen report parsers under it, the image path from a drawn surface to the bytes that would go on the wire, and the network framing in framing.zig.

That last one matters most. The rest parse what a USB device said, which is at worst a device this library has the wrong tables for; the framing parses what came out of a socket, and a header declaring its own payload length is exactly the shape of thing that reads past the end of a buffer when it is got wrong. Both of its targets build a well formed prefix — the magic, or a reply's leading byte — around the generated bytes, because random input contains the magic about once in four billion and a target waiting for one would spend the whole run in an early return. The malformed shapes are in the corpus instead, where they are deliberate. Each picks a model first, so the device tables are exercised as well as the parsers — a report that is harmless for one model is a different length and a different shape for another.

zig build test also runs every target over the corpus, so they keep compiling and an input that once failed keeps being tried.

The loop in tools/fuzz.zig is hand written, and it is worth saying why rather than using zig build test --fuzz. On Zig 0.16.0 that does not compile at all for a project with a fuzz test in it: the standard library's test runner hands @errorReturnTrace()'s result to a function that takes the other kind of stack trace. The flake patches that one line, after which it compiles — and then aborts, because nothing in this release populates the table of program counters the fuzzer works from. So the loop here brings its own generator and corpus instead. The in-tree test "fuzz" is wired up regardless, ready for the day coverage works.

Both the tests and the loop run the allocating paths with an allocation made to fail. std.testing.checkAllAllocationFailures walks every allocation a handful of fixed inputs make, failing each in turn and checking that the error comes back out and that nothing was left behind; the fuzz loop does the same to one input in four, at a random point, which reaches the error paths of inputs nobody thought to write down. A test with a working allocator never takes those paths at all, so an errdefer that is only needed when the second allocation fails is otherwise never exercised.

Two things that loop reports are worth watching:

  • Accepted counts. Smith.slice hands back an empty slice for a length it does not like and Smith.value answers a range's minimum when the input runs out, so a generator that is subtly wrong reports millions of iterations having tested the empty string every time. The driver prints how many inputs got far enough to produce something, and fails the run if a target accepted none. That check caught exactly that bug while this was being written.
  • The failing input. Most of what a fuzzer finds in Zig is a panic, which ends the process rather than coming back as an error. A panic handler prints the target and the bytes before the trace, so a finding is reproducible rather than just a stack trace.

Permissions

/dev/hidraw* nodes are normally root-only. Enumerating them needs no permission — the device list comes from /sys/class/hidraw — but opening one does, so an unprivileged user gets error.AccessDenied from Enumerator.next rather than an empty list. That is a better answer than the silence this used to give, and it is worth knowing which of the two you are looking at. Grant access with a udev rule rather than running as root — in /etc/udev/rules.d/70-streamdeck.rules:

KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", MODE="0660", GROUP="plugdev"

Then udevadm control --reload-rules && udevadm trigger, replug the device, and make sure your user is in the group you named.

Where this lives

The repository is hosted on my Forgejo instance, which is where the issue tracker lives:

git clone https://git.jcollie.dev/jeff/zig-streamdeck.git

The mirrors carry the same history and are there so that the code outlives any one host:

Cloning with Radicle

This repository is also published on Radicle, a peer-to-peer code collaboration network. Its Repository ID is:

rad:z3Vd5zCPSYfAn5ygVN19Lw9wRGyku

With the rad CLI installed and a local identity created (rad auth --alias <name>), start your node and clone:

rad node start
rad clone rad:z3Vd5zCPSYfAn5ygVN19Lw9wRGyku

rad clone finds seeds seeding the repository through your node's routing table, so the node needs to be running and connected. If discovery fails because no seed has been found yet, name one directly:

rad clone rad:z3Vd5zCPSYfAn5ygVN19Lw9wRGyku --seed <NID>

The clone checks out the default branch (main) and leaves you seeding the repository, so your node will serve it to other peers. rad sync pulls later changes.

To publish work back, push to the rad remote and open a patch:

git push rad HEAD:refs/heads/my-change
rad patch open

The repository is delegated to a single key, did:key:z6MkoM8gqRFf1hARf3cSX2hhe7kgTfKSQpNksR9uKErWotKq, which is what authorizes changes to main.

Development

A Nix flake provides the toolchain:

nix develop

Build, test, and run the example:

zig build
zig build test
zig build run

zig build test runs the tests of both the library module and the example.

Those tests are there as much to keep the code compiling as to check what it does. Zig does not analyze a function nothing calls, and it does not run the tests of a file nothing imports, so a file can sit in the tree for a long time quietly not compiling — which is what had happened here. Two things guard against it: src/root.zig names every file in the module in its test block, and build.zig fails the build if a file under src/ is missing from that list. Anything that cannot be run without a real device is at least referred to by a refAllDecls so that the compiler still checks it.

Be aware that zig fmt --check . will descend into zig-pkg/, the gitignored directory the Zig package manager extracts dependencies into, and report formatting in code this project does not own; check this project's own sources with:

zig fmt --check build.zig src

This repository follows the REUSE specification for licensing metadata:

reuse lint

References cited

Elgato does publish protocol documentation, which this library did not know for most of its life. It covers six device families and settles several things the implementations below disagreed about. It does not cover the Studio, the Pedal, the Original or the Galleon, so for those the two independent implementations are still all there is; where they disagree, the README and the device modules say so rather than picking a winner quietly.

  • Elgato. Stream Deck HID API. https://docs.elgato.com/streamdeck/hid/intro. First-party documentation of the protocol, with a general reference at /streamdeck/hid/general and a page per device family: Classic, XL, Neo, Plus, Plus XL, and Mini, the last documented as a legacy protocol that differs from the rest. It gives the USB product ids, key grid dimensions, key and panel pixel sizes, image rotations, and the feature and output report layouts. It is the source for the panel sizes in Panel.zig, for Get Unit Information, for the sleep timeout and colour fill commands, and for the Plus XL's rotation. It confirmed every key image size in the table above. It says nothing about the Studio, the Pedal, the Original or the Galleon.
  • Camera, Dean. python-elgato-streamdeck: Python library to control an Elgato Stream Deck. https://github.com/abcminiuser/python-elgato-streamdeck. The de-facto reference implementation. Its per-model device classes are the source for the USB product ids, the key grid geometry, the key image sizes and formats, the rotation and flips each display needs, the image report lengths and header layouts, the feature reports carrying the serial number and firmware version, and the decoding of input reports for keys, dials and the touchscreen.
  • Waller, Julian. node-elgato-stream-deck: A Node.js library for interfacing with the Elgato Stream Deck. https://github.com/Julusian/node-elgato-stream-deck. The second source. models/definitions.ts gives each model's control layout and key pixel sizes, modelInfo.ts maps product ids to models, and the imageWriter and headerGenerator services give the image report sizes, the page numbering and the header byte layouts.
  • Elgato. Elgato Stream Deck Studio — Technical Specifications. https://help.elgato.com/hc/en-us/articles/29982976486669-Elgato-Stream-Deck-Studio-Technical-Specifications. Cited for the Studio carrying thirty-two keys and two rotary encoders and no touch keys. It does not give the key resolution, which is why that figure is still unconfirmed here.

The network transport has sources of its own. Elgato publishes nothing at all about it — no specification, no port number, no SDK — so the three below are the whole of what is known, and framing.zig follows them where they agree and says so in a comment where they do not.

  • Waller, Julian. node-elgato-stream-deck, packages/tcp. https://github.com/Julusian/node-elgato-stream-deck/tree/main/packages/tcp. The primary source, and the most trustworthy: it is what Bitfocus Companion ships, and it is tested against real Studio and Network Dock hardware. socketWrapper.ts gives the header layout, the two framings and how a device is classified into one of them; hid-device/cora.ts and legacy.ts give the flags, the operation codes and the split between verbatim reports and host commands; device2Info.ts gives the child device table.
  • Haukcode. Haukcode.StreamDeck. https://github.com/HakanL/Haukcode.StreamDeck. A .NET implementation with a packet capture of Elgato's own application beside it, which is the only source that can say what the official software does rather than what a reimplementation found sufficient. Corroborates the header layout, the keepalive exchange and the big-endian reply length, and is explicit about which of its own claims are untested.
  • SKAARHOJ. go-streamdeck. https://github.com/SKAARHOJ/go-streamdeck, and the notes at https://wiki.skaarhoj.com/books/raw-panel/page/stream-deck-on-raw-panel. An independent Go implementation of the older, headerless framing, which is the third agreement on the fixed frame sizes and on the image path being unchanged from USB.

License

MIT — see LICENSES/MIT.txt.

Copyright © 2025 Jeffrey C. Ollie.