No description
  • Zig 86.7%
  • Nix 13.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie 60d6781af2
Follow zig-streamdeck to its transport rewrite
Four changes reach this side of the boundary, and two of them are worth more
than the diff makes them look.

`Events.read` answers `!Event` rather than `!?Event`. It used to return `null`
both for a report it could not parse and for a device that was no longer
there, which a caller can only treat as "nothing happened" -- so this counted
sixteen nulls in a row and called that unplugged. The library tells the two
apart now: a report error is worth carrying on through, anything else is not.
The heuristic is gone and a dead deck is noticed on the first read rather than
the sixteenth.

Serials now come from the enumeration instead of from the device.
`getSerialNumber` is a feature report and a round trip, and a deck whose
feature-report pipeline is out of step answers the previous question -- which
is not hypothetical, and is the whole reason `StreamDeck.resolve` has an
`ambiguous` case. A Plus here answered `\xCC\x9EA00WA3141ID0T` for
`A00WA3141ID0TL`: shifted a byte, a character short, and unmatchable against
any configuration. The system's own idea of the serial cannot go wrong that
way. `greendeck-decks` uses it too, which matters because that tool is where
a serial gets copied from.

The other two are mechanical: `enumerate` became an `Enumerator` that is
initialized in place and wants an allocator, and `close` takes one.

Confirmed against four decks -- an Original V2, a Plus, a Neo and a Mini --
all four enumerating with the right serial, the Original V2 running the real
configuration and the Plus the touchscreen one.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-20 00:46:35 -05:00
.forgejo/workflows Run nix flake check in CI 2026-09-19 17:28:59 -05:00
LICENSES Begin the Zig port: scaffolding and the SVG icon renderer 2026-09-17 21:42:02 -05:00
nix A --verbose flag, and a --help that says it exists 2026-09-19 17:08:30 -05:00
src Follow zig-streamdeck to its transport rewrite 2026-09-20 00:46:35 -05:00
tests Rotary encoders 2026-09-19 00:57:44 -05:00
tools Follow zig-streamdeck to its transport rewrite 2026-09-20 00:46:35 -05:00
.gitignore Begin the Zig port: scaffolding and the SVG icon renderer 2026-09-17 21:42:02 -05:00
.ziggy-schema A Ziggy schema, so an editor knows what a setting is 2026-09-19 16:57:29 -05:00
build.zig A --verbose flag, and a --help that says it exists 2026-09-19 17:08:30 -05:00
build.zig.zon Follow zig-streamdeck to its transport rewrite 2026-09-20 00:46:35 -05:00
build.zig.zon.nix Follow zig-streamdeck to its transport rewrite 2026-09-20 00:46:35 -05:00
flake.lock Begin the Zig port: scaffolding and the SVG icon renderer 2026-09-17 21:42:02 -05:00
flake.nix Nix packaging: a NixOS module for the device, a home-manager one for the rest 2026-09-19 16:35:43 -05:00
package.nix A --verbose flag, and a --help that says it exists 2026-09-19 17:08:30 -05:00
README.md A --verbose flag, and a --help that says it exists 2026-09-19 17:08:30 -05:00
REUSE.toml A Ziggy schema, so an editor knows what a setting is 2026-09-19 16:57:29 -05:00

greendeck

Drives an Elgato Stream Deck from Home Assistant, PulseAudio and the session bus. Each key shows a Material Design Icon in one of two colours depending on whether it is held, an icon can follow a Home Assistant entity's state, and pressing a key calls a service, moves an application's volume, or switches the deck to another page.

It is a daemon with no interface of its own: a configuration file, a deck, and whatever it is pointed at.

The API reference is generated from the module's doc comments and published at https://jeff.jcollie.page/greendeck/.

Requirements

  • Zig 0.16

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

  • A udev rule giving your user access to the deck:

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

    Without it, enumeration finds nothing and says so rather than failing — the nodes are simply not openable. On NixOS, hardware.streamdeck.enable writes a better one; see On NixOS.

Only Linux is supported, because zig-streamdeck reaches the hardware through /dev/hidraw* directly.

On NixOS

