- Zig 86.7%
- Nix 13.3%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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]> |
||
| .forgejo/workflows | ||
| LICENSES | ||
| nix | ||
| src | ||
| tests | ||
| tools | ||
| .gitignore | ||
| .ziggy-schema | ||
| build.zig | ||
| build.zig.zon | ||
| build.zig.zon.nix | ||
| flake.lock | ||
| flake.nix | ||
| package.nix | ||
| README.md | ||
| REUSE.toml | ||
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
hidrawdriver (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.enablewrites 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-checkconfig — zig 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 yellowpauseby default — when something is playing that has no art;stopped— a light greyplayby 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
- Home Assistant. WebSocket API. Home Assistant Developer Documentation. https://developers.home-assistant.io/docs/api/websocket/
- Howard, Dean, et al. python-elgato-streamdeck: Python library to control an Elgato Stream Deck. https://github.com/abcminiuser/python-elgato-streamdeck
- Melnikov, A., and I. Fette. The WebSocket Protocol. RFC 6455, Internet Engineering Task Force, December 2011. https://www.rfc-editor.org/info/rfc6455
- Pictogrammers. Material Design Icons, version 7.4.47. https://pictogrammers.com/library/mdi/
- World Wide Web Consortium. CSS Color Module Level 3. W3C Recommendation, 18 January 2022. https://www.w3.org/TR/css3-color/
- World Wide Web Consortium. Scalable Vector Graphics (SVG) 1.1 (Second Edition). W3C Recommendation, 16 August 2011. https://www.w3.org/TR/SVG11/