The flake carries both halves of a working setup, and they are separate because the two things it needs live in different places. Access to /dev/hidraw* is the system's to grant; everything else greendeck does — the session bus for MPRIS and the portal, PipeWire for volume, the decks in front of you — belongs to your session.

{
  inputs.greendeck.url = "git+https://git.jcollie.dev/jeff/greendeck.git";

  # In the NixOS configuration: the udev rule.
  # `hardware.streamdeck.enable = true;` tags the device `uaccess`, which
  # hands it to whoever is logged in at the seat and takes it back at logout.
  # Name a `group` instead only on a machine with no seat.

  # In the home-manager configuration: the daemon and its settings.
}
programs.greendeck = {
  enable = true;
  settings = {
    homeassistant.hostname = "hass.example";
    streamdecks = [ { serial_number = "AL24J2C05756"; brightness = 30; page = "home"; } ];
    pages = [{
      name = "home";
      keys = [{
        position = { x = 0; y = 2; };
        image.static_icon = {
          icon = "volume-plus";
          up = { foreground = "#969696"; background = "black"; };
          down = { foreground = "red"; background = "black"; };
        };
        actions = [{
          pulseaudio = { application_name = "PipeWire ALSA [plexamp]"; action = "volume_up"; };
        }];
      }];
    }];
  };
};

settings is the configuration file as a Nix type rather than a free-form attribute set, so a name that is not a setting, a brightness over 100 or an action that is not one of the three is an error where it is written. A tagged union — a key's image, an action — is an attribute set with exactly one attribute, so image.static_icon = { … } is a key with a static icon and naming two kinds is an error with both names in it.

Then the generated file is read back by greendeck-checkconfig while it is being built. That catches what a Nix type cannot: a page named by a deck that does not exist, a colour that will not parse, an icon name the set does not hold. A typo in a home.nix is a build failure naming the line, rather than a deck that comes up blank. Set checkConfig = false; to skip it, which is worth doing only when icon_dir points somewhere that does not exist at build time.

The token is deliberately not a setting — the store is world-readable. Point settings.homeassistant.token_file at a path outside it, or leave it null and put the token in ~/.config/greendeck/ha-token.

nix run github:…#greendeck-decks prints the attached decks and their serial numbers, which is what serial_number above wants.

Where this lives

The canonical repository is on my Forgejo instance, and Tangled carries a mirror of it:

git clone https://git.jcollie.dev/jeff/greendeck.git

It is also on Radicle as rad:zQcHFrZCoCpKSurPgF6jD9nHyp4s, which is the only name that finds it there — a peer-to-peer repository has no host to browse:

rad clone rad:zQcHFrZCoCpKSurPgF6jD9nHyp4s

All three serve the same history. CI, the published documentation and the issue tracker follow the canonical one.

Running it

$ nix run .

or, from a checkout:

$ nix develop
$ zig build run

The configuration is read from $XDG_CONFIG_HOME/greendeck/config.ziggy, which is ~/.config/greendeck/config.ziggy unless that variable says otherwise.

-c, --config <path> read this configuration instead. Taken as written, so a relative path is relative to the working directory.
-v, --verbose say what every key press, dial and touch does, and which section a swipe was given to
-q, --quiet warnings and errors only
-h, --help the above

--verbose is the one to reach for when a key does nothing and it is not obvious why: it names the action as it fires, the entity state as it arrives, and the volume before and after a dial moves. It is a run-time flag rather than a build-time one, so a running daemon can be restarted loud without rebuilding anything.

.ziggy-schema at the top of the repository describes the file for an editor: the Ziggy language server reads it and will complete a field, say what it is for, and mark one that does not exist. A test holds it to src/Config.zig name by name, so the two cannot quietly drift apart.

greendeck-checkconfigzig build checkconfig from a checkout — parses and validates a configuration without a deck or a Home Assistant in reach, and probes what it points at — every icon it can draw and every audio stream it names. That last part is worth running: a configuration can be perfectly well-formed and still name things that have been renamed out from under it.

Configuring it

The configuration is Ziggy, read straight into typed structs, so the shape of the file is the shape of src/Config.zig and there is no schema written down twice. Ziggy is a small typed data language: it knows the difference between a number and a string, and when a file is wrong it says which line and column is wrong rather than only that something is.

Three pieces of syntax, since they are the ones that differ from Zig's own literals: a list is [a, b]; a tagged union is .tag(payload), which is how a key says what kind of image it has; and an empty struct is {} while a non-empty one is .{ .a = 1 }. The document has no outer braces — its fields are the top-level lines of the file. Comments are //.

.homeassistant = .{ .hostname = "hass.example", .token_file = "~/.config/greendeck/ha-token" },
.streamdecks = [.{ .index = 0, .brightness = 30, .page = "home" }],
.pages = [
    .{
        .name = "home",
        .keys = [
            .{
                .position = .{ .x = 0, .y = 2 },
                .image = .static_icon(.{
                    .icon = "volume-plus",
                    .up = .{ .foreground = "#969696", .background = "black" },
                    .down = .{ .foreground = "red", .background = "black" },
                }),
                .actions = [
                    .homeassistant_service(.{
                        .domain = "remote",
                        .service = "send_command",
                        .data = [
                            .{ .name = "entity_id", .value = .string("remote.living_room") },
                            .{ .name = "command", .value = .list([.string("VolumeUp")]) },
                        ],
                    }),
                ],
            },
        ],
    },
],

A deck is named by serial_number or by index, and exactly one of the two. index is enumeration order, which greendeck-decks will show you and which only holds while nothing is unplugged; serial_number is the steady one.

An entry that matches no attached deck is a warning naming the entry, and the rest still run. An entry whose serial matches more than one is an error that stops startup: which deck got which page would be decided by enumeration order rather than by the configuration, so it would not do what it looks like it does.

position is .{ x, y } with y measured from the bottom, so on a five by three deck the top-left key is .{ 0, 2 } and the bottom-right is .{ 4, 0 }.

Images

static_icon One icon, whatever else is happening.
static_icon_text The same, with a label along the bottom.
homeassistant_icon The icon follows an entity's state.
mpris_icon The icon follows a media player's PlaybackStatus.
mpris_art The playing track's cover art, with icons when there is none.

Each carries an icon (a Material Design Icon name, without the mdi- prefix), and up and down colours — the second being what the key shows while it is held. Colours are CSS: a name, #rgb, #rrggbb or rgb(), and anything else is refused when the configuration is read rather than drawn wrongly later.

A homeassistant_icon carries an entity_id and a list of states. One entry must have .state = null; it is what the key shows before the entity has reported and for any state the others do not name.

.image = .homeassistant_icon(.{
    .entity_id = "switch.living_room_tv",
    .states = [
        .{
            .state = "on",
            .appearance = .{
                .icon = "television",
                .up = .{ .foreground = "yellow", .background = "black" },
                .down = .{ .foreground = "red", .background = "black" },
            },
        },
        .{
            .appearance = .{
                .icon = "television-off",
                .up = .{ .foreground = "#969696", .background = "black" },
                .down = .{ .foreground = "red", .background = "black" },
            },
        },
    ],
}),

Actions

Actions fire when a key is released, and a key may carry several.

homeassistant_service domain, service and data, which is sent as service_data. Home Assistant accepts entity_id inside it.
pulseaudio application_name, matched against the stream's application.name, and action: .volume_up, .volume_down or .toggle_mute.
mpris command: .play_pause, .next, .previous or .stop.
open .uri, .file or .directory — opened through the desktop portal.
page Switch this deck to another page by name.

Media players

An mpris_icon follows a player's PlaybackStatus, with states spelled "playing", "paused", "stopped" and "gone" — the last being no player at all — and the same .state = null default a Home Assistant key takes.

player is the part of the bus name after org.mpris.MediaPlayer2., so "Plexamp" rather than the whole thing, and a player that suffixes an instance number still matches. Leaving player out follows whichever player last reported, which is what one set of transport keys usually wants.

.{
    .position = .{ .x = 1, .y = 2 },
    .image = .mpris_icon(.{
        .player = "Plexamp",
        .states = [
            .{ .state = "playing", .appearance = .{
                .icon = "pause",
                .up = .{ .foreground = "#00c8ff", .background = "black" },
                .down = .{ .foreground = "red", .background = "black" },
            } },
            .{ .state = "paused", .appearance = .{
                .icon = "play",
                .up = .{ .foreground = "#969696", .background = "black" },
                .down = .{ .foreground = "red", .background = "black" },
            } },
            .{ .appearance = .{
                .icon = "music-note-off",
                .up = .{ .foreground = "#505050", .background = "black" },
                .down = .{ .foreground = "red", .background = "black" },
            } },
        ],
    }),
    .actions = [.mpris(.{ .player = "Plexamp", .command = .play_pause })],
},

An mpris_art key is the album cover, filling the key edge to edge. It shows, in order:

  • the cover art of what is playing, if the track has any;
  • playing — a yellow pause by default — when something is playing that has no art;
  • stopped — a light grey play by default — when nothing is.

So the icon says what pressing will do, and the art says what is on. Both appearances can be overridden; leaving either out takes the default.

.{
    .position = .{ .x = 1, .y = 2 },
    .image = .mpris_art(.{ .player = "Plexamp" }),
    .actions = [.mpris(.{ .command = .play_pause })],
},

Art comes from mpris:artUrl, which is a file: URL for every player that caches its covers — Plexamp, VLC and Firefox all do — so no network is involved. data: URLs are decoded too. http: and https: are refused: they would mean carrying an HTTPS client for a picture, and the key falls back to its icon instead.

A configuration with nothing that needs the session bus never opens one, and a machine without one is not an error — it is a machine this works on, minus the media and open keys. The same goes for PipeWire and the volume keys. Being connected for the portal is not the same as being watched for a player: a configuration that only opens things gets the bus without the match rules and without a task following it.

Volume

Volume is spoken to PipeWire over its own socket through zig-pipewire — no libpipewire, no C, and no subprocess. application_name is matched against the stream's application.name, which is what wpctl and pavucontrol label streams with; wpctl status will show you the spelling.

Note that PipeWire carries linear amplitude while wpctl, pavucontrol and PulseAudio all show a cubic scale, on which 0.5 means an eighth of the amplitude. A step here is five percent of the scale a person sees.

Opening things

An open action hands something to org.freedesktop.portal.OpenURI, which is the session's own idea of which application handles a scheme or a MIME type. It honours the user's default applications rather than guessing, and it is what xdg-open has become.

.actions = [.open(.uri("https://ziglang.org/"))],
.actions = [.open(.file("/home/me/notes.md"))],
.actions = [.open(.directory("/home/me/dev"))],

A URI goes by name; a file and a directory go by file descriptor, which is the portal's own recommendation — handing over an open descriptor is what lets it give the receiving application access to exactly that file and nothing else. A file: URI is therefore recognised and opened as a file rather than passed through as a URI.

URIs are parsed with zig-uri before being sent, so a string that is not one is refused here, naming the string, rather than being handed to the portal to be refused less legibly.

zig build portalprobe checks that the portal accepts the messages this builds, choosing arguments it will refuse so that a passing run puts nothing on screen.

Encoders

The Stream Deck Plus, the Plus XL and the Studio have rotary encoders — four, six and two. They are addressed by index rather than by position, because that is what the hardware reports and there is no grid to place them on, and they carry the same actions a key does.

.pages = [
    .{
        .name = "home",
        .encoders = [
            .{
                .index = 0,
                .press = [
                    .pulseaudio(.{ .application_name = "PipeWire ALSA [plexamp]", .action = .toggle_mute }),
                ],
                .left = [
                    .pulseaudio(.{ .application_name = "PipeWire ALSA [plexamp]", .action = .volume_down }),
                ],
                .right = [
                    .pulseaudio(.{ .application_name = "PipeWire ALSA [plexamp]", .action = .volume_up }),
                ],
            },
        ],
    },
],

press fires on release, like a key. left and right fire once per detent: a report says how far the encoder moved since the last one, so a quick turn does the action several times, which is what a volume knob wants. That is capped at sixteen per report, so a hard spin does a lot rather than an unbounded amount.

A page may carry keys, encoders, both or neither. Configuring an encoder a deck does not have is not an error — a configuration is often written for a model you also own — but it is said once at startup rather than ignored silently.

Touchscreens

The Plus has an 800×100 strip that reports where it is touched; the Neo has a 248×58 one that only displays. A page divides it into equal columns, one per section, and each section shows its own thing and answers its own touches. Four across a Plus puts one above each dial, which is the arrangement the hardware is shaped for.

.touchscreen = .{
    .sections = [
        .{
            .show = .mpris(.{
                .player = "Plexamp",
                .playing = .{ .background = "#181828" },
                .idle = .{ .text = "idle", .foreground = "#606060" },
                .timeline = true,
            }),
            .press = [.mpris(.{ .command = .play_pause })],
        },
        .{
            .show = .text(.{ .text = "next", .foreground = "#00c8ff" }),
            .press = [.mpris(.{ .command = .next })],
        },
    ],
},

show is either a fixed line of text or mpris, and may be left out entirely by a section that exists only to be touched. press and long_press carry the same actions a key does — the device decides which a touch was.

Text is centred in its own section and sized as height × size (0.55 by default), then reduced until it fits that section's width. So a track title in a narrow column shrinks while next beside it stays large.

What mpris shows

Nothing playing gets the idle banner, one centred line. Something playing gets three, each a third of the section's height:

                     Crosby, Stills & Nash
            Suite: Judy Blue Eyes (2005 Remaster)
  1:14  ━━━━━━━━━━━━━━━━━━━━━━━━━────────────────────────  3:36

The artist is dimmed towards the background so that the title, which is the line you are actually reading, is the one that stands out. Either may be missing — a player that publishes no xesam:artist leaves that band blank rather than shifting the title into it, so the title stays where the eye expects it between tracks.

The third line is the timeline: elapsed on the left, total on the right, and a bar between them filled by the fraction played. Set .timeline = false to drop it and give the artist and title the whole section.

MPRIS deliberately does not signal Position — a property that changes continuously would be a property change every frame — so it is polled once a second, and only while something is playing. A poll that lands in the same second as the last one does not redraw, so a paused player costs nothing and a playing one costs one screen write per second.

The times are sized from the band's height rather than its width, because that is what the text is sized from; the bar gets everything left over. A section too narrow to hold both times and a bar between them shows the times alone.

A remainder is spread over the leftmost sections rather than dropped: three sections of an 800 pixel strip are 267, 267 and 266, not 266 with two pixels of nothing at the end.

The whole screen is composed and sent in one write even when one section changed. A Plus's header carries a rectangle and could take a partial write; a Neo's cannot — its strip is written whole — and one path that works on both is worth more than the packets saved on one.

Swiping

A section also takes swipe_left and swipe_right, which carry actions the way press does:

.{
    .show = .mpris(.{ .player = "Plexamp" }),
    .press = [.mpris(.{ .command = .play_pause })],
    .swipe_left = [.mpris(.{ .command = .next })],
    .swipe_right = [.mpris(.{ .command = .previous })],
},

A swipe belongs to the section it started in, not the one it ended in. A gesture across a divided screen has to be attributed to one of them, and the first is the one the person was looking at when they began — so a swipe from the far right to the far left fires the rightmost section's swipe_left.

Left and right only. The Plus strip is 800×100, so a vertical gesture has a twentieth of the room a horizontal one has and would mostly be a mis-read tap.

The device calls it a drag whenever the finger moved at all, so most drags are taps that slid a pixel or two. A drag shorter than a twentieth of the screen — forty pixels on a Plus — is treated as a tap where it began, rather than dropped: dropping one is a touch that does nothing, which reads as the screen having missed it.

The Home Assistant token

The token is not a configuration field. It lives in a file beside the configuration — $XDG_CONFIG_HOME/greendeck/ha-token — so that a configuration can be committed without carrying a credential.

$ install -m 600 /dev/null ~/.config/greendeck/ha-token
$ $EDITOR ~/.config/greendeck/ha-token

token_file names one somewhere else, with ~/ expanded, and $GREENDECK_HA_TOKEN is the last resort — which is what a systemd unit's LoadCredential= or EnvironmentFile= would use.

Icons and the font

The icon set is a directory of SVGs, and the font is a TrueType file. Both are found in the same order: the configuration's icon_dir and font_file, then $GREENDECK_ICON_DIR and $GREENDECK_FONT, then the paths baked in at build time with -Dicon-dir= and -Dfont=. package.nix sets those last two, so an installed binary needs no environment at all, and a development build can point somewhere else without a rebuild.

nixpkgs' own material-design-icons holds only the TrueType webfont and no SVGs, so nix/mdi-svg.nix fetches the @mdi/svg package instead — 7,447 files and nothing else.

How it is put together

A set of long-lived tasks around a std.Io.Queue. One task per deck blocks on a HID input report and pushes what changed; one talks to Home Assistant; and one dispatcher pops from the queue, owns every piece of mutable state, and is the only thing that draws. Because one task owns all the state there is no lock anywhere in this program.

Home Assistant is reached through zig-homeassistant over one WebSocket, for everything — authentication, the state_changed subscription, the initial get_states dump and service calls. There is therefore no HTTP client here at all.

module what it holds
src/svg.zig, src/svg/ the icon reader, the path data grammar, and SVG's elliptical arc
src/render.zig composing a key image, and the cache of them
src/text.zig labels, and where z2d actually puts the ink
src/Config.zig the Ziggy schema and its validation
src/paths.zig where the config and the token live
src/Deck.zig one deck: its page, its key states, its geometry
src/App.zig the dispatcher
src/audio.zig per-application volume, spoken to PipeWire directly
src/mpris.zig media players on the session bus
src/portal.zig opening URLs, files and directories
src/art.zig cover art: fetching, decoding and scaling to a key

Drawing an icon

Every one of the 7,447 Material Design Icons has the same shape: one <svg> with a viewBox of 0 0 24 24, one <path> carrying nothing but a d, and no gradient, stroke, transform or group anywhere. So this is not an SVG implementation and does not pretend to be one — it reads two attributes with zxml and refuses a document shaped any other way.

What it does implement completely is the path data grammar, because the set uses all of it: every command appears, A fifteen thousand times, in both the absolute and the relative spelling. The path is filled onto a z2d surface and handed to zig-streamdeck, which encodes it and sends it over HID.

Stopping it

SIGINT, SIGTERM and SIGHUP stop it. They are blocked and delivered on a signalfd rather than handled, because a handler cannot do what needs doing here; a task reads the descriptor and tells the dispatcher to stop.

What that buys is where it stops: between events, with no image upload part way through. A key image is several 1 KB HID packets, and a deck interrupted mid-upload is left waiting for the rest — which is how a Stream Deck Plus ends up answering the next question with the previous answer, and needing a replug to clear.

The process then ends rather than unwinding. A deck's reader task is blocked in a read on /dev/hidraw* that will not return until somebody presses a key, and no Io can interrupt a thread in that syscall, so waiting for it would hang systemctl stop until a person touched the hardware. A deck keeps the last thing it was shown, which is what it does across a power cut too.

Development

$ nix develop
$ zig build test --summary all
$ zig build fuzz -- --runs 100000
$ zig build docs && zig build docs-serve      # then http://localhost:8000

The strongest check here is neither a unit test nor the fuzzer:

$ zig build svgsweep -- "$GREENDECK_ICON_DIR"
7447 icons, 0 failed, 0 empty

Every icon in the set must parse, produce a closed path, and rasterize. It runs in seconds and it is exactly the input the program sees.

greendeck-decks, or zig build decks from a checkout, says which decks are attached and in what order, which is what index in a configuration means — and why serial_number is the steadier way to name one. It reads each deck's strings and does not reset, draw or change the brightness of any of them, so it will not disturb a deck something else is driving.

zig build svgdump -- icons/volume-mute.svg out.png 256 "Mute" renders one key to a PNG, which is how the path parser and the label placement get checked by eye. zig build artdump -- <artUrl> out.png does the same for cover art, where the question is whether an album cover reduced four times still reads.

zig build screendump -- out.png 800 100 2 renders a touchscreen page the same way, against a made-up track, so that a layout question — whether the artist is dim enough, whether a quarter of a Plus still fits a timeline — can be answered without a deck plugged in.

zig fmt is run over build.zig src tests tools only, because it would otherwise descend into the gitignored zig-pkg/.

Licence

MIT, and REUSE compliant — nix develop -c reuse lint.

This is a rewrite of a Python program that was itself GPLv3 and derived from the MIT python-elgato-streamdeck. None of that code survives the port: zig-streamdeck replaces the whole HID and protocol layer, and it is MIT by way of the same upstream.

References cited