Operational guide for AI coding agents working on LinuxStreamDeck. Read this
before making changes. It is the single source of truth for how the project is
built, how it is structured, and the non-obvious rules you must respect.
(CLAUDE.md builds on this file with Claude-specific notes.)
LinuxStreamDeck is a GTK4 / Libadwaita desktop app that turns an Elgato Stream Deck into a control surface on Linux, built around deep OBS Studio integration (obs-websocket v5). It also works as a virtual deck on screen, so it is fully usable and testable without the physical hardware connected.
- Language / UI: Python ≥ 3.10, PyGObject (GTK 4 + Libadwaita/
Adw). - Key dependencies:
streamdeck(python-elgato-streamdeck, HID),pillow(key image composition),obsws-python(obs-websocket v5 client), GStreamer 1.0playbin(local audio playback). - Target devices: every Stream Deck with key displays — Mini (3x2), Neo
(4x2), Original/MK.2 (5x3,
0fd9:006d), Stream Deck + (4x2 keys, 4 dials and an 800x100 LCD strip) and XL (8x4). The Pedal has no displays and is refused. Geometry comes fromkey_layout()at connection time, never from a constant:GRID_COLSinui/window.pyandGRID_COLUMNSin the renderers are only the pre-connection default. Only the 15-key Original is tested on real hardware; the rest are verified in simulation (tests/test_multi_deck.py). - License: GPL-3.0-or-later. Author: JavocSoft.
- Repo: github.com/javocsoft/linuxstreamdeck
All commands run from the project root. The virtual environment lives in .venv
and is created with --system-site-packages so it can import the system GTK /
PyGObject.
./build.sh # check agent adapters; create .venv, install package + deps, compile-check
./build.sh --apt # same, and install the system packages first (needs sudo)
sudo ./install-udev.sh # one-time USB permissions for the Stream Deck
./run.sh # launch the app
LSD_DEBUG=1 ./run.sh # launch with debug loggingSystem packages required: gir1.2-gtk-4.0 gir1.2-adw-1 gir1.2-secret-1 gir1.2-gstreamer-1.0 gstreamer1.0-plugins-base gstreamer1.0-plugins-good gnome-keyring libhidapi-libusb0 python3-gi python3-gi-cairo.
Compile check (use after any code change):
.venv/bin/python -m compileall -q linuxstreamdeckThe automated unittest suite lives under tests/. Run it with an isolated
configuration directory so no test can reach the user's real configuration:
TEST_CONFIG_DIR="$(mktemp -d)"
LSD_CONFIG_DIR="$TEST_CONFIG_DIR" .venv/bin/python -m unittest discover -s tests -vtests/test_invariants.py is not a feature suite: it makes the silent rules of
§5 fail loudly (BASIC font layout, RENDER_LOCK, the OBS request lock and the
shutdown order). Treat a failure there as a real regression, never as a test that
needs relaxing.
Also verify behaviour with targeted isolated scripts when appropriate (see §6) and, for rendering, by composing key PNGs offscreen. Do not rely on launching the GUI to "see" a change (see §5).
Debian package: ./packaging/build-deb.sh [X.Y.Z] produces
dist/linux-stream-deck-<version>.deb. It is Architecture: all: the pure-Python
app plus the two pip-only deps (StreamDeck, obsws_python) are vendored under
/usr/lib/linuxstreamdeck/_vendor, while GTK4/Adw (PyGObject), Secret Service,
GNOME Keyring, GStreamer plus its base/good plugins, Pillow, hidapi,
websocket-client and ca-certificates come from apt Depends. ydotool (key
injection), playerctl (media transport), pulseaudio-utils (per-application
volume and the default audio device) and avahi-utils (finding Key Lights) are
Recommends, so apt installs
them by default while the package still installs where they are unavailable and
the app degrades to a status message. fonts-noto-cjk is only a Suggests: it
supplies the katakana of the Matrix Code screen saver, which falls back to
Latin without it, and 91 MB of fonts is far too much to pull in by default for
one animation. AI provider HTTPS
calls and the local audio wrapper use the Python standard library / system GI
bindings, so there is no additional pip dependency. The launcher
/usr/bin/linuxstreamdeck runs the system python3 with those paths on
sys.path. It also installs a
desktop entry, the app icon, an AppStream metainfo (so installed software
catalogues can discover its icon, description and screenshot), and the udev rule
(reloaded by the postinst). The maintainer scripts also refresh the desktop
entry and hicolor icon caches after install, upgrade and removal. Version
defaults to pyproject.toml; the build syncs it into both pyproject.toml
and linuxstreamdeck/__init__.py::VERSION, so passing X.Y.Z also bumps those
sources.
Every package format keeps the shared desktop entry's hicolor theme name:
Icon=com.javocsoft.LinuxStreamDeck. The Debian build asserts that exact value
and installs com.javocsoft.LinuxStreamDeck.svg below
/usr/share/icons/hicolor/scalable/apps/; postinst then runs
update-desktop-database and gtk-update-icon-cache when available. This is
load-bearing on COSMIC: its launcher and dock take different icon-loading paths,
and an absolute SVG path can work in the launcher while the dock silently falls
back to a generic icon. Do not rewrite the staged Debian desktop entry to an
absolute icon path. packaging/refresh-appstream.sh refreshes separate
software-catalogue metadata; it is not a substitute for the desktop/icon cache
refresh in the maintainer scripts.
Everything else derives that one value: ui/about.py reads VERSION,
__main__.py answers --version / -V before GTK parses argv (an unknown
option would otherwise be rejected, and the bug report template tells people to
run it), and the AppStream metainfo ships @VERSION@ / @DATE@ placeholders
that the build substitutes. Keep that release entry templated: a hardcoded one
would go stale on the next build without the scan below ever noticing, because
the scan only recognises a version that is wrong, never one that is missing.
Before that sync, check_hardcoded_versions() scans every tracked file and fails
the build when one still names a different version of this application — the way
an issue template or a document silently goes stale after a release. Dependency
constraints (streamdeck>=0.9.5) and lines marked version-check: ignore are
exempt, and the two version sources are skipped because sync_version owns them.
It deliberately runs before any file is rewritten, so a failed build leaves
the working tree untouched. Keep documentation examples as literal X.Y.Z rather
than a plausible number, or they trip the scan. After installing or upgrading the
package, run sudo ./packaging/refresh-appstream.sh to refresh the system
AppStream cache for software centres, then reopen the software centre.
Release version checklist: a version bump is more than editing one line, and it must follow this order so generated content cannot retain the previous release:
- Change both authoritative source values:
pyproject.toml::versionandlinuxstreamdeck/__init__.py::VERSION. They must agree before importing the package; the landing generator readsVERSIONwhile AppImage and Flatpak readpyproject.toml. - Regenerate the landing with an isolated configuration:
LSD_CONFIG_DIR="$(mktemp -d)" .venv/bin/python landing/generate.py. This stamps the version intolanding/assets/actions.json,landing/index.htmlandlanding/assets/img/og-card.png; never edit those generated values by hand. - Run the compile check and the full isolated test suite from §2. Then run
node landing/check.mjs,python3 agent-definitions/sync.py --checkandgit diff --check. - Build
./packaging/build-deb.sh X.Y.Z,./packaging/build-appimage.shand./packaging/build-flatpak.sh --bundle. The Flatpak script installs its result for the current user as part of its workflow; remove that temporary installation afterwards when the workstation is meant to keep only the Debian package. - Verify the version inside all three final artefacts, not only their filenames. Also check that the release's new modules and package data are present, and calculate checksums only after the last rebuild.
The bug-report template is deliberately not a version source. Its
placeholder: "X.Y.Z" asks reporters for the version they actually have
installed, which may be older than the latest release; replacing it with the
current version would be misleading and create release-by-release maintenance.
tests/test_version.py pins that generic placeholder. The AppStream source is
also not bumped by hand: its @VERSION@ and @DATE@ placeholders are filled by
each package build.
Flatpak: ./packaging/build-flatpak.sh [--bundle] [--clean], from
packaging/flatpak/com.javocsoft.LinuxStreamDeck.yml. It sits on
org.gnome.Platform, which supplies GTK4, libadwaita, PyGObject, GStreamer and
libsecret, so the only things built are libusb, hidapi and three pip
packages. runtime-version must stay on a runtime GNOME still supports — 48
reached end of life in March 2026, and flatpak install says so on every run
rather than failing, which is exactly how a manifest quietly stops getting
security updates.
Two things about it are load-bearing:
- hidapi must be built with the libusb backend, and therefore libusb must be
built first.
StreamDeck/Transport/LibUSBHIDAPI.pysearches Linux for exactly["libhidapi-libusb.so", "libhidapi-libusb.so.0"]and never falls back to the hidraw one. A hidraw-only build is smaller, configures cleanly and produces a Flatpak that starts perfectly and never finds a deck. The GNOME SDK has no libusb, so leaving it out fails the build outright — which is the good failure; the silent one is worse. --talk-name=org.freedesktop.Flatpakis a deliberate hole. Seecore/host.py: system integrations run host programs, and an application that can spawn host processes is not meaningfully confined. Removing that line leaves a confined Flatpak in which those keys report what is missing, exactly as on a machine without the tools, and everything network-based is unaffected.
AppImage: ./packaging/build-appimage.sh, built in a container from
packaging/appimage/Dockerfile. The base is Ubuntu 24.04 and that is not a
preference: the UI uses Adw.Dialog and Adw.ToolbarView, which need
libadwaita 1.5, and 24.04 is the oldest Ubuntu carrying it. An AppImage cannot
run on an older glibc than it was built against, so the base sets the floor at
glibc 2.39 — Fedora 40+, Ubuntu 24.04+, Arch and Tumbleweed work; Debian 12,
Ubuntu 22.04 and RHEL 9 do not and want the Flatpak. Do not "fix" that by
moving to an older base without first checking every Adw.* the UI uses.
Neither format can install the udev rule or the optional host tools, so both scripts print those two reminders when they finish. Never drop them: without the rule the deck is simply never opened.
Composition root is app.py::LinuxStreamDeckApp, which wires everything together
and wraps a single-instance Adw.Application. Components communicate through a
thread-safe pub/sub EventBus; UI never talks to the device or OBS directly.
LinuxStreamDeckApp also owns shutdown, in a fixed order: status icon,
all controller workers, the bounded Arcade feedback-sound player, automatic
Twitch-category monitor, HID fleet, OBS client.
Controller workers must stop before the HID fleet so no render is submitted
to a closed device. The category monitor must stop before OBS and Twitch, whose
state and requests it uses; every deck must stop before OBS so nothing is left
waiting on a request that can no longer be answered. The EventSub session stops
after the deck and before the Twitch client whose requests it makes, for the
same reason the deck stops before OBS.
_on_shutdown also sets _shutting_down before stopping the icon,
so hides_on_close() cannot intercept a quit already in progress. It reads like
tidy-uppable boilerplate, so it is pinned by ShutdownOrderTests in
tests/test_invariants.py. DeckFleet.stop() joins its one discovery monitor
and stops every endpoint; each DeckManager.stop() joins its screen-saver
thread, applies that workspace's clean-exit display and finally closes HID, so
no background work outlives application teardown.
linuxstreamdeck/
├── __main__.py Entry point; logging setup; `linuxstreamdeck` console script.
├── app.py LinuxStreamDeckApp: builds config, credential stores, AI service,
│ EventBus, OBS/Twitch clients, automatic category monitor,
│ fleet/device runtimes, MainWindow and status icon; lifecycle.
├── basic_actions.py System actions (clocks, audio, counter, mixer) plus page navigation.
├── web_actions.py One generic HTTP endpoint action, with an optional value on the key.
├── light_actions.py Elgato Key Light power, brightness and colour temperature.
├── steam_actions.py Installed/recent game launch and Steam navigation actions.
├── system_stats.py A live machine measurement on a key: CPU, RAM, GPU, temps, network.
├── ha_actions.py Home Assistant: switch or run an entity, and show what it reports.
├── ai/
│ ├── constants.py OpenAI/Claude provider ids, labels and default models.
│ └── service.py Provider calls, bounded optional context, local proposal validation.
├── games/
│ ├── catalog.py Stable IDs, names and user hints for all built-in games.
│ ├── common.py Shared engine events, phases and adaptive lobby/layout model.
│ ├── mole_smash.py Pure clock/RNG-injected reaction-game engine.
│ ├── circuit_breaker.py Pure guaranteed-solvable Lights Out-style engine.
│ ├── pulse_memory.py Pure increasing sequence-recall engine.
│ ├── memory_match.py Pure pair-matching engine with odd-grid status slot.
│ ├── minesweeper.py Pure first-reveal-safe adaptive minefield engine.
│ ├── tic_tac_toe.py Pure compact/3x3 player-versus-AI engine.
│ ├── mastermind.py Pure adaptive colour-code and clue engine.
│ ├── neon_relay.py Pure endless edge-to-edge circuit-routing engine.
│ ├── neon_jackpot.py Pure three-reel fictional-credit slot engine.
│ ├── economy.py Thread-safe persistent Deck Sparks wallet, unlocks and rescue.
│ ├── challenges.py One-time configuration/action/game Spark Challenges.
│ ├── manager.py Shared exclusive input/display session, worker, persistence and restore.
│ ├── render.py Snapshot dispatch plus Mole Smash key frames and Plus HUD.
│ ├── arcade_render.py Shared lock, insufficient-funds and paid START controls.
│ ├── arcade_audio.py Bounded player for refill and two-stage unlock cues.
│ ├── *_render.py Key frames and Plus HUDs for the other eight games.
│ ├── rendering.py Shared BASIC-font lobby and HUD rendering helpers.
│ └── audio.py Bounded, cancellable workers for the bundled WAV effects.
├── core/
│ ├── events.py EventBus + per-device ScopedEventBus; a `dispatcher`
│ │ (GLib.idle_add) marshals callbacks to the UI thread.
│ ├── config.py Data model + atomic user-only JSON persistence: Config →
│ │ DeckWorkspace → Profile → Page → KeyConfig → Folder,
│ │ plus global service/game settings. Legacy migration, backup,
│ │ `.lsdconfig` and single-key `.lsdkey` I/O.
│ ├── actions.py Action framework: `Action` base, declarative `Param`,
│ │ `ActionContext`, global `REGISTRY`, `@register`, `by_category`.
│ ├── audio.py Blocking local playback via GStreamer playbin; shutdown-aware.
│ ├── autostart.py XDG autostart entry: read/write/remove, hidden-start flag.
│ ├── apps.py Desktop applications: list, resolve, launch, find and close.
│ ├── steam.py Local libraries/manifests/artwork, running AppID detection and launch.
│ ├── media.py MPRIS transport (playerctl) and session volume (wpctl/pactl).
│ ├── mixer.py Per-application volume, mic mute and the default device (pactl).
│ ├── soundboard.py A null sink plus its monitor, so a sound key reaches the stream.
│ ├── nowplaying.py What is playing over MPRIS, and its album art, off the render worker.
│ ├── keystrokes.py Shortcut parsing, Linux presets, ydotool/wtype/xdotool.
│ ├── clocks.py Thread-safe countdown/stopwatch runtime keyed by RuntimeKey.
│ ├── controller.py DeckController: presses, action/render workers, running feedback,
│ │ clocks/audio notifications, folder navigation, profile/key
│ │ operations and imports.
│ ├── search.py Key search across devices, profiles, pages and folders.
│ ├── references.py Keys pointing at OBS objects that no longer exist.
│ ├── sysstats.py Whole-machine readings from /proc and /sys: CPU, memory,
│ │ network throughput, temperatures, GPU, free disk space.
│ ├── webrequest.py HTTP calls, dotted-path value extraction, off-render cache.
│ ├── keylight.py Elgato Key Lights: mDNS discovery, mired conversion, state cache.
│ ├── homeassistant.py REST client: entity list, service calls, off-render state cache.
│ ├── starter.py The keys a brand new configuration is offered.
│ ├── preflight.py Checks worth running in the minute before going live.
│ ├── icons.py Built-in icon library (Material Design Icons glyphs via
│ │ Pillow, recolorable, cached). `RENDER_LOCK`.
│ └── secrets.py Async Secret Service storage for OBS and per-provider API keys.
├── device/
│ ├── fleet.py One HID enumerator, serial binding and reconnect coordinator.
│ ├── manager.py One device endpoint: startup/saver, exit display and HID I/O.
│ ├── exit_display.py Validate/crop static full-deck images for clean shutdown.
│ ├── screensaver.py Pure-Pillow coordinated full-deck animation renderer.
│ ├── startup_animation.py 33-frame offscreen physical-deck wake/title sequence.
│ ├── touchscreen.py Stream Deck + LCD strip: one labelled panel per encoder.
│ ├── layout_sheet.py Whole profile as one printable, captioned PNG.
│ └── renderer.py `compose()` — builds each configured key PNG with Pillow
│ (bg, icon or centered value, sized label, badge, lighting,
│ running halo).
├── obs/
│ ├── client.py OBSClient: obs-websocket v5 connection, auto-reconnect,
│ │ thread-safe requests, re-emits OBS events onto the bus.
│ └── actions.py Catalogue of OBS actions (scenes, recording/streaming,
│ audio, sources/filters, media, advanced + raw request).
├── twitch/
│ ├── constants.py Client ID, endpoints, the scopes this application asks for.
│ ├── eventsub.py One live WebSocket: chat, follows, subs, gifts, raids.
│ ├── events.py Six payload shapes reduced to one Alert.
│ ├── attention.py What is still waiting, per key, and when to make a noise.
│ ├── http.py The one place this integration touches the network.
│ ├── auth.py Device code flow, token refresh, validate and revoke.
│ ├── client.py TwitchClient: Helix requests plus one cached channel
│ │ snapshot that `feedback()` reads without ever blocking.
│ ├── auto_category.py Stable Steam detection, exact mappings, debounce and override policy.
│ └── actions.py Viewers/followers/uptime on a key, title/category controls,
│ automatic-category toggle, clip and stream marker.
├── ui/
│ ├── window.py MainWindow: key grid (virtual deck), header (profiles,
│ │ import/export, pages, deck display, brightness, OBS, About),
│ │ folder breadcrumb and navigation, explicit saver activity,
│ │ DnD, copy/paste, single-key export/import, unsaved guard,
│ │ arrow-key grid navigation, OBS connection dot, status bar.
│ ├── editor.py EditorPanel: key editor, canonical draft/baseline and key type.
│ ├── dials.py Stream Deck + encoder row under the grid, and its editor.
│ ├── ai_assistant.py OpenAI/Claude proposal dialog and explicit editor handoff.
│ ├── steps.py StepEditor / StepList / AppearanceBox — reused by the editor
│ │ for single, multi and toggle key types.
│ ├── icon_picker.py Searchable grid to pick a library icon.
│ ├── action_picker.py Searchable list of every action, by name or by what it does.
│ ├── key_search.py Find a key in any profile, page or folder, and go to it.
│ ├── tray.py StatusNotifierItem + dbusmenu status icon (no GTK widgets).
│ ├── preferences.py Close behaviour and start-on-login settings dialog.
│ ├── about.py About dialog: application identity, credits, license and source link.
│ ├── backups.py Restore one of the automatic configuration backups.
│ ├── reference_check.py Report and repoint keys OBS no longer resolves.
│ ├── preflight.py The pre-flight report in full sentences.
│ ├── obs_settings.py OBS connection dialog (host/port/password).
│ ├── twitch_settings.py Twitch account, automatic category and mapping dialogs.
│ ├── ha_settings.py Home Assistant dialog: address, token, and a check that both work.
│ ├── screensaver_settings.py Screen saver and clean-exit display settings.
│ ├── arcade_settings.py Wallet, refill options, unlocks and challenge progress.
│ └── profile_dialog.py New/edit profile dialog (name + description).
├── assets/icons/ MDI font (TTF) + icons.json index (bundled, Apache-2.0).
├── assets/arcade/ Original global Arcade refill/unlock WAVs and license.
└── assets/games/ One self-contained asset directory per built-in game ID.
| Topic | Payload | Meaning |
|---|---|---|
deck.key |
deck_id, index:int, pressed:bool |
Physical key pressed/released. |
deck.dial |
deck_id, index:int, direction:str, ticks:int |
Stream Deck + encoder turned (left/right) or pushed (press). |
deck.touch |
deck_id, index:int, event:str |
LCD strip tapped, resolved to the dial under it. |
deck.connected |
deck_id, model:str, keys:int, columns:int, dials:int |
Physical device ready after startup. |
deck.disconnected |
deck_id |
Device removed. |
deck.screensaver |
deck_id, active:bool, preview:bool, style:str |
Screen saver started/stopped; controller restores keys on stop. |
deck.list_changed |
deck_id |
A workspace's name, binding or connection metadata changed. |
deck.selection_changed |
deck_id, name |
The UI/tray target changed; physical runtimes are unaffected. |
obs.connected / obs.disconnected |
— | OBS websocket state. |
obs.outputs |
recording:bool, streaming:bool |
Immutable output-state snapshot for screen-saver suppression and automatic Twitch categories. |
game.state |
deck_id, active:bool, game:str, name:str, phase:str, hint:str |
A built-in game took or released exclusive control; inactive payload strings are empty. |
game.settings |
deck_id when emitted by a game |
The active game's difficulty, sound choice or record changed and must be persisted. |
game.request |
deck_id, game:str |
Start another game on the same deck only after the current exclusive session released it. |
game.completed |
deck_id, game, difficulty, won, record |
One paid run ended; drives one-time game challenges. |
arcade.state |
balance, jackpot, jackpot_progress, unlocked, completed, next_refill_at, … |
Wallet/progress snapshot for UI, tray and active Jackpot. |
arcade.refill |
amount, balance, sound, visual |
The eligible zero-balance 24-hour rescue was granted. |
arcade.challenge |
id, title, reward, balance |
One success-only or configuration challenge was awarded. |
configuration.changed |
deck_id |
Durable configuration may satisfy a retroactive Spark Challenge. |
action.succeeded |
deck_id, action:str |
An action returned without raising; never emitted for a failed action. |
twitch.state |
linked:bool, login:str |
A Twitch account was linked, dropped or confirmed. |
twitch.auto_category |
enabled, paused, streaming, status, detected_*, pending_*, category_name |
The automatic category policy changed; repaint its key and refresh settings. |
notification |
id:str, title:str, body:str |
Show or replace one quiet desktop notification for background work. |
twitch.live |
connected:bool |
The EventSub session came up or went away. |
obs.state |
what:str |
Any OBS state change (drives key feedback). |
page.changed |
deck_id, index:int, name:str |
Active page changed. |
folder.changed |
deck_id, path:tuple[int, ...], trail:list |
A folder was opened, left or reset to the page root. |
profile.changed |
deck_id, name, description, … |
Active profile changed. |
ui.key_image |
deck_id, index:int, png:bytes |
A key was rendered; UI paints it when that deck is selected. |
ui.game_hud |
deck_id, png:bytes |
Stream Deck + LCD game HUD for the selected virtual deck; empty bytes hide it. |
ui.screensaver_frame |
deck_id, images:tuple[bytes | None, ...], size:(width, height) |
Changed raw RGB key frames for the selected visible virtual deck; None preserves an unchanged key. |
preflight.report |
deck_id, checks:tuple |
A pre-flight run finished; the selected deck's window shows the detail. |
status |
text:str |
Transient status-bar message. |
The * topic receives every event. EventBus.dispatcher is GLib.idle_add in
the app, so subscribers always run on the GTK main thread even when the emit came
from the deck read thread or the obs-websocket event thread.
DeckFleet is the only production component that enumerates Stream Decks. It
opens every visual device, reads (vendor id, product id, serial number) and
assigns it to one persistent DeckWorkspace; doing this centrally is
load-bearing, because a monitor per workspace would race to open the same HID
path. A portable or migrated workspace starts unbound and is assigned in its
persisted order. A genuinely new device creates a new workspace. The HID path is
only a last-resort identity when a driver cannot return a serial, because paths
can change across replug and resume.
Each workspace owns independent profiles, pages, keys, brightness, screen saver,
exit display and remembered geometry. It has one endpoint DeckManager, one
DeckController and one GameManager; OBS, Twitch, Home Assistant, AI, Steam
and the Arcade wallet remain global services. Config.view(deck_id) is fixed to
one workspace, while the compatibility properties on root Config follow
selected_deck_id for the UI. Never give a controller the root's dynamic view:
changing the editor selector must not make a press on deck A execute deck B's
page.
Every device-local event carries deck_id. ScopedEventBus filters physical
input and connection events for one runtime and tags everything that runtime
emits, while still allowing global OBS/Twitch/service state to reach every
controller. RuntimeKey is (profile, page, folder path, index, deck_id); the
last field prevents class-wide action state and Twitch acknowledgements from
colliding between identical slots on two devices without moving the historical
profile/page/path/index positions. The main window filters display/navigation
events to the selected workspace. Switching the selector changes only the
editor and virtual deck: every physical device keeps running.
The Arcade wallet is thread-safe and shared, but only one built-in game may run across the fleet at a time. This avoids two Jackpot engines settling absolute wallet/jackpot snapshots over each other. A second game request explains that the active game must stop first.
Configuration format 6 stores all workspaces. Portable exports include every workspace and its assets but clear vendor/product/serial bindings so another computer assigns its own hardware. Versions 1–5 migrate losslessly to the primary workspace. A full import or backup restore shuts down every old controller before closing HID, rebuilds the fleet, then reconnects and renders the replacement workspaces.
USB presence is not device health. After suspend/resume,
python-elgato-streamdeck can still enumerate the same HID path after its reader
has closed the stale handle. _alive() therefore requires both is_open() and
connected(). A TransportError from a key, touchscreen, brightness or screen-
saver write sets one bounded reconnect request and wakes the fleet immediately;
image-conversion and other programming errors are logged with their traceback
but must not masquerade as transport loss. The fleet detaches and closes only
the failed endpoint, emits scoped deck.disconnected, and can reopen it in the
same scan pass. Publishing the replacement handle and clearing the old request
are atomic under that endpoint's HID lock, so a fresh failure cannot be erased.
Other decks continue without interruption. Physical writes pause for the failed
workspace during recovery, but its virtual deck and manual screen-saver preview
remain usable.
Geometry comes from the device, never from a constant. _device_columns()
reads key_layout() and DeckManager.columns carries it to every full-deck
renderer: startup_frames(), screensaver_frame() and exit_image_tiles() all
split their canvas along it. Assuming five columns scrambled the screen saver,
the startup sequence and the custom exit image on every model that is not the
15-key original — a Mini has three columns, an XL eight. Per-key rendering was
always fine, because compose() takes the real key size, and every HID write
goes through PILHelper.to_native_key_format(), which applies each model's own
size, flip and rotation.
MainWindow builds its grid through _build_key_grid() rather than once at
startup: the window exists before anything is plugged in, so it opens on the
MK.2 defaults and _on_deck_connected reshapes it when a deck of another shape
arrives, dropping the selection first because every index the editor holds
refers to the old grid. GRID_COLS is only the pre-connection default.
Anything that spreads text one character per key has to shrink, not truncate.
startup_animation.title_layout() places the name across whatever grid exists,
picking the longest entry of TITLE_FORMS that fits — a six-key Mini gets
Linux, not the fragment LinuxS that cutting the name to length produced. The
block is centered vertically but each row starts at the left, because these are
a wrapped word rather than centered lines. The linuxstreamdeck screen saver
shares that helper: it is nothing but the title, so returning an empty layout
there would be a black screen that looks broken.
The Split-Flap Board has the same problem and its own answer:
_flap_words() falls back to SPLIT_FLAP_SHORT_WORDS when the fifteen-character
phrases do not fit, because a board that spells LINUXS and STANDB never
becomes readable.
A deck with no key displays is refused outright: _is_visual() gates it and
_reject_device() explains why, once per device. The Stream Deck Pedal has three
keys and no screens, and most of this application is about what gets drawn. When
the driver cannot answer, the deck is assumed visual — refusing one we merely
failed to ask about would be worse than trying to draw on it.
Every broad HID handler keeps its traceback. Both the fleet scan and the
legacy standalone manager catch Exception on purpose — hardware can fail in a
dozen legitimate ways — but log with exc_info=True, because without it a
TypeError from our own code reads exactly like a missing udev rule. Open
failures surface only after two attempts on one path: a deck just plugged in
often refuses the first while it is still enumerating, and repeating the warning
on every scan would bury useful status messages.
Everything above is verified in simulation for every model the library knows
(tests/test_multi_deck.py), but only the 15-key original has been run on real
hardware. The simulations cover simultaneous assignment, scoped input,
stable-serial reconnection, duplicate-scan suppression and independent config
round trips; physical confirmation on mixed hardware is still valuable.
The Plus adds four rotary encoders and an 800x100 LCD strip above them. Both are
gated on DeckManager.dial_count, which is 0 on every other model, so nothing
here is reachable on hardware that has none.
A dial reuses KeyConfig with KIND_DIAL and three lists — steps_left,
steps_right, steps_press, all three in STEP_FIELDS. That is the whole
reason it is not its own dataclass: every walk over a key's actions and assets
(portable bundles, nav.page migration, rename_page, the layout sheet) then
reaches a dial without being taught about it. What genuinely differs is storage.
Dials are numbered independently of keys, so they live in Page.dials, and
_key_configs() plus the bundle exporter iterate that mapping alongside
page.keys. Page.from_dict drops any index at or beyond MAX_DIALS, so a
hand-edited file cannot invent a fifth encoder.
Transient state uses DIAL_PATH, the folder path (-1,). A real path holds
key indices, which are never negative, so dial 0 and key 0 can never collide in
RuntimeKey — and no change to that type was needed.
_dial_event() is the one place that knows the library reports a push as a
boolean and a turn as a signed tick count. It drops the release edge (a dial
acts on the way down, like a key) and keeps the tick count, because a fast spin
arrives as several ticks in one event: flattening it would move a volume by one
step no matter how far the hand turned. turn_dial() therefore runs the list
once per tick, bounded by MAX_DIAL_TICKS so one flick cannot occupy an action
worker long after the hand stopped.
device/touchscreen.py draws the strip as one panel per encoder. It is the only
thing that can say what a dial does, so panels are labelled rather than
free-form. segment_bounds() derives each panel from the running edge instead
of a rounded per-segment width: four panels of 800 // 4 happen to tile, four
of 801 // 4 leave a one-pixel seam that is plainly visible on black. A tap is
resolved by touch_segment() into the dial beneath it and runs that dial's push
list, which is why the strip needs no configuration of its own; a coordinate
outside the strip is discarded rather than clamped.
ui/dials.py keeps dials out of EditorPanel deliberately. That panel's
unsaved-change guard compares a canonical draft against a baseline for one slot
of the key grid, and a dial is not a slot of that grid; its own dialog leaves
that guard untouched instead of teaching it a second kind of index.
None of this has run on physical hardware — see the note in §1.
device/startup_animation.py::startup_frames() creates a 33-frame offscreen
sequence for whatever grid the connected deck has: wake/energy wave, burst,
progressive title, hold, fade and a final black frame. title_layout() assigns
the name one character per key in row-major order — on the 15-key 5×3 deck that
is Linux / Strea / mDeck — shortening it through TITLE_FORMS rather than
cutting it when the grid is smaller. Frame brightness changes smoothly but
_scaled_brightness() never exceeds the user's configured target.
DeckManager plays the sequence directly on a newly opened device from the
monitor thread. Until it completes, the manager must not assign self.deck,
register the key callback or emit deck.connected; this keeps normal controller
renders and physical presses from interleaving with startup writes. The final
deck.connected event triggers the controller's existing configured-key refresh
after the black frame. The virtual deck does not display this animation.
Every wait and per-key write checks the monitor stop event. Shutdown therefore
cancels startup promptly and closes the provisional device without publishing a
connection. Rendering or HID I/O failure skips the remainder safely so connection
can continue when the device remains available, and DeckManager restores the
current configured brightness in finally on every exit path.
ScreenSaverSettings persists four top-level configuration values:
| Field | Range/default | Meaning |
|---|---|---|
enabled |
False |
Start automatically after inactivity. |
style |
neon_pipes |
One of the eleven installed animation IDs. |
idle_minutes |
1-1440, default 5 | Delay since the last tracked physical or virtual-deck activity. |
intensity |
5-100, default 35 | Screen-saver brightness, independent of normal deck brightness. |
The available ID/display-name pairs are neon_pipes / Neon Pipes,
digital_rain / Digital Rain, aurora_flow / Aurora Flow,
orbital_core / Orbital Core, circuit_pulse / Circuit Pulse,
ember_field / Ember Field, hyperspace / Hyperspace,
matrix_code / Matrix Code, hal_9000 / HAL 9000,
split_flap / Split-Flap Board and
linuxstreamdeck / LinuxStreamDeck. An unknown
persisted ID falls back to Neon Pipes. Adding a style means four edits that must
stay together: the tuple in SCREENSAVER_CHOICES, the renderer in the
renderers table, its entry in brightness_factors and its intentional cadence
in FRAME_DELAYS — a style present in the first but missing from any of those
tables raises a KeyError on the frame that selects it. The settings dialog
builds its list from SCREENSAVER_CHOICES; the other tables are render-time
contracts and the tests require FRAME_DELAYS to cover exactly the installed
IDs.
Ember Field is procedural on purpose. The obvious way to do fire is the
Doom algorithm, where each frame is derived from the previous one, and that
cannot work here: screensaver_frame() is a pure function of elapsed, so
nothing may accumulate between frames. It instead scrolls three _noise_strip()
layers at three speeds under a _ember_falloff() gradient and maps the result
through _ember_palette() with point(), which keeps it all at C level.
Image.effect_noise() is equally unusable — it draws from Pillow's own
generator and answers differently on every call. _noise_strip() therefore
seeds its own random.Random, and repeats the grid's first row and column in
its last so the upscaled tile meets itself without a seam and any vertical
offset can be cropped out of a double-height strip.
Its embers are drawn as an L mask that is blurred and colored afterwards,
never as blurred RGBA. Blurring RGBA mixes the black of its transparent pixels
into the colour, which turned them into olive rings instead of warm points of
light. The larger glow layers elsewhere get away with it because they are blurred
copies of content in the same hue; small isolated sprites do not.
Hyperspace is a wormhole rather than a starfield, and it is built from four
things that each carry part of that: _hyperspace_tunnel() draws rings rushing
out of the throat, warped by two out-of-step sine waves and turned further the
deeper they are (HYPERSPACE_TWIST) so the throat twists; _hyperspace_streaks()
samples each star along its path instead of drawing a straight line, with the
angle turning as it flies (HYPERSPACE_SWIRL) so the streak curves the way the
tunnel does; _hyperspace_aberration() splits the streak layer into colour; and
a final bloom pass swells with the surge so the jump flares.
Three things there are load-bearing:
- Star phases and speeds come from
_scatter(), a multiplicative hash, never from a linear function of the index. The angles are a linear sequence already, so a linear phase correlates with them and every star lands on one curve — the field collapsed into a spiral atelapsed0, which is exactly when the saver starts every time it wakes. - Depth is
_hyperspace_depth(), a power law. A star crawls near the middle and tears past at the rim, which is what stretches it into a streak without faking motion blur, and it is what gives the rings their perspective. - The whole style composites additively on black. That is both how light behaves and why these layers can be blurred safely: black is the neutral value, so no transparent-pixel colour can bleed in the way it did into Ember Field's sparks.
Chromatic aberration is applied to the finished streak layer with two resizes
(red scaled out, blue scaled in) rather than by drawing every streak three
times. Keep HYPERSPACE_ABERRATION small — the split grows with distance from
the centre, and at 0.013 the streaks near the rim separated into visibly
distinct red and cyan bars instead of fringing.
Split-Flap Board is the one style whose grid is the point: one module per
key, so the physical gaps between keys read as the gaps between modules.
_flap_word() picks and centres the word, _flap_state() returns
(outgoing, incoming, flip) for one module, and _flap_module() draws it. Both
helpers are pure so the timing can be asserted without rendering.
Its one real trap is what a module shows at rest: both halves have to be the
same character. The old one only lingers below the seam while the leaf
carrying the new one is still falling — keeping it there when settled spells
every word with mismatched halves, and the board never becomes readable. flip
counts down to exactly 1.0 at rest, so flip >= 1.0 is the test for it.
Matrix Code is the only style that draws real characters, which makes it the
only one with a font problem. It wants half-width katakana (MATRIX_KATAKANA,
U+FF66-U+FF9D), and no font carrying them is a dependency: fonts-noto-cjk is a
Suggests of the .deb, not a Recommends, because apt would then pull 91 MB
of fonts for one screen saver. _matrix_alphabet() therefore falls back to
MATRIX_FALLBACK (Latin plus digits) and the style always renders.
Detecting a usable font is the subtle part. getbbox() cannot answer it: a
Latin-only font returns a perfectly good box for any codepoint, because it
measures the empty rectangle it substitutes. _draws_katakana() instead draws
the glyph next to an unassigned private-use codepoint and compares the two
renderings; a font that drew the same thing twice has neither, and accepting it
would fill the rain with tofu boxes. Keep that check — the obvious getbbox()
version accepts DejaVu.
Two more things there are deliberate. Glyphs are pre-rendered once into cached
grayscale masks (_matrix_glyph) that a frame only colorizes, because drawing
text per cell per frame is far too slow for the hundreds of cells a frame paints.
And the film's mirrored glyphs come from flipping the finished rain layer
rather than each glyph: that also reverses the column order, which is invisible
because every column is seeded from its own index anyway.
HAL 9000 is the one style that is a single still object rather than a moving
field: one red camera eye centered on black, whose iris breathes on a slow wave
(HAL_BREATH_CYCLES, about nine seconds) and whose center point breathes on a
faster one of its own (HAL_DOT_CYCLES), so the two never lock together. Its
brightness_factors entry rides the same iris wave deliberately, so the device
breathes with the eye instead of against it. The lens does not move, scan or look
around — what makes it recognisable is that it only ever watches.
Its four layers go on in a fixed order, and the order is the whole trick:
- bounce — a wide, faint wash reaching the rest of the deck, so the keys
around the eye are lit by it rather than cut out of black. It scales with the
same
iris_level, because a spill that does not breathe with what casts it reads as a separate light. - lens — housing rim, iris and pupil, opaque.
- glare — composited over the finished lens, not behind it. Behind it, any spill bright enough to be seen turned the opaque housing into a black cut-out ring, because the glow peaked exactly where the rim is; over it, the bloom washes across the rim as a camera sees it and the lens blends into the light it is casting.
- point — the center dot and its own glow, brightest and last.
So brightness on this style is not free: raising the bounce lifts the whole deck,
and the tests pin the falloff (eye > neighbour > far key, far key lit but well
under the eye) rather than any absolute value. The settings dialog can start any selection immediately as a preview,
even while automatic activation is disabled and without physical hardware.
Closing or saving stops the preview; only Save persists the four values. The
dialog must unsubscribe its temporary deck.screensaver callback on close.
device/screensaver.py::screensaver_frame() builds one coordinated canvas for
the complete deck, splits it into per-key RGB images and supplies a frame delay
and brightness. Rendering is pure Pillow under the shared RENDER_LOCK; its
title font must use ImageFont.Layout.BASIC. Every animated brightness is at
least 1 and no greater than the configured screen-saver intensity. That intensity
does not modify Config.brightness. FRAME_DELAYS deliberately gives the slow
ambient effects a 0.18-0.20 second cadence while Hyperspace keeps 0.16 seconds;
do not collapse them back to one global refresh rate without measuring both CPU
cost and motion on the physical displays.
DeckManager owns a dedicated deck-screensaver thread and tracks monotonic
idle time. Physical key handling and deliberate virtual-deck entry points call
record_activity(); these include selecting or testing a key and opening the
Stream Deck display controls. Do not infer activity from every window event or
attach a broad Gtk.EventControllerLegacy hook. While the saver is active,
normal controller/HID key renders are suppressed. The physical deck receives
only keys whose raw RGB pixels changed since the previous successful write, so
a static key is neither converted to native format nor sent over USB again. The
one selected virtual deck receives only changed raw RGB keys through
ui.screensaver_frame; MainWindow wraps them directly in
Gdk.MemoryTexture, with no PNG encode/decode round trip. LinuxStreamDeckApp
routes that virtual work only while the window is visible, mapped and not
minimized, and retargets it when the selected workspace changes. The physical
animation remains active when the window is hidden. An offline workspace that is
not the selected visible virtual deck has no output at all, so its saver thread
waits instead of rendering frames that cannot be displayed. The appropriate
physical or virtual pixel cache is cleared when visibility, connection or saver
lifecycle requires a complete first frame. A physical wake press and its
matching release are consumed; a later press executes normally. Waking clears
preview state, restores normal brightness, emits inactive deck.screensaver,
and the controller refreshes configured key images.
LinuxStreamDeckApp subscribes to the dedicated obs.outputs topic and passes
the event payload's recording or streaming value to
DeckManager.set_screensaver_suppressed(). It must use that immutable snapshot,
not reread OBSState, whose event worker can change it concurrently. Suppression
applies only to automatic idle activation: an explicit dialog preview remains
available and takes precedence. Entering suppression wakes an already active
automatic saver, and both edges reset _last_activity; after recording and
streaming are both off, a complete new configured idle interval must elapse
before automatic activation.
Suppression is a set of named reasons, not one boolean. OBS uses the legacy
external reason and a built-in game uses game; removing either must leave the
other in force. Game ownership is stronger than OBS suppression and is checked
before _screensaver_preview: a settings dialog left open cannot paint a manual
preview over a live game. Both edges of the effective union still restart idle
tracking, rather than every redundant addition/removal doing so.
OBSClient._prime_state() publishes the first obs.outputs snapshot immediately
after GetRecordStatus and GetStreamStatus, before unrelated initial requests
such as per-input mute queries can delay the policy. Live record and stream
events update the cache and publish a new snapshot before the general
obs.state repaint event.
Those initial requests and live callbacks arrive on different sockets, so
their order is not guaranteed. _output_lock protects the output cache and two
independent monotonic generations. Each prime request captures its output's
generation before sending and applies the response only if no newer live event
advanced that generation; otherwise an old outputActive=False response could
overwrite a recording or stream that started while the request was in flight.
Record and stream generations must stay separate so activity on one output does
not discard a valid initial answer for the other. _emit_output_state() copies
both booleans under the same lock before publishing the immutable snapshot.
On disconnect, _disconnect_clients() stops and joins the OBS event client
before _teardown() advances both generations, resets the cache and emits the
authoritative recording=False, streaming=False snapshot. The generation advance
also invalidates prime requests already in flight, while the join prevents an
in-flight callback from restoring stale active state after suppression has been
lifted.
Screen-saver settings are part of normal JSON serialization and .lsdconfig
import/export. Import applies them immediately through
DeckManager.configure_screensaver() and restarts idle tracking. Shutdown sets
the shared deck stop/wakeup signals, joins the screen-saver thread before the
monitor thread, and proceeds to the configured clean-exit display only after both
have exited.
ExitDisplaySettings persists mode and image_path at the top level of the
configuration. The Stream Deck display dialog saves one of three modes:
| ID / display name | Clean-exit behavior |
|---|---|
device_default / Device default |
Call the firmware reset so the device supplies its standby image. |
blank / Off |
Write black to every key, then set brightness to 0. |
custom / Custom |
Center-crop one image across the full key grid and leave it at normal configured brightness. |
Custom images may be BMP, JPEG, PNG or WebP and are limited to 50 MiB.
device/exit_display.py::exit_image_tiles() validates the local file, uses
ImageOps.fit() under RENDER_LOCK to fill the complete 5-column grid, then
returns one RGB tile per physical key. blank_exit_tiles() produces the black
tiles used by Off.
DeckManager.stop() stops and joins both device workers before
_close(..., apply_exit_display=True) writes the selected state and closes HID.
Shutdown during the provisional startup animation also applies the selected
state before closing that device. A missing, invalid or unwritable custom image
falls back to the firmware default. This behavior is guaranteed only for a clean
application shutdown; forced termination, a system crash or power loss may
prevent the final HID writes.
The setting is part of normal JSON persistence. Custom images are bundled in
.lsdconfig exports from format v3 onward and restored below
CONFIG_DIR/imported-exit-images; importing applies both its mode and restored
path immediately through DeckManager.configure_exit_display().
Actions are declarative and self-registering:
- Subclass
Action, setid,name,category,params, optionaldefault_icon("mdi:name"), and decorate with@register. - Each
Paramhas akind(string | int | float | choice | duration | optional_duration | file | color). Duration fields acceptMM:SS/H:MM:SS;optional_durationmay stay blank. Numeric parameters may declareminimum/maximum/step; file parameters may declareextensionsandfile_filter_namefor the native chooser, ordirectory=Trueto pick a folder instead (no extensions apply then). Acolorparameter is the picker plus clear button described in A colour parameter that can be left inherited below. Any parameter may setplaceholder, which is worth doing when blank is a meaningful value rather than an unfinished one, so the empty field can say what it means —obs.stats'sdisk_folderis the case. A parameter may also setchoices_sourceso the editor fills the dropdown live (scenes,inputs,media_inputs,transitions,scene_collections,profiles,sources_in_scene,audio_sources_in_scene,filters_of_source,text_inputs,browser_inputs,hotkeys,pages,deck_profiles,applications,audio_apps,audio_devices,key_lights,network_interfaces,ha_entities,steam_games,steam_recent_games). The last ten are theLOCAL_CHOICE_SOURCESand must stay above theobs.connectedguard in_fetch_choices, so they still fill when OBS is not running. Note thatprofilesmeans OBS profiles; LinuxStreamDeck's own aredeck_profiles. Not every local source is final when it comes back empty:pages,deck_profilesandapplicationsareSETTLED_CHOICE_SOURCES, whose empty answer really means "nothing to pick". The other seven can answer empty for a different reason entirely — no mixer installed, no avahi, no Home Assistant configured yet, nothing playing audio — so_choices_known()leaves those as a plain text field instead of a dropdown with no way out. Conflating the two is a mistake that already happened once withha_entities. A parameter whose full list cannot reasonably be enumerated usescompletion_sourceinstead (twitch_categories): the field stays a text entry storing free text and gains live suggestions searched as it is typed. Reach for it whenever a dropdown would have to omit the very value the action exists to set. A parameter that only applies to some settings of another one declaresdepends_onanddepends_values; the editor then hides its row rather than dropping it, so the widget keeps its value and switching away and back does not silently reset what was chosen.twitch.alert's chat filter is the case: it answers a question only chat has.sources_in_scene,audio_sources_in_sceneandfilters_of_sourcedepend on another parameter (scene,sceneandsource, read with_sibling_value()), so changing the parent rebuilds them through_repopulate(). That rebuild replaces the whole widget, not just its model:_choices_known()decides between a dropdown and a plain text field, and a dependent list that was empty when the step was built starts out as a text field. Updating only the model left it a text field for good, so a scene whose sources appeared later showed nothing until the key was saved and reopened. A stored value is always kept selectable (keep_unknown), while a value inherited from the previous parent is deliberately dropped. A dropdown may show something other than what it stores, and there are two ways in. A static choice list addschoice_labels({stored value: label}) to itsParam;_labelled_choices()builds the visible labels from it. Without it a choice shows what it stores, which is right for values that already read as words (toggle,start) and wrong for identifiers — a list readingstream_timeandrender_lagtells nobody what those keys would show. The stored value stays the identifier, so rewording a label cannot invalidate a saved key, and the AI catalogue keeps validating againstparam.choicesrather than against the labels. A dynamic list goes through_display_options(), which returns the labels plus adisplay -> valuemap. Both fill the same_value_mapon the widget, which_widget_value()consults.hotkeysand the two Steam lists use it because their protocol identifiers and AppIDs are not useful labels.GetHotkeyListonly returns identifiers such asOBSBasic.StartStreamingandTriggerHotkeyByNameneeds exactly that string back.hotkey_display_name()inobs/client.pyderives the readable text, andget_hotkeys()removes the duplicates OBS emits once per source. The map is rebuilt with the widget, so it can never drift from the options on screen. execute(ctx, params)performs the action;feedback(ctx, params)may return any ofactive,color,badgeanddisplayfor live key state.displayreplaces the icon with fitted, centered text.- Override
label_for(params)when a selected local item can supply a useful label and the user's own label should still take priority. Overrideis_available(params)when a local program or selected item may be absent; an explicitFalseparticipates in the same all-actions-blocked fade as a missing service, while an exception remains unknown rather than disabling a potentially working key. ActionContext.keyidentifies the executing device-scopedRuntimeKey(profile, page, folder path, key index, deck id)when available. Usectx.for_key(key)to derive a context with that identity while preserving its cancellation controls.- Set
immediate = Trueonly for fast, non-blocking state changes. A single-action key then executes synchronously on press instead of occupying an action worker; multi/toggle sequences still use their normal worker. - Set
running_feedback = Trueon a blocking action that should show the controller's temporaryRUN/breathing feedback even as a single-action key. - Set
supports_long_press = Trueand overridelong_press(ctx, params)when holding a single-action key should do something else. ReturningFalsemeans "not handled", and the controller runs the normal press instead. - Set
restart_on_repress = Truewhen pressing the same device-scoped key again should cancel its prior invocation and restart the complete key sequence. Long-running actions must cooperate throughActionContext.stop_requested()orActionContext.wait_until_stopped(), which observe both application shutdown and replacement cancellation. needs_obssays the action can do nothing without the OBS connection, andrequires_obs(params)lets a key answer per configuration (obs.statsoverrides it). The deck fades a key whose every action needs OBS while OBS is closed. Everything inobs/actions.pyis flagged by_mark_obs_dependency()from its category, so a new OBS action gets it without doing anything.apply_default_icons({action_id: "mdi:…"})assigns default icons after registration. Overrideicon_for(params)when one action id is really several buttons:sys.mediais seven of them behind one parameter, and without it a row of transport keys all inherit the play/pause picture. Five places resolve an inherited icon — the deck, a step list, the editor preview, the printable sheet and the dial row — and every one goes throughicon_for, so a key can never preview one picture and draw another. Same shape asrequires_obs(params): an attribute for the simple case, a method for the one that has to look. Registration happens on import (app.pyimports the catalogues).
core/steam.py is deliberately local and credential-free. It reads each known
native, Flatpak and Snap Steam root, follows only paths registered in
libraryfolders.vdf, and parses appmanifest_*.acf; it never scans arbitrary
mounts and never asks for a Steam Web API key, account or public profile. The
small KeyValues parser accepts nested quoted fields, comments and Valve's quote
and backslash escapes. File size and host-command time limits bound malformed or
unavailable metadata. Proton, Steam Linux Runtime, Steamworks redistributables
and the two anti-cheat runtimes are filtered by their explicit component names;
unknown manifests remain visible rather than being guessed away.
A configured key stores only the numeric AppID. steam_games and
steam_recent_games turn that stable value into a friendly name in the editor,
preserve an imported unknown value as editable text, and resolve the current
installation again on the destination computer. Recent means a positive
LastPlayed, newest first, and the picker is bounded to fifty. Discovery has a
short locked cache because icon_for(), label_for() and is_available() can
all be reached during one repaint.
Artwork comes only from Steam's local appcache/librarycache: a portrait cover
is fitted over a dark blurred square and atomically cached below the
LinuxStreamDeck user cache. Pillow decoding/composition stays under the shared
RENDER_LOCK, but host reads and cache writes do not hold it. No copyrighted
game art is bundled in this
repository. A custom key icon and label still win; otherwise the cover and the
manifest's current game name are inherited dynamically on the live key, the
printable layout sheet and the Stream Deck + strip.
The five actions in steam_actions.py are steam.game.launch,
steam.game.resume, steam.open, steam.game.details and
steam.game.recent. URI parts are selected internally and AppIDs are numeric,
so none of them invokes a shell or accepts an arbitrary URI. The installation
that owns a manifest chooses native versus flatpak run; if it disappeared,
the other installed client is a safe fallback. A per-URI two-second cooldown
turns a double press into one launch. In the LinuxStreamDeck Flatpak, bounded
cat/find and the client command go through flatpak-spawn --host, using the
existing deliberate host permission rather than adding another sandbox hole.
running_games() is the local signal used by automatic Twitch categories. It
reads only same-user process environments and accepts STEAM_COMPAT_APP_ID,
SteamAppId or SteamGameId when the numeric value also belongs to an indexed
installed manifest. Native packages and AppImages enumerate /proc; the
LinuxStreamDeck Flatpak asks host ps for PIDs and uses the existing bounded
host-aware cat path for environ. Unreadable, malformed, non-Steam and unknown
AppIDs are ignored. Never replace this with active-window titles: Wayland does
not offer a reliable global foreground-window identity, and alt-tabbing to OBS
must not look like a game change.
The System category includes two stateful clock actions:
| ID | Parameters | Press behavior | Default icon |
|---|---|---|---|
sys.timer |
duration; optional supported audio sound; volume 0-100 |
Idle shows the configured duration; first press starts, a running press resets, completion stays at 00:00:00, and a finished press resets/stops its sound |
mdi:timer-outline |
sys.stopwatch |
None | Idle shows 00:00:00; first press starts and the next resets to zero |
mdi:clock-outline |
Both actions return their HH:MM:SS value through feedback display, which the
renderer centers in place of the icon while retaining any label. They set
immediate = True, so single-action keys update without entering the action
executor. A timer rejects a non-positive duration. Its optional completion sound
accepts the same .wav, .wave, .mp3, .ogg, .oga, .flac and .opus
formats as sys.audio, with independently clamped volume.
ClockRuntime owns transient timer/stopwatch state under a lock, keyed by the
device-scoped RuntimeKey. Its scheduler checks running clocks every 0.1 seconds but
requests a render only when a displayed second changes, and only the visible
profile/page is repainted. State continues to advance across page/profile
switches. Completion is emitted once; its optional sound runs on the separate
notification executor so it cannot consume action workers.
Drag/drop moves or swaps clock state with its configured key. Editing/saving, pasting over or clearing a key resets that position and cancels its timer sound. Profile/page deletion clears all clock state because stored indices shift, as do configuration import and controller shutdown. Pressing a running timer resets it; pressing a finished timer also cancels its completion sound.
sys.counter adds its step on a press and resets when the key is held --
supports_long_press, so a second key is not spent on something that happens
once a session. long_press() returns True to claim the gesture; returning
False would let the controller run the normal press as well, so holding it
would reset the count and then add one.
The count is transient key state, kept beside _toggle and following exactly
its lifecycle. It travels with a key that moves (_swap_key_state), is
dropped when the key's configuration is replaced (key_config_changed), is
discarded with a folder's subtree, and is cleared wherever indices shift —
which is why the test asserts _counters.clear() appears exactly as often as
_toggle.clear(). It deliberately survives a page or profile change, since
a tally is kept over a session and a page switch happens by accident.
It is not persisted. A count written to the configuration on every press would be a file write plus a backup rotation per press, and the honest consequence is that a restart starts again from the configured value.
start is read from the key's own parameters rather than stored, so changing
it moves an untouched counter with it — and editing the key resets the count
anyway, as it does every other piece of transient key state. A step of zero
counts as the default rather than as a choice: it is a key that looks
configured and never changes anything.
The visible Navigation category contains three explicit actions:
| ID | Parameters | Behavior | Default icon |
|---|---|---|---|
nav.page.next |
None | Next page, wrapping last → first | mdi:page-next |
nav.page.previous |
None | Previous page, wrapping first → last | mdi:page-previous |
nav.page.go |
page, labelled Destination page, choices_source="pages" |
Named page in the active profile; emits status when missing |
mdi:book-open-page-variant |
The old combined nav.page action is not registered or visible. AI proposals
therefore see only the three current IDs. KeyConfig.from_dict() transparently
migrates the legacy ID in single actions and every steps, steps_on and
steps_off entry. Legacy next / next page and previous / previous page
modes become their parameterless actions; other modes become nav.page.go with
the stored page name. Normal serialization writes the new IDs on the next
configuration save or export, so old local configs and imported bundles remain
compatible without preserving the deprecated shape.
Named targets must stay unambiguous within a profile. add_page() and
rename_page() reject an exact duplicate name and emit status without saving.
A successful rename traverses every key on every page in the active profile,
including single, multi and toggle steps, and rewrites matching
nav.page.go parameters. Deleting a referenced destination is allowed;
PageGo.execute() reports the missing name when the key is pressed.
set_page_by_name() returns whether the name exists, while set_page() treats
the already active page as a no-op to avoid redundant saves and events.
An empty KeyConfig.icon or icon_off is an inheritance marker, not a missing
render value. The editor Appearance preview must resolve the same effective
action/default icon as the controller and deck grid, but must keep the stored
reference empty. Clearing an explicit icon therefore restores inheritance instead
of copying the current fallback into config.
Three modules keep the system-facing work out of the action definitions, so each action stays declarative and every backend is unit-testable on its own.
core/apps.py reads the application list from Gio.AppInfo, which is already
available through GTK, so listing and launching need no extra dependency. Keys
store the display name; find_application() accepts either that or a desktop
id. running_pids() uses pgrep against the entry's executable, deliberately
ignoring wrapper names (env, sh, flatpak) that would match everything.
Relaunching a running application is what raises its window — the desktop routes
the launch to the existing instance — so there is no separate "activate" path.
core/media.py drives transport through MPRIS (playerctl) and volume through
the session mixer (wpctl, falling back to pactl). Never implement these by
faking media keys: MPRIS and the mixer work identically on Wayland and X11 and
need no privileges. Volume raises are capped at 1.0 so a key cannot push the sink
past 100 %.
core/mixer.py is the other half of that, and the half a stream needs: turning
the game down without touching the microphone, muting the microphone without
muting everything, and moving the session between speakers and a headset.
Everything in it goes through pactl -f json, which is a requirement rather
than a preference, for three separate reasons:
wpctlcannot list what is playing in any parseable form. Itsstatusis a tree drawn for a terminal.pactlwithout-f jsonanswers in the user's own language. On the machine this was written on it saysSilenciado: noandEntrada del destino #108, so anything matching English words works until somebody who is not English runs it. A pactl too old for-f json(before PulseAudio 15) is reported as such rather than guessed at.pactlcovers PulseAudio and PipeWire alike throughpipewire-pulse, so one backend serves both.
Four things about it are load-bearing:
- A raise is written as an absolute level, never as pactl's own
+5%. pactl clamps nothing, so a relative change on a key pressed a dozen times walks the sink to 160 %, where the sample is amplified in software and distorts._level()reads the current value first, which is what puts a ceiling on it. - Every stream of an application moves together. A browser commonly owns
several, and acting on the first alone leaves one tab audible after the key
said it had muted it. For the same reason
_app_state()calls an application muted only when all of it is. - An unanswerable state is
None, never "not muted". A microphone key exists to be believed about exactly this, so a question that went unanswered draws nothing rather than reassuring. - One reading of the mixer serves every key.
feedback()runs on the single render worker and each reading is four processes; withoutSTATE_TTLa page of fifteen mute keys would spawn sixty per repaint.apply()drops the cached reading, so the repaint after a press cannot show the state from before it.
Only mute keys join _live_loop, at MIXER_REFRESH_SECONDS: the mixer can
be changed from the desktop's own volume panel or a headset button, so that
state has to keep up, while a volume key has none to show. The editor fills its
two dropdowns from audio_apps and audio_devices, both in
LOCAL_CHOICE_SOURCES since neither needs OBS. A device key stores the stable
name and shows the description, exactly as an OBS hotkey does.
core/keystrokes.py is the only place that injects input. Wayland blocks
synthetic events, so it shells out to ydotool (preferred: it writes to uinput
and therefore works on every compositor), wtype or xdotool. They are
Recommends of the .deb, never Depends: apt installs them by default while
the package still installs on a distribution that lacks them, and backend()
returns "" when none is present so the action reports what to install.
The two ydotool generations are incompatible. 0.x — still what Debian and
Ubuntu ship — takes a key sequence (ydotool key ctrl+c), while 1.x takes
press/release event codes (ydotool key 29:1 46:1 46:0 29:0). Never assume one:
ydotool_syntax() detects it from the help text and caches the answer. A key is
therefore held canonically as its Linux input name (esc, dot, sysrq,
leftbrace), with _KEY_ALIASES accepting what a user might type, _KEY_CODES
giving the 1.x codes and _XKB_NAMES the X keysyms for the other two backends.
Presets are adapted to Linux — Windows-only entries of the original catalogue are
dropped — and always stay editable, because desktops rebind them.
sys.media gains an optional Show what is playing, off by default so a
plain transport key costs nothing. core/nowplaying.py reads MPRIS through
playerctl, which media.py already uses, so this adds no dependency.
A song title does not fit on a key, and that decided the whole design.
Measured at 96 px: as a label it wraps to two lines and is cut — "Wish You
Were Here" becomes "Wish You Were" — and centered it is worse, because
compose() fits centered text to the key width, so a short title is drawn
huge and a long one unreadably small. The artist fits every time, because
artist names are short by nature. So a key shows the album art with the
artist over it: the picture is what makes it recognisable before anything is
read, and the artist is what stays legible when there is no picture.
Five things are load-bearing:
- The separator is
\x1f. Any punctuation would eventually land inside somebody's song title and split it in half. Verified against playerctl 2.4, where a metadata key the player does not publish renders as empty rather than as an error or as the literal template. - Feedback may replace the label, which is the only reason this works:
_single_specreadsfb.get("label") or kc.label. The fallback is what makes the key show its own name again the moment nothing is playing, rather than blanking or keeping a stale artist. A player that is playing but publishes no metadata — a stream that has just started — takes the same path. - A
file://cover is read on the calling thread; anhttp(s)one is not. A local read is the same onecompose()already does for a custom icon, and doing it in the background would blank the key for a refresh on every track change. Spotify publishes an https address, andfeedback()runs on the single render worker, so that one goes throughwebrequest.background(). Any other scheme is refused and remembered as "no cover", so it is not examined again on every repaint. - One reading serves every key, at
STATE_TTL: each is a process (measured at ~7 ms). A press drops it, so the repaint after pausing does not show the state from before. No players foundis not a failure.playerctlexits non-zero for it, and it is the ordinary answer when nothing is playing.
sys.audio plays a file on the machine, which is right for a cue meant for
the person at the deck and useless for a soundboard: nobody in the stream or
the call hears it. core/soundboard.py is the missing piece, and it is not
audio code at all — it is one device.
module-null-sink creates an output that goes nowhere and exposes what was
played into it as a source. OBS, Discord or a browser then picks
linuxstreamdeck.monitor as an input and hears exactly what a key played,
with nothing else from the machine mixed in. play_audio(..., sink=) routes
one playback there through a pulsesink, leaving every existing key untouched.
Four things are load-bearing:
- A
module-loopbackcomes with the sink, from its monitor back to the normal output. Without it the person pressing the key hears nothing, which is indistinguishable from a broken key. A loopback that fails to load is logged and no more: the sound still reaches the stream, and losing the monitoring must not lose the feature. - The device is looked for before it is created. It belongs to the audio session rather than to this process, so it survives a restart — and creating blindly would leave a day of restarts with a dozen of them.
- Shutdown deliberately leaves it alone. Another application stores it by
name: OBS as an audio source, Discord as an input. Removing it on exit would
silently break their configuration every time this one closed, and the audio
session clears it at logout anyway.
remove()exists but nothing calls it. - A device that cannot be made reports, and plays nothing. Falling back to
the default output would put a soundboard cue where the person pressing it
can hear it and the stream cannot — which looks like it worked and is not.
_route_to()raises for the same reason when the PulseAudio plugin is missing.
AUDIO_LOCAL stays the default, so no key already configured changes what it
does by being loaded.
sys.audio (Play audio file) uses GStreamer playbin for local .wav,
.wave, .mp3, .ogg, .oga, .flac and .opus files. Its declarative
parameters are a filtered file chooser, integer volume from 0 to 100 with a
step of 5, and an optional maximum duration. Blank duration means play to EOS;
otherwise MM:SS / H:MM:SS limits playback.
Playback is deliberately blocking on the action worker so the next multi/toggle
step starts only after EOS or the limit. The player polls
ActionContext.stop_requested(), returns promptly during application shutdown or
replacement cancellation and always resets the pipeline to NULL.
Missing/unsupported files, pipeline creation/start failures and decoder errors
must surface through the normal action error status. Both sys.audio and
sys.wait opt single-action keys into running feedback.
sys.audio sets restart_on_repress = True, making its entire containing key
sequence restartable. Execution controls are scoped by RuntimeKey.
A same-key press signals the prior control and chains its replacement behind the
prior finished event, so the old GStreamer pipeline reaches NULL before the
new invocation starts. Rapid presses cancel queued replacements before they run,
collapsing the queue to the latest invocation. sys.wait uses
ActionContext.wait_until_stopped() so a wait anywhere in an audio-containing
sequence can be interrupted promptly before the restart.
AIService supports OpenAI and Claude with user-selected models. Provider API
keys are stored separately per provider through ApiKeyStore in Secret Service /
GNOME Keyring. They must never enter Config, config.json, its backup, or a
.lsdconfig export. Provider API billing is separate from LinuxStreamDeck.
When a saved key is displayed, its fixed read-only mask is only a presence
indicator. The request must use the separately held stored key, never the mask.
Replacing, returning to the saved key, and forgetting it are explicit UI actions.
AIKeyDialog is laid out around the fact that its configuration is set up once
and its description field is used every time. Provider, model, API key and the
context switch live inside one Adw.ExpanderRow, and the primary action sits in
an Adw.ToolbarView bottom bar rather than at the end of the scrolled page.
Measured at the default 580x820, that took the request page from 936 px of
content in a 773 px viewport — description field starting 487 px down, Generate
button entirely below the fold — to 606 px in a 727 px viewport, which needs no
scrolling at all.
Four rules keep it from feeling like something is hidden or moving:
- The folded row restates the whole configuration in its title and subtitle
(
_refresh_summary()): provider, model, key state, context and billing. A bare "Settings" row is the version that leaves people hunting. - The opening state is decided once, in
_settle_opening_state(), from what the keyring holds: folded with the description focused when_configuration_ready(), expanded with the API key focused when not. It is guarded by_settings_settledso a later lookup — a provider switch triggers one — can never fold the settings away while they are being used. - Validation opens what it complains about.
_generate()expands the row before reporting a missing API key, or the message points at a field nobody can see. _show_page()moves the content stack and the action stack together. The bottom bar is shared by both pages, so switching only one leaves the wrong buttons under the wrong page.
The reassurance under Describe the key — that a proposal never runs or saves anything — stays outside the expander on purpose. It is a safety statement about what the feature does, not configuration, so it must be readable without expanding anything.
A group's children are not items of the scene. OBS reports a group as one
scene item and hides everything inside it: GetSceneItemList never mentions the
children, and GetSceneItemId against the scene fails for them, because OBS
addresses them with the group's name in place of the scene's. Both halves of
that have to be handled together, or the feature is broken either way round:
get_sources_in_scene()walks eachisGroupitem throughGetGroupSceneItemListand lists its children after it, keeping the group itself selectable and de-duplicating a source that sits in two groups. Without this the editor offered only the group, so nothing inside it could be picked.find_scene_item()returns the container that actually holds a source, not the scene, trying the scene first and then its groups. Every later scene-item request must be addressed to that container. Listing nested sources without this would offer sources that then fail on press.
OBS does not allow a group inside a group, so one level is the whole tree.
get_audio_sources_in_scene() is built on the same walk and inherits the fix.
The Audio actions scope their input list to a scene:
OBSClient.get_audio_sources_in_scene() takes the scene's items, adds the
special inputs from GetSpecialInputs (Desktop Audio, Mic/Aux belong to no
scene yet are audible on all of them) and keeps only the ones that answer
GetInputMute, since OBS has no request for "the audio sources of a scene".
Their scene parameter is an editor filter only: it defaults to "", is
never sent to OBS, and the actions still target the input globally, so keys
saved before it existed keep working untouched.
The optional generation context is an explicit user opt-in. It contains only
bounded OBS names, page names, and installed Steam game names/AppIDs collected
by collect_generation_context(); never send passwords, commands, the full
configuration, or other secrets. The action
catalogue exposed to a provider always excludes sys.command, obs.raw and
web.request.
A provider response is untrusted data. It must pass local structural, action,
parameter, choice and icon validation before becoming an AIProposal. Generation
must never execute or save a key. The user first previews the proposal, explicitly
loads it into the existing editor, reviews it, and presses Save to persist it.
A proposed step may carry an optional label, the same descriptive name the
editor's step list shows. _step_label() treats it as what it is — text that
names a row and is never matched against an action — so it only bounds it to
MAX_STEP_LABEL_CHARS and flattens it to one line; a missing one is simply
empty, and a non-string is rejected. The action id alone still decides what
runs, so no label can select or reach an action. format_proposal() prints the
name and its action (1. Cut to camera — Switch scene), so the preview can
never hide behind a friendly name what the key would actually do.
Config holds profiles and delegates pages/current_page to the active
Profile. Each KeyConfig.kind is one of:
single(KIND_SINGLE) — one action, with state feedback.multi(KIND_MULTI) — an ordered list ofActionSteps run in sequence; pauses are an explicitWaitaction (sys.wait, adurationparam), not a per-step delay.
Each StepList row is a Gtk.Expander whose title is a widget
(set_label_widget): a drag handle, the hexpanding title label and the move /
copy / remove buttons, so none of them needs the step to be opened first.
Rows also reorder by drag and drop, with one source/target pair per row in the
bubble phase — capture would swallow the clicks of those very buttons and
the expander's own toggle. Each row must also drop GtkExpander's own
GtkDropControllerMotion (_disable_hover_expand()): it exists to spring open
a tree node after a hover, so without removing it the row under the pointer
opens itself mid-reorder. The remove button uses the .step-remove class rather
than .destructive-action, because the latter paints a background that .flat
drops, leaving the icon in the normal text colour instead of red. The payload is the internal typed string
linuxstreamdeck-step:<index>, validated against the drag in progress exactly
like the key grid's, and a drop moves the step to that position rather than
swapping two. Because the title is a widget, renaming a row sets
editor._title, not Gtk.Expander.set_label(); step_title() reads it back.
StepList rebuilds all of its rows whenever one is added, pasted, moved or
removed, which makes two things easy to get wrong. It must call
_remember_expansion() first, so each row keeps the state the user gave it
rather than the one it was created with — otherwise adding a step silently
reopens rows that were collapsed. And it must unparent each StepEditor
explicitly before re-wrapping it, instead of relying on the previous expander
being garbage collected: anything still holding that expander (a queued reveal,
a handler) leaves the editor attached to it and the rebuild fails with
gtk_box_append: assertion 'gtk_widget_get_parent (child) == NULL' failed.
_reveal() scrolls a newly added or pasted step into view on a low-priority
idle and resolves the expander late, so a rebuild in between scrolls to the
current row. Reordering and removing instead go through
_rebuild_keeping_scroll(), and _move() hands focus back to the same arrow on
the row's new position: GtkViewport:scroll-to-focus is on by default, so the
button being destroyed mid-click is what threw the list back to the top. That
grab must be wrapped in a callback returning False: grab_focus() answers
True when it succeeds, and GLib.idle_add(button.grab_focus, …) therefore
kept the source alive and re-ran it for the rest of the session. Note that a
collapsed GtkExpander keeps its child out of the traversable widget tree, so
tests must walk down from get_child().
STEP_CLIPBOARD (a module-level _StepClipboard in ui/steps.py) holds one
copied ActionStep. It cannot live on a StepList: EditorPanel._build_body()
rebuilds every list whenever the selected key or the key type changes, so a copy
kept there could never reach another list, let alone another key. It stores and
returns a fresh copy each time, so pasting twice cannot share one params dict,
and it refuses a step with no action.
The row header's copy button only fills that clipboard; nothing is inserted
until a paste. Right-clicking a row offers everything that row can do —
Copy action and Paste action, then Move up / Move down / Move to top /
Move to bottom, then Remove action — with None between the groups standing
for a separator. A paste lands at that row's index, pushing it down
(_add(..., position=)); the move entries reuse _move() and _reorder(), and
each is disabled where it would do nothing, so the only row of a list can only be
copied, pasted onto or removed. Right-clicking the list beside its rows
offers only Paste action, at the end. A right click inside a parameter entry
reaches neither: the entry claims it first and shows its own text menu.
While a row menu is open its row carries the menu-target CSS class, because the
popover covers part of the list and the entries act on a row the pointer has
already left. _set_menu_row() holds that row as a widget, not an index: an
entry may reorder the list, and the highlight has to come off whatever it went
on. _ContextMenu.show(..., on_close=) clears it however the menu ends —
choosing an entry, dismissing it, or a _rebuild() closing it.
A pasted row arrives collapsed — _add(..., expand=False) followed by an
explicit _reveal(). It is scrolled into view but never opened: the copy is
already configured, so expanding it would only push the rest of the list out of
sight. Adding a new step still opens, because that one has to be filled in.
Both gestures are in the bubble phase and both see the same click, so the row's
answer has to win twice over. _on_row_menu() claims the sequence, which should
already deny the list's gesture, and raises _row_menu_click, which
_on_list_menu() checks before showing anything; it is cleared on the following
idle, so the next click works normally. Do not drop the flag and rely on
claiming alone, and do not resolve the row from Gtk.Widget.pick() instead:
pick() answers None on an unmapped widget, which makes that path impossible
to verify without showing a window. row_menu_items() / list_menu_items() /
StepEditor.menu_items() build the entries as plain (label, enabled, callback)
tuples, so a test can assert them without popping up a popover.
A single-action key has no row to right-click, so EditorPanel calls
StepEditor.enable_context_menu() on its editor; pasting there replaces the
action and its parameters, and drops the step name, which only a list can show.
That is opt-in precisely because a StepEditor inside a list must let the right
click bubble to its row.
The same copy can also become a whole key: the grid's context menu carries
Paste action (win.key-paste-action), enabled from STEP_CLIPBOARD, which
MainWindow._paste_action() turns into a KIND_SINGLE KeyConfig and applies
through the usual DeckController.paste_key(). It goes through the same guards
as pasting a key — the unsaved-change dialog with offer_save=False, and
_replacing_folder() — and it refuses the reserved Back slot. It drops the step
name for the same reason the single-action editor does, and it leaves the
clipboard filled, so one action can be dropped onto several keys in a row.
_ContextMenu keeps at most one popover, parented to the row it was opened on.
Since every StepList rebuild destroys those rows, _rebuild() closes it first,
and close() is idempotent — it also runs from the popover's closed signal, so
dismissing the menu by clicking away cannot strand it either.
Every ActionStep also carries an optional label: a name shown in the editor's
step list instead of the action name, so a long sequence stays readable. It is
purely descriptive — nothing at run time reads it — and it is stripped of
surrounding blanks, so a name of only spaces falls back to the action name.
StepEditor(show_label=True) draws the field, which StepList sets and the
single-action editor does not: a single-action key has no list entry to name, and
its own label already lives in Appearance. Because the field is outside
params_box, a name survives changing the step's action. Old configurations
without the field load with an empty label, and older builds ignore it, so no
bundle version changes.
multi_toggle(KIND_TOGGLE) — two lists (steps_on/steps_off) with an ON/OFF state; the state is keyed byRuntimeKeyin the controller.random(KIND_RANDOM) — reusessteps, but a press runs exactly one entry chosen withrandom.choice.press(KIND_PRESS) —steps_single,steps_doubleandsteps_long, one per press gesture.folder(KIND_FOLDER) — holds aFolderof nested keys infolderand runs no action at all; see Folder keys below.
STEP_FIELDS lists every field holding ActionSteps. Anything walking a key's
actions — portable bundles, legacy migration — must iterate it rather than
hard-coding the three original names, or the new lists silently lose their
bundled audio and their nav.page migration. For the same reason it must walk
nested folder keys with Config._walk_keys() / _walk_raw_keys().
KeyConfig.on_error applies to every one of those lists: ON_ERROR_CONTINUE
(the default) or ON_ERROR_STOP. See When a key cannot do its job.
DeckController keeps a bounded history (UNDO_DEPTH) of the key changes that
can be taken back: clear_key(), paste_key() and swap_keys() each record
what the affected slots held before they ran. Only the KeyConfigs are kept,
never transient state — an undone key starts from a clean runtime state exactly
as a pasted one does.
The history is scoped to the grid on screen. can_undo() requires the top
entry's container to be the current one, and forget_undo() drops it on every
page, profile and folder change and on deleting a page or profile or importing a
configuration. Restoring into a grid the user has left would be invisible, and
inside a folder the same index is a different key entirely. MainWindow routes
undo through the usual unsaved-change guard, since it may replace the key the
editor is showing.
ui/action_picker.py searches all registered actions at once — name, category,
description and id, terms ANDed — because the two chained dropdowns need the
category to be known before the action can be found. Results starting with the
query rank first, so Enter picks the obvious one.
core/search.py finds a key anywhere in the configuration. locations() walks
every profile, page and nested folder yielding a KeyLocation with enough to
navigate back; key_terms() builds the haystack from labels, action ids and
their registered names, step names and parameter values, so a key is found by
what it does rather than by where it is. key_steps() deliberately does not
descend into a folder: nested keys are yielded by the walk itself, and crediting
a folder with its contents would make every folder match every search.
LONG_PRESS_SECONDS (0.5) and DOUBLE_PRESS_SECONDS (0.35) live in
core/controller.py. _on_deck_key now routes both edges: key_down() and
key_up(). _gesture_mode() decides what a key needs — "press" for
KIND_PRESS, "long" for a single-action key whose action sets
supports_long_press, and "" for everything else, which keeps running on the
press exactly as before.
A gesture key resolves on release: held past the long threshold runs steps_long;
otherwise a threading.Timer waits out the double-press window, and a second
release inside it cancels that timer and runs steps_double instead of
steps_single. Pending timers must be cancelled whenever the key's meaning
changes — key_config_changed(), swap_keys(), _clear_time_actions() (page or
profile change) and shutdown() all call into _cancel_gesture() or
_clear_gestures(), because a stored index refers to a different key afterwards.
A virtual press has no release to time, so press() runs steps_single directly;
the editor says so. It also accepts an explicit gesture (GESTURE_SINGLE /
_DOUBLE / _LONG, resolved by gesture_steps()), which only the editor's
Test buttons pass: naming a gesture is a different thing from making the virtual
deck wait out the double-press window, which it must never do. Action.long_press(ctx, params) returns False to decline,
and the controller then falls through to a normal press, which is what makes
"Nothing" behave exactly like a short press.
Built-in games are launched from the status icon's Games submenu. They are
not actions stored on a key and never modify the active profile. GameManager
owns one daemon worker and sits in DeckController ahead of every normal input
path: physical press and release edges, virtual presses, dials and touchscreen
taps. While games.active is true, _standing_aside() also stops configured
renders and Twitch alert flashes, the window disables editing/navigation and
DnD, and the screen saver is suppressed with the named game reason. An
already-active saver is explicitly woken before the first game frame.
The physical release belonging to a press that exits the game must still be
consumed after active clears. _owned_down records presses that began inside
the session for exactly that reason; without it the release would reach the
gesture resolver of whatever configured key is underneath. On exit, disconnect,
layout-changing connect, error or shutdown, the manager cancels queued sounds,
releases only its own screen-saver reason and restores the current configured
page. _restore_after_game() clears any report that completed late so a
pre-flight board cannot suddenly appear as the game lets go. The game worker
must stop from DeckController.shutdown() before DeckManager.stop() closes
HID, preserving the application-level controller-before-deck shutdown order.
games/catalog.py holds the stable identities and active-window hints; the
status menu's fixed IDs/labels must mirror it. Each registered game has its own
pure state machine with injected monotonic time and randomness, and none knows
about GTK, HID, Pillow or audio.
They share common.py's GameLayout, EngineEvent, phases and lobby contract:
Start, difficulty, sound, record and Back. Their rules differ:
ArcadeEconomy is the single authority for Deck Sparks. A missing legacy
games.arcade object migrates to 250 Sparks and the three starter unlocks
(Circuit Breaker, Mole Smash and Neon Jackpot). Every non-Jackpot START/AGAIN
is intercepted by GameManager before the pure engine sees it and atomically
spends the catalogue's per-run price. Locked games stay in the menu and start a
manager-owned access screen; UNLOCK requires a second press, and EARN SPARKS
ends the current exclusive session before game.request starts Neon Jackpot.
Never start the replacement while _finishing still owns pixels.
The first valid UNLOCK press queues unlock_prompt; the successful debit queues
the distinct unlock_success achievement cue. Both are global Arcade assets,
not files borrowed from any individual game's self-contained asset directory.
Unlocks, wallet, progressive jackpot, its fractional fifth-Spark contribution
remainder, completed challenges, next refill UTC timestamp and the two
refill-alert switches live in ArcadeSettings, so JSON, automatic backups and
.lsdconfig exports preserve all of them. There is no
Free Play switch. Deck Sparks are local fictional data with no purchase,
real-money value or cash-out, and are deliberately not encrypted or presented
as cheat-proof. When the balance is zero, an overdue 24-hour eligibility grants
100 rescue Sparks; a positive balance leaves an overdue grant ready rather than
consuming it. The refill sound and full-deck gold pulse are independently
optional, and app.py defers both while a game owns the deck or OBS is recording
or streaming.
ChallengeService awards each stable challenge ID once. Configuration facts
are scanned retroactively (one summary for existing users); OBS/Steam rewards
listen only to action.succeeded or obs.connected, never to a press that may
have failed. The scan covers configured keys, profiles, page navigation,
folders, three-step multi keys, toggles, press gestures, Steam game keys, five
catalogue categories, exports and deck-display settings. Game completion,
victory and record rewards come from game.completed. Adding repetitive or
risky rewards would turn ordinary control use into farming pressure and is out
of scope.
MainWindow owns at most one ArcadeSettingsDialog; repeated menu or tray
requests present that instance, and its close callback clears the reference.
Every Games row is activatable and routes through app.request_game, preserving
the unsaved-key guard while allowing a locked title to open its physical-deck
access screen. Completed challenge rows must remain distinguishable without
reading their subtitle: green tint, success icon and reward colour are all
state-driven and removed again for an available row.
-
mole_smash.pyruns a three-second countdown and a 45-second reaction round. Normal targets score 10, golden targets 25, an empty press costs 2 and breaks the combo; difficulty changes visible/gap timing and targets accelerate. -
circuit_breaker.pycreates a non-empty, guaranteed-solvable board by applying legal cross toggles to an all-off grid. A press toggles its cell plus the orthogonal neighbours, difficulty changes scramble density, and finishing in fewer moves replaces the record. -
pulse_memory.pystarts after a three-second countdown, shows a sequence and accepts input only after the display phase. Easy/normal/hard begin at two, three or four steps and use progressively shorter light/gap intervals. A correct replay appends one step that cannot immediately repeat the same key; a wrong key ends the session, and the longest completed sequence is the record. -
memory_match.pyshuffles pairs over the largest even number of keys. An odd grid reserves its final key for moves/pairs; Easy and Normal preview all cards for 2.2 or 1.1 seconds while Hard starts hidden. Mismatches block input until their difficulty-dependent reveal expires, and fewer completed turns wins. -
minesweeper.pyreserves the final key as a Reveal/Flag mode switch and uses every other key as the minefield. Mines are placed only on the first reveal, keeping that cell and its neighbours safe whenever capacity permits; revealing a zero expands through its safe region. Larger layouts use 12%, 18% and 25% mine density, while compact layouts use exactly one, two or three mines so all difficulties stay distinct. A mine ends the round; revealing every safe cell records the lowest elapsed whole-second time. Results keep every final cell visible except the controls: the former mode key becomes Again, and Back prefers the normal lobby exit position but must move rather than cover the exploded cell. -
tic_tac_toe.pygives the player X and the computer O, with a 0.34-second visible AI turn. Layouts with at least nine keys receive a centered 3x3 board; smaller layouts use every key and derive every horizontal, vertical or diagonal three-cell line their geometry supports. Easy chooses a random free cell, Normal first wins or blocks and otherwise chooses randomly, and Hard completes that tactical pass with minimax. Each player win increments the persisted win count. Results retain and highlight the winning line; Again and Back first use keys outside the board, then non-winning cells on a compact board, so neither erases that line. -
mastermind.pylets each peg cycle through a difficulty-dependent palette, then scores a submitted guess as exact-position plus colour-only matches with duplicate colours counted once. Easy is 3 pegs/4 colours/8 attempts, Normal 4/6/10 and Hard 5/8/12; the code shortens only when a compact deck needs two control keys. Spare keys show newest-first clue history. A six-key Mini has no spare history key at Normal, so Submit keeps the latestE/Cclue visible; results reveal every solution peg and turn Submit/Reset into Again/Back. The lowest successful attempt count is the record. -
neon_relay.pyis an endless circuit-routing run. Each sector builds a randomized, non-repeating, guaranteed orthogonal route between two boundary portals, then scrambles its straight/corner tiles among decoys. The player rotates tiles before the travelling spark reaches them, collects crystals and carries a growing sector combo; efficient rotations earn a perfect-sector bonus. Speed rises by sector to the difficulty's floor. Easy/Normal/Hard also differ in initial hop time, route brightness, pre-aligned safe tiles and starting shields. A crash spends a shield and rebuilds the sector after a recovery pause, or ends the run when none remain. Crystals, perfect clears and completed sectors charge a six-second score-doubling Overdrive. Every third cleared sector presents shuffled shield, permanent stasis-slowdown and score/Overdrive-surge upgrades. The highest score is the record. -
neon_jackpot.pyis a three-reel arcade slot game using the global fictional Deck Sparks wallet only, with no payment, real-money stake or cash-out path. A fresh progressive pot starts and resets after a win atJACKPOT_SEED=40; loading preserves an existing pot above that floor. Every paid spin contributes exactly 20% of its wager.jackpot_progresspersists the remainder as zero to four fifths, so bets of one or two Sparks do not round a whole Spark into the pot. A spin chooses all unheld outcomes before animation begins; pressing a moving reel only brings its predetermined stop time forward. Both recovery paths require an ordinary paid, unheld loss: NUDGE may advance one reel by one position and, only when no nudge was granted, HOLD may retain up to two reels for exactly one following spin. Neither is offered after a win, free spin or held spin, they are never offered together, and assisted spins cannot chain. WILD substitutes in a three-symbol match, any two cherries return the bet, three sevens add the growing jackpot, and three bolts add three free spins and a 2x multiplier. A granted one-shot nudge advances the chosen reel one strip position and settles once more. The Info control cyclesPAYTABLE_HINTS, the declared 3-match, two-cherry, bolt and jackpot rules, rather than hiding the payout model. Easy/Normal/Hard use 12%/10%/8% nudge chances, 5%/4%/3% hold chances and 2.25/1.95/1.72-second base spins. Payout multipliers stay equal: once the wallet buys other games, Hard doubling every reward would make every other difficulty an irrational economy choice. The wallet and growing progressive jackpot and fractional contribution remainder persist across sessions. The highest bankroll is the record. A deterministic optimal-strategy simulation over 1,000,000 paid spins per difficulty measured 86.81%/84.38%/82.06% total RTP on Easy/Normal/Hard respectively, including assists and the progressive pot; treat those figures as balance checks rather than a per-session promise.A paid spin first exposes a bounded
WAGER_SECONDSpresentation phase: the bet and jackpot accounting is already committed, but reel motion and its motor cue begin only when that phase finishes. If the first two predetermined stops are compatible with a three-symbol match, the third reel enters a bounded ease-out anticipation phase; this changes only its presentation time, never the target strip position. Each stop records its own short impact window. The snapshot carries wager progress, three impact strengths, anticipation progress and a stable celebration tier so physical keys, the virtual deck and the Plus HUD render the same coordinated sequence.
Neon Relay's difficulty tuple is load-bearing rather than cosmetic:
| Difficulty | Initial/floor seconds per tile | Sector acceleration | Pre-aligned route tiles | Starting shields | Route light |
|---|---|---|---|---|---|
| Easy | 1.08 / 0.58 | 0.035 | 3 | 2 | 1.00 |
| Normal | 0.84 / 0.42 | 0.040 | 2 | 1 | 0.78 |
| Hard | 0.66 / 0.32 | 0.044 | 1 | 0 | 0.58 |
A collected crystal scores 30 and adds 16 charge. A cleared sector scores
110 + sector*28 + crystals*35, plus 85 for optimal rotations, under a combo
multiplier that rises every three clears to a maximum of 5; active Overdrive
doubles both crystal and clear rewards. A clear adds 18 + crystals*8, plus 24
for a perfect sector, to charge. The shield upgrade adds one to a maximum of
three, stasis adds 0.07 seconds per tile to a 0.24-second cap, and surge adds 300
points plus 55 charge.
Each record key is <columns>x<rows>[+lcd]:<difficulty>, so decks with different
playable areas do not share an unfair leaderboard.
game_layout() derives every shared control from live key_count and columns.
On decks without a touchscreen, Mole Smash reserves the two top corners for
score/time and Memory Match reserves the last key only when an odd key count
would leave an unpaired card; Circuit Breaker and Pulse Memory use the full grid.
Minesweeper reserves one mode key, Tic-Tac-Toe centers 3x3 when it can and uses
the complete compact grid below nine keys, Colour Mastermind sizes its peg row
around Submit/Reset before assigning leftover keys to clue history, and Neon
Relay uses every key as a circuit tile. Neon Jackpot is the one catalog game
with a minimum geometry: GameInfo.minimum_keys=8 makes GameManager.start()
refuse a six-key Mini with a status message. slot_layout() uses three
single-key reels plus shared status/controls on 4x2 Neo and Plus layouts, three
three-key vertical reel windows on 5x3 and 8x4 layouts, and never changes its
three predetermined payline symbols merely to animate them. On
Stream Deck +, dial_count selects the +lcd layout and each renderer puts its
live score/moves/sequence/pairs/time/clue/progress state on the strip where
appropriate. The same PNG travels on ui.game_hud to a 480x60
aspect-preserving Gtk.Picture below the virtual grid; an empty payload and the
inactive state both hide it. Lobby controls may reuse play positions because
the phases never render at once. Keep the controls unique even on a six-key Mini
and never assume a 5x3 grid.
The phase-specific presentation is intentional. Compact Tic-Tac-Toe has no
spare status key, so every board tile receives a subtle waiting tint during the
AI delay; larger boards use exactly one central extra key for turn/result text
and render the other extras as subdued dots rather than repeated status tiles.
Colour Mastermind's Plus HUD uses compact E/C feedback and supplies the
E = exact / C = colour legend on its lower row; keep that legend, Best,
attempts and the title in separate measured regions so they cannot overlap.
Neon Relay's Plus HUD carries score, sector, shields, speed or active
Overdrive/stasis state and the charge bar. Turning a dial rotates every tile in
that dial's column by the reported tick count, except the tile currently holding
the spark. Pushing any dial or tapping its LCD segment spends 40 charge to apply
3.5 seconds of stasis and extends the current hop, so a near-deadline press takes
effect immediately; the direct extension is one second. This has simulation
coverage only; no Plus game control has been tested on physical hardware.
Neon Jackpot's Plus HUD shows its three symbols, fictional bank, bet, jackpot,
result and free-spin multiplier. Dial 0 turns through affordable bets and pushes
Spin; dials 1-3 brake their corresponding moving reel, or push to hold/nudge it
between spins. Its deck renderer also owns the Bet-to-Jackpot wager token, reel
impact waves, focused third-reel anticipation, symbol-specific celebrations and
the four-act jackpot blackout/777/title/coin-payout sequence. These controls
have simulation coverage only too.
games/render.py dispatches engine snapshots to the Mole Smash renderer or the
eight *_render.py modules, and sends the same Pillow images to physical and
virtual decks under the shared RENDER_LOCK; every TrueType font uses BASIC
layout. GameManager is shared by all nine engines, compares encoded PNGs and
sends only changed keys at its 20 Hz tick; the engines remain deterministic even
though their sessions are animated.
Every game owns a self-contained asset directory named after its catalog ID:
assets/games/mole_smash/, circuit_breaker/, pulse_memory/ and
memory_match/, plus minesweeper/, tic_tac_toe/, mastermind/ and
neon_relay/ and neon_jackpot/. The Mole Smash directory also owns its
original transparent mole sprite. Neon Jackpot owns eight original transparent
symbol sprites: cherry, lemon, star, bell, bolt, diamond, seven and WILD.
games/audio.py::CUE_FILES is keyed first by game ID, and every
cue resolves only below that game's directory; shared waveforms are duplicated
deliberately so one game never depends on another game's assets. Each directory's
WAV set must exactly match its cue mapping and its own LICENSE-GAME-ASSETS.txt.
tools/generate_game_sounds.py deterministically writes the complete set for
all nine directories, including Circuit Breaker's circuit.wav, Pulse
Memory's six pitch-mapped pulse-0.wav through pulse-5.wav, and the distinct
reveal/flag/explosion, mark/AI/result and peg/submit/result cue sets of the three
new games, Neon Relay's reactor cues and Neon Jackpot's cabinet cues. Keep all
nine directory patterns in
pyproject.toml package data
whenever the mapping changes.
Playback uses a bounded two-worker queue and a per-session generation so rapid
hits never leave delayed sounds playing after exit. Neon Jackpot deliberately
has no continuous or fixed-duration spin bed: each integer reel-travel crossing
emits its short reel_tick_1/2/3 cue, all backed by the same quiet
reel-tick.wav, while the final position emits only its distinct stop cue.
The exact new-game WAV sets are load-bearing: Minesweeper owns
explosion.wav, finish.wav, flag.wav, go.wav, record.wav,
reveal.wav and select.wav; Tic-Tac-Toe owns ai.wav, draw.wav, go.wav,
lose.wav, mark.wav, select.wav and win.wav; Colour Mastermind owns
finish.wav, go.wav, peg.wav, record.wav, select.wav, submit.wav and
wrong.wav. Each set is original synthesized audio reproduced by
tools/generate_game_sounds.py and licensed by the file beside it.
Neon Relay owns exactly crash.wav, crystal.wav, gate.wav, go.wav,
overdrive.wav, record.wav, rotate.wav, select.wav, shield.wav,
stasis.wav and upgrade.wav under assets/games/neon_relay/; its license,
deterministic generator mapping, cue map and package-data pattern must move in
lockstep.
Neon Jackpot owns exactly anticipation.wav, bell-win.wav, bet.wav,
bonus.wav, brake.wav, diamond-win.wav, game-over.wav, hold.wav,
jackpot.wav, lose.wav, nudge.wav, reel-stop-1.wav, reel-stop-2.wav,
reel-stop-3.wav, reel-tick.wav, select.wav, star-win.wav, start.wav,
wild-win.wav and win.wav below assets/games/neon_jackpot/.
Its generated sprites, exact deterministic WAV set, license, cue map and package
data patterns must remain isolated and move together.
GameSettings stores separate difficulty, sound, bounded volume and bounded
per-layout record mappings for all nine games in the normal JSON/export.
Minesweeper persists best times, Tic-Tac-Toe persists cumulative player wins,
Colour Mastermind persists best attempt counts and Neon Relay persists high
scores through relay_difficulty, relay_sound_enabled, relay_volume and
relay_high_scores; Neon Jackpot uses slot_difficulty, slot_sound_enabled,
slot_volume and slot_high_scores for its highest bankrolls. Its nested
ArcadeSettings stores the shared wallet/progressive jackpot, its persisted
jackpot_progress remainder in fifths, permanent unlocks, completed challenge
IDs, refill eligibility and alert switches.
game.settings
asks the GTK
thread to save only when the active game's choice or record actually changes.
Import and backup restore replace Config.games as an object, so
_adopt_replaced_configuration() must also pass that new object to
GameManager.adopt_settings(); keeping the old reference would make later
records disappear into detached state.
tests/test_new_games.py pins registry parity, first-reveal safety, flagging,
compact mine densities, deferred result-screen difficulty, both Tic-Tac-Toe
geometries and AI levels, duplicate-safe Mastermind clues, Mini clue/results UX,
all supported key counts and every new Plus HUD. tests/test_neon_relay.py pins
boundary-to-boundary route generation, spark traversal, shield recovery,
difficulty speed progression, third-sector upgrades, Overdrive, Plus column
rotation/stasis and representative key/HUD rendering. tests/test_games.py
pins the manager's exclusive Plus-dial routing. tests/test_classic_games.py
pins exact per-game asset isolation and persisted settings for all nine;
tests/test_neon_jackpot.py pins its minimum geometry, adaptive reel layouts,
predetermined outcomes/braking, paid-unheld-loss-only non-chainable holds and
nudge, exact 20% progressive contribution, the 40-Spark reset floor,
the independent-spin return bound, difficulty assist probabilities, bolt bonus,
delayed paid-spin wager, fair anticipation, distinct reel impacts, tiered
celebrations, jackpot stages, expanded sounds, Plus controls, sprites and
representative 8/15/32-key and HUD rendering. Finally,
tests/test_invariants.py drives all nine dispatch paths through BASIC font
and shared-RENDER_LOCK checks.
GTK 4 has no tray API, so ui/tray.py publishes the icon directly on D-Bus with
org.kde.StatusNotifierItem plus com.canonical.dbusmenu, over
Gio.DBusConnection. It adds no dependency and works on COSMIC and KDE natively,
and on GNOME with an AppIndicator extension. The module creates no GTK widgets;
D-Bus handlers run on the GTK main thread, and every user action is still routed
back through GLib.idle_add.
The status icon is published as actual IconPixmap data at several panel sizes,
not only as the application theme name. TrayIcon locates the same SVG in a
source tree, Debian install, Flatpak or AppImage, rasterizes it with GdkPixbuf and
serializes it as network-order ARGB32. Before scaling, it finds the alpha bounding
box and removes only fully transparent outer padding, then fits that visible
rectangle inside each panel size without forcing it square. The published pixmaps
may therefore be rectangular: preserving the artwork's aspect ratio is what keeps
the status icon large without distorting it. This crop is local to IconPixmap;
the launcher and dock continue to use the original hicolor SVG. While those
pixels are available it leaves IconName empty because hosts are encouraged to
prefer the name, and COSMIC can replace a valid pixmap with a generic placeholder
when its theme lookup fails. Failure to locate or decode the SVG falls back to
the named icon instead.
Menu-item icons use the separate standard dbusmenu icon-data property. Open,
Profile, Games, Arcade, Stop and Quit carry distinct 16x16 coloured PNGs rendered
from the bundled MDI font. Individual game rows are deliberately text-only: their
price and unlock labels are already information-dense, and repeating one icon per
title makes the submenu busier without improving navigation. Never replace the
fixed icons with icon-name: the host desktop is not guaranteed to provide the
requested glyph, and source, Debian, Flatpak and AppImage installs must look the
same without a theme-specific dependency. _menu_icon_png() supersamples before
reducing to the published size, is cached, and holds the shared reentrant
RENDER_LOCK through Pillow drawing, MDI rendering and PNG encoding. Missing font
data or a PNG-encoding failure must omit only that item's icon-data; the text
entry and the rest of the menu remain usable. Hosts that ignore optional item
icons receive the same complete text menu.
TrayIcon.start() exports both objects, owns an
org.kde.StatusNotifierItem-<pid>-1 bus name and then watches the watcher name,
so the icon survives a panel restart and can be published before the status area
exists. is_supported() is only informational: app.py must start the icon
even when it returns False, because a session that is still logging in — the
normal autostart case — very often has no status area yet, and the name watcher
publishes the icon the moment one appears. RegisterStatusNotifierItem is called
asynchronously; it runs on the GTK main thread, so a synchronous call would
freeze the window whenever the panel is slow. Until the reply arrives the icon
counts as unregistered, which is the safe direction. The menu is Open, a
Profile submenu of radio entries, a Games submenu and Quit. Games
starts with Arcade — N Sparks, then lists every entry in games/catalog.py
alphabetically with its run price, unlock price or Neon Jackpot's Earn Sparks
role: Circuit Breaker, Colour Mastermind, Memory Match, Minesweeper, Mole Smash,
Neon Jackpot, Neon Relay, Pulse Memory and Tic-Tac-Toe.
While a game is active, Profile is disabled and that submenu retains the Arcade
balance above Stop [game name]; ItemIsMenu is true, so a plain click opens
it. menu_items() and
build_layout() are pure functions, which is what the tests exercise. Profile
entry IDs start at PROFILE_ID_BASE so they never collide with the fixed ones.
app.py subscribes to profile.changed, game.state and arcade.state and calls refresh(),
which bumps the revision and emits LayoutUpdated; those events cover adding,
renaming, deleting and switching a profile as well as both game-session edges.
Config.close_action is tray (default) or quit.
LinuxStreamDeckApp.hides_on_close() is the single decision point and requires
all three of: no explicit quit in progress, the configured tray action, and a
registered icon. A missing or unregistered status area therefore always
falls back to quitting, so the window can never vanish with no way back.
MainWindow hides instead of closing by returning True from close-request;
the window is only hidden, never destroyed, so Adw.Application keeps the
process alive and the whole session — including an unsaved key draft — survives.
Hiding deliberately asks nothing.
Quitting and switching profiles from the icon go through MainWindow.request_quit()
and request_profile(), which reuse the normal unsaved-change guard and present
the window first when a confirmation is needed, because that dialog is modal to
it. app.quit() sets the explicit-quit flag before Gtk.Application.quit() so
the hide rule cannot intercept it, and _on_shutdown stops the icon before the
controller, deck and OBS client.
core/autostart.py owns the XDG entry at ~/.config/autostart/<APP_ID>.desktop
(overridable with LSD_AUTOSTART_DIR in tests). Its state lives in that file and
never in config.json: an exported configuration must not enable autostart on
another computer, and the desktop's own startup tool may disable the entry, which
is_enabled() honours by checking both Hidden and X-GNOME-Autostart-enabled.
The entry runs the installed console script when there is one, falling back to
the current interpreter, and always adds the --hidden flag that
strip_hidden_flag() removes before GTK parses argv. A hidden start only skips
presenting the window when the icon actually registered.
A KIND_FOLDER key opens its own grid instead of running anything, so related
actions group without spending a page. Its contents live in KeyConfig.folder
(a Folder), its name is the key's own label (folder_name() falls back to
DEFAULT_FOLDER_NAME) and its icon is the key's icon, defaulting to
DEFAULT_FOLDER_ICON. Page and Folder both extend the plain KeyGrid mixin,
which supplies key(), set_key() and configured_keys(); the mixin declares no
dataclass fields, so both keep their own field order and JSON layout.
Three rules make the shape safe:
folderiscompare=False. Its contents are edited by navigating inside and saved there, so the editor's unsaved-change comparison must not read an edit made inside a folder as a pending edit of the folder key itself.- Slot
FOLDER_BACK_INDEX(0) is reserved. Inside a folder it always renders the Back key (_back_spec, showing the folder name), it is never stored, andis_reserved_key()blocks selecting, dragging, dropping, pasting, clearing and swapping it. The physical deck can therefore never enter a folder it cannot leave. Folder contents keep the deck's own numbering, so no index shifts. - Nesting stops at
MAX_FOLDER_DEPTH(5)._folder_contents()drops the contents of anything deeper while keeping the key itself, so one hand-written branch cannot cost the whole configuration or make loading recurse without end.can_add_folder()andfits_here()apply the same limit to new folders and to pasted or imported subtrees.
DeckController._folder_path is a tuple of key indices from the page root, and
container resolves it into the grid on screen, self-healing back to the page
when a step stops resolving. It is view state, never configuration: opening
or closing a folder saves nothing, and _leave_folders() returns to the page
root on every page, profile and configuration change, because those indices would
otherwise address a different grid. folder.changed carries path and the
folder_trail() of (path, name) pairs that the window renders as a clickable
breadcrumb.
On the virtual deck a folder opens by double-clicking its key. MainWindow
times that from the button's own clicked signal (_last_key_click plus the
pure _completes_double_click()), never from an extra Gtk.GestureClick on the
key button: a GtkButton claims the primary-button sequence on press, which
cancels any other primary gesture on the same widget, so such a gesture never
reaches n_press == 2. The secondary-button context menu on those same buttons
is unaffected, because GtkButton never claims that sequence. The second click
is consumed, so a third one starts a new pair instead of reopening the folder
just entered. A drag held over the key opens it too — see Spring-loaded
folders — which is the only way to put a key inside one without cutting and
pasting it.
Transient state therefore keys on RuntimeKey — (profile, page, folder path, key index, deck id) — so the same index inside a folder or on another device is
a different key from the one on its page, and clocks inside a folder keep running
while you are elsewhere.
_discard_folder_state() drops every toggle, clock, timer sound, execution
control and gesture under a folder slot when that folder is cleared, pasted over
or moved; editing the folder key itself keeps them, so renaming a folder does not
reset the timers inside it. Pressing a folder key calls open_folder() directly
and never reaches an action worker.
Anything walking a key's actions or assets must recurse with _walk_keys() /
_walk_raw_keys(): _key_configs(), both bundle exporters (through the shared
_bundle_key_icons() / _bundle_key_audio()), import_key_bundle() and
rename_page() all do. Missing one silently loses a folder's nested audio,
icons or nav.page.go rewrites.
The editor offers Folder as a key type, hides it once the depth limit is
reached, and locks the whole type list while the stored key is a folder that
holds keys, so its contents cannot be replaced by switching type. Clearing,
pasting over or importing onto a non-empty folder asks for confirmation first.
The AI catalogue never exposes folders: ai/service.py still restricts proposals
to single, multi and toggle.
KeyConfig.font_size and KeyConfig.font_size_off hold one of the IDs in
KEY_FONT_SIZE_CHOICES: "" / Automatic, xs / Extra small, s /
Small, m / Medium, l / Large, xl / Extra large. Like an
empty icon, the empty value is an inheritance marker, not a missing value:
renderer._label_font_size() then derives the size from the key height
(max(10, h // 6), the historical automatic size). Every named ID is a divisor
of the key height in FONT_SIZE_DIVISORS, so a chosen size keeps its proportion
on any key geometry, and no resolved size drops below MIN_FONT_SIZE.
KeyConfig.from_dict() normalizes the stored value and falls back to automatic
for anything unknown, so old configurations and hand-edited files stay loadable.
The controller passes font_size through every _key_spec variant; a toggle's
OFF state uses font_size_off or font_size, mirroring how label_off falls back
to label. The badge keeps its own fixed size, and because the label box grows
with the font, the icon and any centered clock value shrink to fit.
KeyConfig.text_color / text_color_off follow exactly the same inheritance
rule: empty means the renderer's own TEXT_COLOR, so a key that never chose one
renders identically and clearing the choice restores inheritance rather than
freezing today's default into the configuration. _text_color() validates the
stored value to #rgb / #rrggbb at load, because a hand-edited file would
otherwise reach Pillow mid-render, on a worker thread, once per repaint.
compose() resolves it once into ink and uses it for the label, a centered
value such as a clock, and the badge. The badge is included on purpose:
leaving it white would preserve the very problem the setting exists for, since a
pale background hides a white badge exactly as it hid the label.
AppearanceBox blocks its own notify::rgba handler while showing the
inherited colour. Without that, displaying the default is indistinguishable from
the user picking it, and clearing the choice would immediately write the current
default back into the key.
move_page(), duplicate_page() and duplicate_profile() all shift what a
stored index means, so each clears the transient toggle/clock state and the undo
history exactly as delete_page() does. What they must not disturb is
nav.page.go, which targets a page by name: moving a page changes no name,
and Profile.clone() keeps every page name so a copied profile's navigation
points inside the copy rather than back at the original. unique_name() supplies
the "Live copy", "Live copy 2" sequence — duplication is the one operation where
a clashing name is guaranteed rather than possible, so the user is never asked.
_shifted_index() keeps the page the user is looking at on screen after a move;
the arithmetic is worth keeping because every off-by-one there silently switches
the visible page.
_apply_entry() restores an undo entry and returns the entry that reverses it,
which is what makes undo and redo mirror images: a change can be walked back and
forward any number of times without the two stacks drifting apart. _redo is
bounded by the same UNDO_DEPTH, cleared by _record_undo() (a new change
branches away from the undone future) and by forget_undo(), and gated by
can_redo() on the same container check as can_undo().
device/layout_sheet.py composes a whole profile into one printable PNG: every
page, then every folder inside it, with each key drawn by the real compose()
and captioned with what it does. The caption is the point — an icon-only key is
unidentifiable away from the hardware. It takes columns/rows from the
connected deck rather than assuming 5x3, and it deliberately never consults live
OBS feedback, or the same profile would render differently every time.
_draw_caption() trims against the room left once the ellipsis is paid for.
Testing whether text + "…" fits on each pass never succeeds: by then the bare
text fits too, the loop ends, and the caption is left cut mid-word with nothing
to show it was shortened.
Alongside the single-step config.json.bak, rotate_backups() keeps up to
BACKUP_KEEP timestamped copies under CONFIG_DIR/backups. BACKUP_MIN_INTERVAL
is the whole point of it: saves are frequent — a page switch, a brightness change
and every key save is one — so without a floor between snapshots a burst of saves
would push every older state out of the history at exactly the moment it becomes
worth having. import_bundle() and restore_backup() pass force=True, because
replacing the entire configuration is precisely when the previous one is worth
keeping. Rotation never raises: losing a backup must not stop the save it
precedes.
Two details there are load-bearing and easy to undo:
- The history is ordered by modification time, not by name. Two snapshots
can land in the same second — a restore forces one immediately after the save
before it — and
_free_backup_path()disambiguates with a-NNsuffix. Any such suffix sorts before the plain name (-precedes.), so name order would report the older copy as the newest. - It copies with
shutil.copy, notcopy2.copy2preserves the source's timestamp, which would both break that ordering and label every backup with when the configuration was last edited rather than when the copy was taken.
describe_backup() reads each file to report its profile, page and key counts,
because a row showing only a filename cannot tell you which copy you want.
restore_backup() refuses any path outside BACKUP_DIR, keeps the keyring
password of this computer exactly as importing does, and goes through
_adopt_replaced_configuration() — the same runtime reapplication as an import,
since the whole configuration has just been replaced.
Renaming a scene in OBS is the one failure this application never reported: the
key stays where it is, with its icon, and silently does nothing when pressed.
core/references.py finds those keys, and Param.choices_source is what makes
it possible — a parameter that fills from scenes holds a scene name, so the
metadata already says what points at what.
It never runs by itself, and the scope is where you are. Switching scene
collection replaces every name at once, so a checker that ran on its own would
report the whole page as broken on every switch, and be ignored by the time it
mattered. The user starts it, standing in a page or a folder, and therefore
reads the result in the right context. check_references() walks the current
grid plus everything nested inside it, with the page's dials in scope only at
the page root, since dials belong to the page.
The report names the collection it compared against, because obs-websocket can only list what is loaded: there is no way to ask what an unloaded collection holds without switching to it, so no other answer is honest.
Four rules keep it from crying wolf, which is the only way this feature fails:
Param.advisorymarks a value that is never sent to OBS. The audio actions'sceneonly narrows the editor's input list, so a rename leaves it stale but breaks nothing, and reporting it would be a false alarm.- Unknown is not missing.
_ObsNames.available()returnsNonerather than an empty list when it cannot judge, andcheck()skips those. A source whose scene is itself gone is unknowable, and reporting both would count one rename twice and offer a fix that could not work. - Each list is fetched once per check. A page can hold a dozen references to one scene, and every lookup is a request through the one serialized connection.
- The report says how much it looked at. A checker that only speaks up on failure never tells you it ran.
Repointing rewrites parameters across more keys than the undo history is scoped
to hold, so the safety net is a forced backup instead — which is why
apply_reference_fix() saves the configuration first when none is on disk yet:
rotate_backups() copies that file, and without one the promise the dialog
makes would silently be false.
obs.scene_switch and obs.scene_preview take a preview parameter — off,
once a second, or twice a second — that turns the key into a live thumbnail of
what that scene is showing. It is off by default and chosen per key on purpose:
every capture makes OBS render and encode the scene, on the machine that is
also encoding the stream, so the user decides which keys are worth that. The
point is not decoration. A scene whose capture died renders black, which is
visible before cutting to it rather than live on air.
feedback() gained an image key for it, carrying raw JPEG bytes, which
_single_spec passes to compose().
Four things there are load-bearing:
OBSClient.source_thumbnail()caches per source and size, and takes the staleness the caller will accept, so a key at two frames a second and one at one frame a second share a cache without either being held to the other's rate. Six keys on one scene cost one capture._thumbs_pendingstops a render burst from starting the same slow capture twice, and the request is made outside the cache lock so no other key's feedback queues behind it.- A failure keeps serving the last good frame, and unreadable bytes fall all the way back to the ordinary key. A live picture is decoration over a key that must keep working; it may never leave a blank square.
- The label needs
_scrim(), and its ramp is not linear. A straight gradient is still nearly transparent where the top of the glyphs sits, so a pale scene left the first row of the label at almost its own brightness — measured at 226 of 255.SCRIM_RAMPbelow 1 darkens early and then flattens, which brought that to 116 while leaving the picture above untouched. - The active state changes shape. Lightening the background is invisible over a photograph, so a previewing key marks the live scene with an accent border instead.
web.request is deliberately the widest action in the catalogue. A great many
single-purpose plugins in the Stream Deck ecosystem are one REST call with an
icon on top — home automation bridges, dashboards, webhooks, CI servers, uptime
monitors — so an endpoint plus a value read out of its answer covers all of
them at once, and covers the next one nobody has written yet.
core/webrequest.py holds the network side; web_actions.py is the action.
Four things about it are load-bearing:
feedback()never performs a request. It runs on the render worker, so a round trip there would hold that worker for the latency of the internet on every repaint — the same reasonTwitchClient.channel()performs none.cached_value()answers from the cache and schedules its own fetch on a small executor of its own;_pendingstops a burst of renders from starting the same fetch several times.min_ageis what keeps a repaint from being a request. Repainting is what makes the key ask again, and_live_loopis not the only thing that repaints a key — anobs.statestorm or a page change does too. The value is only asked for again once it is older than the interval that key chose.- Both staleness bounds do different jobs, exactly as Twitch's do. The
chosen interval is when a value is worth replacing;
VALUE_STALE_SECONDSis when it stops being shown at all, so a blip keeps the last good reading and a sustained outage blanks the key rather than leaving a number that stopped being true. A failed fetch therefore keeps the previous value rather than wiping it. - A response is data from outside. Only
httpandhttpscan be reached (a typo must not be able to open a local file), the body is capped before it is read, andextract()reduces it to one short string — answering""for anything that does not resolve rather than raising, because a key showing nothing is a far better failure than a key that goes red every second.
It is in BLOCKED_ACTIONS beside sys.command and obs.raw, and for the same
reason: each can reach something outside this application on the strength of a
string, and somebody reviewing an AI proposal cannot be expected to audit a URL.
webrequest.shutdown() runs last in _on_shutdown, after the controller,
since the controller's workers are what ask for a value.
webrequest.background() is public for the same reason. Anything drawing a key
has this module's problem — feedback() is on the single render worker — so
one executor serves them all rather than each growing its own thing to remember
to stop. core/keylight.py is its other user. It answers whether the work was
accepted, so a caller can release its own marks when it was not.
The third service, and the smallest. web.request could already call these
endpoints, so this exists for the one thing it cannot do: fill a dropdown
with the entities that server actually has. Typing light.kitchen_ceiling_2
from memory is where the real failures live — a key that saves cleanly, looks
configured and does nothing when pressed.
Only base_url lives in Config. The long-lived access token goes to Secret
Service through HomeAssistantTokenStore, exactly as the OBS password, the AI
provider keys and the Twitch tokens do, so it never reaches config.json, its
backup or a .lsdconfig export. The address does travel, unlike the Twitch
tokens and unlike autostart: a configuration moved to another computer in the
same house still points at the right box.
There is deliberately no login flow. Home Assistant's is built around a browser redirect to a registered address, which a desktop application on somebody's LAN cannot offer, and asking for the account password instead would be worse than asking for a token the user can revoke on its own.
Five things are load-bearing:
cached_state()performs no request, likeTwitchClient.channel()andweb.requestbefore it. It runs on the single render worker, and the server is a box in the house.STATE_TTLandSTATE_STALEare the same pair of bounds: a blip keeps the last state, an outage blanks the key, and a state that never arrived draws nothing rather than off — a light that looks switched off while it is lighting the room.- A service call is addressed to the
homeassistantdomain, not the entity's own, which is what makes one key work on a light, a switch, a fan and a media player without knowing which it is. - "On" is an exclusion list, not an inclusion one.
OFF_STATESnames what counts as off and everything else is on, because no list of on states can be complete: a media player isplaying,paused,idleorbuffering, a vacuum iscleaning, a cover isopening. The first version enumerated the on ones and read an idle Chromecast as switched off. Home Assistant's own media player toggle settled on the same rule in 2025. Unknown counts as off, so a key never claims a device is on because nothing answered. - A toggle is resolved here, never sent as
homeassistant.toggle._resolve_toggle()reads the state and picksturn_onorturn_offfromis_on(). The generic service reads a media player sittingidleas off and turns it on again, so a Chromecast key toggled on and never off. A scene or script is run rather than toggled — it has nothing to toggle — and that short-circuit is load-bearing for a second reason: a scene's state is the timestamp it last ran, whichis_on()reads as on, so without it a scene that had ever run would resolve toturn_offand do nothing. - The answer to a service call matters as much as the call. Home Assistant
accepts
turn_onfor any entity and returns 200 whether or not the device can do it — a Chromecast that cannot be woken is the case that found this — so a key checking only for an error reported success while nothing happened. The REST API hands back the states that changed while the service ran, and_new_state()reports the target's. An empty list is deliberately not called a failure: an entity already on does not change either, so the key states the fact and leaves the verdict to whoever pressed it. _clean_url()accepts the three shapes people paste. With a trailing slash or an/apisuffix left on, every path becomeshttp://box:8123/api/api/states, which fails with a 404 that says nothing about what went wrong.- A refusal becomes a sentence.
_explain()turns a 401 into "create a new token" and a 404 into "that address was not recognised"; the status code alone sends somebody hunting. - Listing entities also updates every state it carried. The answer holds them all, so refreshing the editor's dropdown is a free update of everything a key on that page might be drawing.
A press shows its result at once, without a round trip. The service call's
own answer carries the state the entity ended in, so act() stores it rather
than dropping the cache: without that the key blanked after every press and
waited for a refetch, which is exactly the gap in which somebody presses again
thinking it failed. When the server does not say, the read-back happens
on the action worker, so the key keeps showing RUN until the answer is in
instead of going blank mid-way.
A state that really changed repaints the deck at once. _remember() calls
on_change — wired in app.py to controller.refresh — but only on a real
change: most polls find the same value, and refreshing per poll would redraw
every key on the page for nothing. Without it a light switched from a phone
took up to the full live-loop interval to show on the deck.
Both HA actions join _live_loop. ha.state on its own interval,
ha.switch on the fixed HA_SWITCH_REFRESH_SECONDS. Without that second one
the switch key fetches its state in the background and then nothing ever
repaints it, so the key never shows whether the entity is on — and it has to
notice a light somebody turned off from their phone anyway. Its on state is
coloured, not merely active: lightening alone rendered almost
indistinguishable from an idle key at 96 px, and telling on from off at a
glance is that key's whole job.
Both actions set requires_home_assistant, the third service in
_action_blocked() beside OBS and Twitch, so a key that cannot work fades
rather than looking idle. ENTITY_TTL exists because the editor fetches that
list on the GTK thread while building a row, and a house has hundreds of
entities.
sys.stats shows CPU, memory, GPU, temperatures, network throughput and free
disk space, all read from /proc and /sys by core/sysstats.py. It lives in
System rather than under OBS because a key showing GPU temperature has
nothing to do with OBS and must not have to be looked for there, and it shares
STAT_METRICS's table shape: one entry per measurement rather than a branch in
three methods.
The two overlapping metrics stay in obs.stats as well. Moving them would
silently change what every key already configured with them shows, and a
duplicate dropdown entry costs nothing next to that.
Six things here are load-bearing, and each is a way of being wrong that looks right:
- Memory used comes from
MemAvailable, neverMemFree. Free memory on a healthy Linux box is nearly zero because the kernel spends it on cache, so a key built onMemFreereads 98 % on a machine doing nothing at all. - Throughput is a difference between two readings, like
cpu_percent(), so the first call answers None rather than an average since boot. A counter that went backwards — an interface reset or replaced — counts as no traffic, since reporting that jump as a rate shows an absurd spike. - The temperature is the processor package, not the hottest core. One
core spiking is normal and says nothing. The package input is found by label,
and the fallback matters:
sorted()putstemp10_inputbeforetemp1_input(because0sorts before_), so a version that simply took the first labelled input looks correct on a tidy fixture and reports a core on real hardware. - A measurement the machine does not report answers None, and the key shows a dash. A zero is a claim: it says the GPU is idle rather than absent. An integrated chip has no busy counter and plenty of desktop boards have no package sensor.
- A metric with no threshold is never painted, not even green. Green reads as "checked, and fine", which is a claim nothing measured.
- Throughput is shown in bits. A connection is sold in megabits, so bytes invite exactly the wrong conclusion about whether a line is keeping up.
- The interface dropdown shows what the device is, not its name.
enx00e04c3676ebis derived from a MAC address and tells nobody anything.interface_label()prefers the USB product string the device publishes ("Realtek USB 10/100 LAN"), falls back to the kind of link read from the kernel — thewirelessdirectory or the ueventDEVTYPE, never the name — and keeps the interface on the label either way, since two adapters of one model would otherwise be identical in the list. A PCI card publishes only numeric vendor and device ids, which would need a hardware database to read, so those get the link kind. The key still stores the interface name: it is what/proc/net/devis keyed by.
nvidia-smi is a process (measured at ~30 ms), so its reading is shared for
GPU_INTERVAL exactly as the disk one is: a page of keys must not each spawn
one. AMD is read from gpu_busy_percent in sysfs instead, and an integrated
Intel chip reports neither, which is why every field is optional.
The other half of the Elgato desk with no Linux software at all, and whoever
owns a Stream Deck very often owns a Key Light. They run an unauthenticated
HTTP server on port 9123 answering GET/PUT on /elgato/lights, so this
needs no account, no cloud service and no vendor library.
Two things about the protocol are silent when wrong:
- Temperature is in mireds, not kelvin. The device takes 143 to 344, which
is
1000000 / kelvin, so a higher number is a warmer light. The actions speak kelvin andkelvin_to_device()converts.LightTemperaturetherefore inverts twice — "warmer" is fewer kelvin, and the device's unit runs the other way again — and both inversions are deliberately in one place. The two ends are written down rather than derived:1000000/7000is 142.86 and the firmware's floor is 143, so the formula is off by one at exactly the end a "coolest" key lands on. - A
PUTreplaces the fields it names.apply()sends only what it was asked to change; resending a whole light object would push back whatever was last read, turning a light on again because a brightness key remembered it that way.
discover() shells out to avahi-browse -rpt _elg._tcp, whose parseable form
is =;iface;protocol;name;type;domain;host;address;port;txt. Only resolved
(=) IPv4 lines are kept: a link-local IPv6 address cannot go in a URL without
a zone index, so a light with no published host name must not fall back to one.
A key stores the published host name, since an address comes from DHCP and
changes; avahi resolving it is guaranteed by having found the light at all.
Without avahi-utils discovery answers nothing, the field stays a plain text
entry, and an address can still be typed by hand.
Three bounds matter. DISCOVERY_TTL exists because the editor fills that
dropdown on the GTK thread and avahi-browse -t takes about a second even
when nothing answers. STATE_TTL and STATE_STALE are the pair web.request
and the Twitch snapshot both use: a light behind a slow access point keeps its
last state rather than flickering, and one that stopped answering goes unknown
rather than being drawn as off — a light that looks switched off while it is
lighting the room is worse than a key showing nothing.
obs.stats draws a live measurement — dropped frames, bitrate, congestion,
stream or recording time, free disk, OBS and system CPU, OBS memory, FPS,
skipped render frames — through the display feedback the clocks already use.
STAT_METRICS holds each one's label, reader, formatter and thresholds in a
single table, so adding a metric is one entry rather than a branch in three
methods.
"CPU usage" is two different measurements and both must say which.
GetStats.cpuUsage is the OBS process, the number OBS's own Stats window
shows; a system monitor shows the whole machine. Labelling the first plainly
"CPU usage" makes anyone comparing the two conclude, reasonably, that the key is
broken. So OBS's is "OBS CPU usage" and core/sysstats.py reads the machine's
from /proc/stat as "System CPU usage". That reading is a difference between
two samples, so the first call after start-up answers None rather than
reporting use since boot, and MIN_INTERVAL keeps a second caller from
measuring noise across a few microseconds.
A metric that does not come from OBS sets needs_obs = False, which both
Stats._read() and DeckController._live_interval() honour: a kernel reading
keeps working, and keeps being repainted, while OBS is closed. That is also why
_live_loop() has no obs.connected guard of its own. Such a reader is handed
the key's own parameters in place of an OBS sample, since what it needs is
configured rather than reported.
Free disk space is a kernel reading for a reason of its own. OBS reports it
as GetStats.availableDiskSpace, but only while OBS is running, and "have I got
room to record" is asked before opening it at least as often as during a
session — so the key that answers it must not go blank with OBS. disk_free_mb()
reads the filesystem directly and client.py deliberately no longer carries
availableDiskSpace into the sample, so there is one source rather than two that
can disagree.
Which filesystem is asked comes from the key's own disk_folder parameter
(kind="file", directory=True), blank meaning the home folder. It is asked
rather than taken from OBS's recording path on purpose: a key that measured home
with OBS closed and the recording drive with it open would silently be showing
two different numbers, which on a warning key is worse than showing nothing.
disk_folder() resolves blank and ~; a folder that does not exist answers
None rather than being walked up to its nearest existing parent, because an
unmounted recording drive would then report the root filesystem's free space.
Readings are cached per folder for DISK_INTERVAL and the cache is bounded by
DISK_CACHE_LIMIT, since a page can hold several keys watching one drive.
Percentages go through _percent_text(), which keeps one decimal below 10.
Whole numbers threw away most of the information exactly where these values
live: an OBS process at 1.4% and one at 0.6% both printed "1%".
Two things about it are unlike every other action:
- Its value changes with nothing happening. No bus event announces a rising
frame counter, so
_live_loop()repaints it on a clock — the same loop that drives live scene previews, since they are the only two kinds of key with that problem._live_keys()returns each visible one with the interval it asked for, and the loop ticks atLIVE_TICK_SECONDS, which must stay at least as fast as the quickest rate any of them offers. That thread submits to the render executor, so it is joined beside the activity thread and before that executor stops. Only a single-action key ever qualifies: feedback is resolved for a key's own action, never for a step inside a list. - Several keys can ask at once.
OBSClient.stats()caches one sample perSTATS_INTERVALbehind its own lock — never the request lock, which would serialize the whole client behind a display refresh. Without the cache, a page showing six statistics keys would fire eighteen websocket requests per repaint, through the one serialized connection, competing with the feedback of every other key.
Bitrate is derived, not reported: OBS only exposes total bytes sent, so the rate exists solely as a difference between two samples. The first sample after connecting or going live has nothing to compare against and reports nothing rather than an absurd spike from a counter that started at zero.
neighbour_index() walks the deck's own numbering, so left and right lead from
the end of one row into the start of the next — the grid is one sequence of keys
drawn in rows, not a set of independent rows. Up and down stop at the edges;
wrapping them would throw the focus to the far end of the deck.
Focus is not selection. Selecting runs the unsaved-change guard, so moving
the focus with an arrow key would raise a dialog per key press; activating a key
still selects it. _on_grid_key_pressed() returns True even at an edge,
because falling through hands the focus to GTK's own directional search and
leaves the grid entirely. It is a scoped, bubble-phase Gtk.EventControllerKey
on the grid — not the broad Gtk.EventControllerLegacy hook that §5.15 forbids.
The window's content is a Gtk.Paned: the grid box on the left, the editor on
the right, with a handle the user drags. The width they leave it at is
Config.editor_width, clamped to MIN_EDITOR_WIDTH / MAX_EDITOR_WIDTH on
load and serialized like brightness.
A drag is the only thing that may move it. Before the pane, the panel had no
hexpand of its own, so it inherited one from its contents — and its Save
button hexpands while the whole button row is hidden by clear(). The panel
stopped expanding whenever no key was selected and started again the moment one
was loaded. The grid box went from receiving all the spare width to half of it,
and since it is halign=CENTER it slid left by a quarter of that: 247 px
measured at 1920, against a 96 px key. The pointer ended up two keys from the
one it had just pressed, so the second click of a folder's double-click landed
somewhere else. self.editor.set_hexpand(False) is what fixes that, and it has
to stay explicit: any widget whose expand flag can follow its contents will
renegotiate the layout under whatever the user was pointing at.
Four details:
- The deck is what stops the handle.
shrink_start_childandshrink_end_childare both False, so neither side may go below its own minimum — and the grid box's minimum is the key grid. The cap is therefore the deck itself rather than a number kept somewhere that could drift from the model connected. resize_start_childalone. A window resize is absorbed by the grid area while the panel keeps the width it was given, which is what makes a remembered width mean anything on a window that opens maximized.- The position is restored once, and it needs a retry. A pane position is a
distance from the left edge, so it cannot be computed until the pane has a
width — and it has none at
map, because allocation comes afterwards. HenceRESTORE_TRIES, and hence the bound on it: an unbounded retry would spin, and the honest fallback is to open at the minimum and be dragged once. It runs once becausemapfires again when the window returns from the status area, and re-running it would discard a width set since. - The drag is saved when it comes to rest.
notify::positionarrives per pixel of pointer movement and every save is a file write plus a backup rotation, soEDITOR_WIDTH_SAVE_MSdebounces it. A position change arriving before the restore is ignored: GTK assigns the pane one of its own during the first allocation, and storing that would overwrite the remembered width with a default nobody picked.
The panel's scrollbar is not an overlay one. set_overlay_scrolling(False)
in EditorPanel. An overlay scrollbar is drawn over the content instead of
taking width of its own, which put it on top of the right-hand end of every row
— the arrow of each dropdown, the action picker's search button. That is fine
over a document, where the text simply continues behind it, and wrong over a
column of controls whose last few pixels are the part you click.
Multi and multi-toggle invocations, plus single actions with
running_feedback = True, are counted from queueing through completion, keyed by
the device-scoped RuntimeKey. While the count is nonzero, that key shows a RUN badge
and a subtle slow blue breathing halo. Repeated presses increment the count; for
restartable sequences, canceled running or queued invocations remain counted
until they finish unwinding, so feedback stays visible across the replacement.
Toggle keys keep their ON/OFF state underneath the temporary running feedback
and restore the appropriate badge when the final invocation completes.
Action execution and rendering use separate ThreadPoolExecutor instances. Two
long Wait actions may occupy both action workers, but must never prevent a key
image or activity pulse from rendering. The activity thread alternates the pulse
phase and asks the render executor to refresh only busy keys on the active
profile/page; activity belonging to another profile or page must not leak into the
current view.
Controller shutdown order is deliberate: set the stopping signal, stop and clear
the clock runtime/completion sounds, shut down the action executor, shut down the
timer-notification executor, join the activity thread, then shut down the render
executor. Wait observes both the stopping signal and per-invocation cancellation
through its ActionContext, so shutdown and same-key replacement can end it
promptly. Do not merge the executors or reorder teardown in a way that lets
actions, clocks or activity submit work after their destination executor has
stopped.
Three separate silences used to leave someone pressing a key and watching nothing happen. All three are now answered on the deck itself, because that is where the user is looking.
A failing action marks its own key. _note_failure() records the key and a
deadline in _failed, and _mark_key_state() turns that into failed=True
plus the ! badge on whatever spec the key would otherwise have produced.
renderer.compose() draws it as a border, last, over artwork and live
previews alike — a background change is invisible on a key carrying a scene
thumbnail. The mark is expired by the activity thread, not by a timer of its
own: that thread already pulses running keys, so _busy_loop() gained
_expire_failures() and now also stays awake while anything is marked. It must
keep repainting the keys that just expired even on the tick where it goes back
to sleep, or a mark stays on screen until something else repaints the key.
The mark is transient state keyed by RuntimeKey, so it is dropped everywhere
the others are: editing, clearing, pasting, swapping, discarding a folder's
subtree and every page/profile change. A run still in flight keeps its RUN
badge, because that is the more useful message while it lasts.
The reason this exists rather than a status message: _flash_status() shows a
message for five seconds in a window that close_action = tray normally keeps
hidden, and the traceback went to a stderr that a session autostart discards.
A key that cannot work does not look idle. Action.needs_obs says whether
an action can do anything without the connection, and
Action.requires_obs(params) lets a key answer per configuration — obs.stats
overrides it, since free disk space and system CPU come from the kernel.
_mark_obs_dependency() in obs/actions.py sets the flag from the category
rather than on each class, so an action added later cannot forget it.
DeckController._unavailable() fades a key only when every action of it is
blocked — by a missing OBS connection or, since _action_blocked() also checks
Twitch, Home Assistant and Action.is_available() (see The unavailable fade
covers services and local targets below), by any of those: one that mixes a
blocked action with a working local one still does half its job, and an
unregistered action is unknowable, so neither is faded.
compose(unavailable=True) blends the finished key toward black last, so
nothing on it can escape the fade and look usable. The keys come back on
obs.state, which the client emits on connect and disconnect alike.
A list can stop at the first failure. KeyConfig.on_error is
ON_ERROR_CONTINUE (the default and the old behaviour) or ON_ERROR_STOP, and
_stops_on_error() carries it into _submit_steps / _run_steps. Continuing is
right for a list of independent things done together; stopping is right when the
list is a recipe, because "switch scene, wait, start recording" must not record
the wrong scene after the switch failed. The message says which happened, in one
status emit rather than two — a second would immediately overwrite the first.
Only list kinds offer it (ERROR_POLICY_KINDS in ui/editor.py): a single
action has nothing left to abandon, and _on_error() normalizes anything
unknown back to continuing, so no stored key changes behaviour by being loaded.
The log is a file. CONFIG_DIR/linuxstreamdeck.log, rotated by
_add_file_logging() at LOG_MAX_BYTES with LOG_KEEP older copies, and
reachable from the menu as Open log file. Failing to open it must never stop
the application: a read-only home is a reason to lose the log, not the deck.
obs.preflight answers, on the deck, the question asked in the minute before
going live. core/preflight.py holds the checks; obs/actions.py drives them
and paints the board; ui/preflight.py carries the sentences.
Nothing here has a side effect and nothing here overclaims. Those are the two rules, and both were arrived at by measuring rather than by reasoning:
UNCHECKEDis a result, not a gap, ranked aboveOKinSTATE_ORDER, always named inReport.summary(), drawn with its own colour, a?badge andunavailable=Trueso it fades. Several checks can only be answered on the machine OBS runs on (is_local()), and a check that was quietly absent would look exactly like one that passed. There is deliberately no verdict word: a board is honest, "READY" is a promise this cannot keep, and the first time something broke with everything green it would be worthless for ever after.- Every
detailstates its own limit, because a key carries two words and the dialog carries the sentence.
Audio is judged on the mute state, not on the level. muted_inputs()
answers a fact; a level does not. OBS meters read before the mute, measured
live at -49.8 dB on an input that was muted, so a level never meant it would be
heard. And silence is not a failure: someone running a pre-flight is usually not
talking, so treating a quiet room as a dead microphone would cry wolf on nearly
every run. Muted is FAIL, silence with nothing muted is WARN telling you to
say something and try again.
Audio levels use a second, short-lived EventClient. OBSClient.measure_audio()
opens one subscribed to Subs.INPUTVOLUMEMETERS — a high-volume subscription
the normal connection deliberately never asks for — listens, and closes. That
separation is not tidiness: the second socket only receives, so it makes no
request and can never touch the _lock that must serialize every request (§5.3).
Measured live: 20 events a second, exactly as the protocol documents. It returns
None rather than an empty mapping when no events arrived at all, and that
distinction is the check: meters arriving and flat means a dead microphone,
while no meters means the question went unanswered.
Cameras are checked against the kernel, never against the picture. The
obvious version — screenshot the source and call a black frame a dead camera —
was tried against a real setup and reported two of three working cameras as
dead. GetSourceScreenshot does render inactive sources, but at the moment
someone runs a pre-flight the program scene is a holding card, so a black frame
means nothing. Instead capture_sources() reads each V4L2 source's device_id
and held_video_devices() reads /proc/<obs-pid>/fd: on Linux OBS never
releases a V4L2 device when the source is hidden (there is no "deactivate when
not showing" for V4L2), so a healthy camera has its device open even with the
deck on a black scene. Resolve symlinks on both sides — a source is commonly
configured as /dev/v4l/by-id/... while the descriptor reports /dev/videoN,
and comparing them as strings fails a working camera. That is a real bug that
was caught live, and test_a_by_id_symlink_resolves_to_the_same_device pins it.
Comparing the deck's keys against OBS cannot be automated, so it is not.
obs-websocket can only list the loaded scene collection, so a key belonging
to any other one is indistinguishable from a key whose scene was renamed. On a
real configuration with nine collections and a folder per collection, the
automatic version reported 99 perfectly good references as broken — from the
page root and from inside each folder, since only one collection is ever
loaded. check_collection() replaced it: it states which collection OBS has
loaded, which is decidable, useful (with nine of them that is exactly what gets
forgotten) and judges nothing. check_references() stays as the deliberate
tool, and its docstring now says why nothing may call it on its own.
The stream key must never leave stream_target().
GetStreamServiceSettings returns it in the clear; only a boolean crosses into
check_stream_target(), whose signature is pinned by a test. The application
writes a log file now, so one careless line would put someone's stream key on
disk in plain text.
The board is a layer, not a device mode. DeckController.show_board() sets
_board, which _key_spec() consults first; the screen saver owns the deck and
needs a thread, a brightness and a wake press, and a second thing like that
would fight it. A press dismisses the board instead of running the key under
it, _clear_time_actions() drops it on every view change, and the action clears
it in a finally — a report left up hides every real key.
dismiss_board() is the one way it is put away, and the hold watches for it.
PreFlight._hold() polls board_active() beside its own deadline, so anything
that clears the board ends the hold: a press on the deck, closing the report
window, or leaving the page the report describes. Both halves are needed. The
window's close-request calls dismiss_board, or the deck kept showing a
report the user had read and closed for the rest of PREFLIGHT_HOLD — twelve
seconds of a deck they could not use. And the hold has to notice, or the key
goes on pulsing RUN over a report that is no longer there. The dialog must
return False from that handler: claiming the signal stops the window closing
at all.
sysstats.cpu_percent() is primed before the audio check on purpose: machine
CPU only exists as a difference between two readings, and the two seconds the
meters take is exactly the gap it needs.
The second service this application talks to. It is in the tree rather than behind some extension mechanism because a deck built around OBS is built for people who stream, and Twitch is where most of them stream: it is the other half of the core use case, not a peripheral integration.
The authorization is the device code flow, and that choice decides the rest.
Twitch offers it for clients that cannot keep a secret, which is exactly a
desktop application. There is no client secret to ship, no redirect URI and no
local web server listening on a port; the user reads a code off the dialog,
types it at twitch.tv/activate, and auth.poll_for_tokens() waits.
Three things about that flow are easy to get wrong and are pinned by tests:
- Twitch reports every device-flow outcome as an RFC 8628 error code in the
messagefield of a 400, never through the status code._is_pending()matches after_normalize()flattens separators and case, because the first build compared against the literal"authorization pending"while Twitch sendsauthorization_pending: the very first poll read a normal "not yet" as a refusal and abandoned the flow while the user was still typing the code.slow_downarrives the same way and means back off, not stop. Treating every 400 as pending loops forever on a revoked code; treating every 400 as fatal ends the flow the instant it starts. - An error code must never reach the user.
describe_error()maps the known ones to sentences and answersGENERIC_DEVICE_ERRORfor anything that still looks like an identifier, so a code Twitch adds later cannot leak either. Real prose — Helix answers in words, such as "Missing scope: clips:edit" — passes through untouched, because that text is worth showing. - Refresh tokens are single use. The one spent on a renewal is dead the
moment the answer arrives, and that answer carries its replacement.
_persist()therefore stores the new pair before it is adopted, and_renew_lockserializes renewals so two workers cannot spend the same token twice — the second would get a refusal that unlinks a perfectly good account. - A missing scope is also a 401, and renewing can never fix it.
_is_scope_error()keeps that case out of the retry path, or a key needing a permission that was never granted would spend a single-use refresh token on every repaint.
Disconnecting revokes the access token, and nothing else, and the dialog says
so. Twitch has no API to remove an application from the user's Connections
page — only the user can, at CONNECTIONS_URL — so the dialog reports what
actually happened plus a button to finish it there. Saying "disconnected" full
stop would be exactly the unearned reassurance the pre-flight board exists to
avoid, and it is checkable, because the application stays visible in Twitch's
own list.
revoke() must never be given the refresh token. It was, once, on the
reasoning that revoking an access token leaves the refresh token usable. RFC
7009 has an authorization server invalidate every token issued from the same
grant, and Twitch does: disconnecting tore down the whole authorization, so
someone who reconnected got a session whose EventSub subscriptions were revoked
with authorization_revoked twelve seconds later and whose new scopes were
refused with a flat 403. An access token expires by itself in about four hours;
a torn-down grant does not repair.
For the same reason the revoke is guarded by _revoke_if_still_gone(). It runs
off the calling thread so the dialog does not wait on the network, which means
it can land after somebody has reconnected — and revoking then destroys the
authorization they just granted. The account currently held is the one that
matters.
The Client ID is public and is meant to be distributed. The device flow
exists for clients that cannot keep a secret, and Twitch counts its rate limit
per Client ID per user, so one shared identifier cannot let one person
exhaust anyone else's budget. DEFAULT_CLIENT_ID holds this project's own
registered application; a user who prefers their own overrides it from the
dialog, and emptying it puts the dialog back into asking for one — which
_client_id_help() already switches its wording for. Tokens live in Secret
Service through TwitchTokenStore,
never in Config, config.json, its backup or a .lsdconfig export.
TwitchSettings holds only public, portable state: the Client ID, whether
automatic categories are enabled/paused and bounded Steam AppID → Twitch
category ID/name mappings.
TwitchTokenStore is synchronous, unlike SecretStore and ApiKeyStore.
Its caller is not the GTK thread: the client reads and rewrites tokens from its
own worker while renewing them, and a callback-based lookup there would need a
main loop it does not have. It stores the pair as one JSON item so a refresh
replaces both together. HomeAssistantTokenStore is synchronous for the same
reason — the client reads it from its own worker too — so there are now two
synchronous stores and two asynchronous ones, not one exception among three.
channel() never performs a request. It is called from feedback() while
a key image is being composed, on a render worker, so a Helix round trip there
would hold that worker for the latency of the internet on every repaint — far
worse than the OBS captures that already had to be cached. It returns what is
already known and schedules its own refresh on the client's single worker;
_channel_pending stops a render burst from starting the same refresh twice.
With no Twitch key on screen nothing calls it, so an unused integration costs
nothing at all.
Two staleness bounds do different jobs. CHANNEL_TTL (20 s) is when a snapshot
is worth replacing — Twitch aggregates viewer counts on its own side and does
not move faster. CHANNEL_STALE (90 s) is when it stops being shown at all: a
brief network failure keeps the last value, which is what makes a key feel
steady, while a sustained one blanks it, because a number that stopped being
true is worse than no number.
The category is a text field with live suggestions, resolved when the key is
pressed. A dropdown was the obvious choice and cannot work: Twitch has tens of
thousands of categories and the one a person streams is very often not in any
list short enough to offer, so a list would fail at exactly the case the action
exists for. Free text alone was not enough either — a typo was only discovered
when the key was pressed, live. Param.completion_source is the answer, and it
is a different thing from choices_source: the field stays a text entry and
still stores free text, so a key configured before the suggestions existed still
loads, while TwitchClient.search_categories() makes a real name easy to type.
_resolve_category() still runs at press time, and an exact name beats the
search ranking — searching "Doom" also matches "Doom Eternal", and taking the
first ranked result would quietly set the wrong game for anyone who typed the
exact one. What Twitch matched is reported back in the status message, so a
wrong guess is visible.
Automatic categories are an opt-in global policy, not key state.
twitch/auto_category.py::AutomaticCategoryManager listens to the immutable
obs.outputs snapshot and does no process scan or Helix mutation unless the
policy is enabled, unpaused, the account has channel:manage:broadcast, and OBS
reports streaming. Recording alone never qualifies. One daemon worker samples
core.steam.running_games() every four seconds, requires a selected AppID to
remain stable for twelve seconds, and updates through
TwitchClient.set_category_id() so an already-mapped transition performs no
search and cannot choose a ranked near-match.
Multiple games are deliberately asymmetric. One game is unambiguous; exactly one AppID newly appearing beside games already seen is the transition; an initial or otherwise ambiguous set leaves Twitch untouched. Closing the game never clears the category. A missing mapping runs one strict exact-name Twitch search: an exact result is saved automatically, while no exact result becomes a pending choice in Game mappings and is not searched again during that game session. Network failure is retryable with bounded exponential back-off and must never be turned into a pending/manual choice.
Mappings persist both the Twitch ID and display name below TwitchSettings and
therefore travel in .lsdconfig; tokens still do not. UI mutations and exact
matches save on the EventBus-dispatched GTK thread. The mapping editor reuses
the off-thread category completion, then resolves the final text strictly: a
ranked approximate result is acceptable for the deliberate Set category key,
never for unattended automation. twitch.auto_category is the optional deck
action: first press enables the global policy, later presses pause/resume it,
and feedback is OFF, PAUSE or active AUTO.
A successful manual twitch.set_category marks the currently detected AppID
as overridden; automation resumes only when another game is selected. Changes
made outside LinuxStreamDeck are noticed from the cached channel category_id
after a propagation grace period and receive the same treatment. Never compare
the stale snapshot immediately after our own PATCH or the old category will be
misread as a manual override. The manager stops after controller workers and
before HID/OBS/Twitch shutdown, joins its detector thread, and unsubscribes all
three bus handlers.
ui/steps.py::_CompletionPopup drives it, and none of it is about suggestions:
- The search never touches the GTK thread. Each one is a request over the
network, so it runs on its own worker and returns through
GLib.idle_add. COMPLETION_DEBOUNCE_MSis what keeps a rate limit from being spent on prefixes nobody meant to search for, andCOMPLETION_MIN_CHARSdrops the queries that would match most of the catalogue anyway.- A stale answer is discarded, by generation counter and by comparing
against the field's current text. A slow reply arriving after the typing
moved on would replace the current suggestions with older ones, which is
worse than showing none.
close()bumps the same counter, so work in flight cannot reopen a popup the field has finished with. - The popover does not autohide. An autohiding one takes the focus when it opens, which stops the typing that opened it.
- A suggestion is taken on the press, through a CAPTURE-phase
Gtk.GestureClick, never through a button'sclicked. That signal needs the press and the release to reach the same widget while it is still mapped, and pressing moves the focus off the entry — which closed this popover, so the release landed on a widget that had gone and the suggestion was silently never applied. For the same reason_on_focus_changed()ignores a focus that landed inside the popover. _place()sets the pointing rectangle explicitly and the popover ishalign=START. Left alone, a popover points at the entry's whole allocation — shadow included, so wider than the visible field and starting a few pixels to its left — and centres itself in it, which reads as a floating window rather than as a list belonging to the line being typed in._choose()closes after setting the text. Filling the field fireschanged, which would schedule a search for the very name just chosen and reopen the list under it.- Each row shows the category's box art, which is the point rather than decoration: a search for "detroit" answers "Detroit", "Detroit: Become Human" and "The Detroit After", hard to tell apart as words and immediate as pictures. The slot is reserved whether or not the picture arrives, so a slow or missing one cannot make the rows jump.
- There are two counters, and conflating them is a bug that happened.
_generationsays which query is current andclose()bumps it, so an answer for a field that stopped asking cannot reopen it._fill_generationsays which rows are on screen, and closing does not replace rows — sharing one counter meant every close threw away artwork for rows that were still there, and since a close fires on focus loss the list showed empty placeholders that never filled in. In_artwork_workthat counter stops the downloads rather than the display: rejecting a picture on arrival is too late, it has already been fetched for a row that is gone.
Only a value the service recognises is stored. settled_value() is what
_widget_value() reads for a field with suggestions, and it answers empty for
text Twitch never offered. Free text that merely looks like a category is the
worst case there is for this action: the key saves cleanly, looks configured,
and fails the first time it is pressed — which here means live, on air. Saving
nothing makes the key plainly unconfigured instead, and SetCategory.execute()
turns that into a message naming the fix rather than an internal complaint.
Three details keep it from being destructive:
- The value the field opened with is seeded into
_known. It was either chosen from this list when it was set or it predates the list, so opening a working key and saving it must not empty it. Only what someone types now has to be recognised. - A name that was merely displayed counts, not just one that was clicked. Typing a category out in full is not a mistake, and the list showed it.
- A field with no suggestions passes its text through untouched. With no account there is nothing to check against, and unknown is not the same as wrong — the same rule the pre-flight board follows.
settled_value() returns the name as Twitch spells it, so the configuration
holds a real category rather than a near miss that only happens to resolve, and
_mark_unsettled() warns in the field itself once a search has answered — half
a typed word is not yet wrong, so it is not marked as it is typed.
Box art is fetched with http.request_bytes, which refuses any host that is
not Twitch's. That address arrives inside an API response, making it data from
outside rather than a URL this application chose, so ASSET_HOSTS is checked
before anything is opened and the match is on whole labels — jtvnw.net.evil
must not pass. MAX_IMAGE_BYTES bounds what a wrong or redirected address can
stream into memory.
Twitch's own search shows a viewer and follower count per category; this cannot. Helix exposes neither, and the only source is Twitch's private GraphQL API — undocumented, outside the terms for third-party clients and free to change without notice. Showing the name and the artwork is what this can stand behind, and inventing the rest would be the same overclaiming the pre-flight board exists to avoid.
- Without a linked account
_completion_search()answers None and the field stays a plain entry, rather than one that never suggests anything.
Actions report failure by raising. The controller turns that into the key's own red border and a status message, which is where someone pressing a key is looking; catching the error and emitting a status instead would leave the key looking like it had worked.
The editor offers the connection the key needs. EditorPanel carries an
Adw.Banner that appears while the draft holds any action whose
requires_twitch() is true and no account is linked. It exists because the
deck's own answer — a faded key that does nothing — is only half a message: the
fix lived in a dialog under the profile menu, which is the last place someone
configuring a key would look. ha_banner follows the same shape for
requires_home_assistant(), and _refresh_service_banners() decides both
together through the shared _draft_needs(predicate), since a key can equally
be missing an unconfigured Home Assistant server — there the message matters
more, because the failure is a silently empty entity dropdown rather than
anything on the deck.
Three details keep it honest. Both banners sit above scroller, not inside
body, so rebuilding the body for a new key type cannot destroy them mid-edit.
Each is decided from the draft rather than the stored key, so picking a
Twitch or Home Assistant action offers the connection immediately instead of
after a save. And any one action is enough, unlike the fade, which needs
all of them: a key that is half local still cannot do the other half, and the
person editing it is the one who can fix that.
_on_step_changed() is the single handler behind every on_change= in the
editor, so the icon preview and the banner follow the chosen action together.
Note that _build_body() also refreshes explicitly: today the step widgets
happen to report a change while being built, which covers it, but that is a
signal from a child widget rather than a decision by this panel, and a build
that produced no step widgets would otherwise leave the previous key's banner
on screen.
Ad breaks, raids and announcements are the rest of the catalogue.
start_commercial() answers the cooldown as well as the length, because Twitch
refuses another break until it passes and a key that only said "started" would
leave someone pressing it into a refusal. start_raid() resolves the channel
name through find_user() (cached: the same few channels get raided again and
again) and its status message says the raid still has to be confirmed in
chat — Twitch opens a 90-second countdown rather than moving anyone, and a key
that implied otherwise would be frightening to press. search_channels() sorts
live channels first, since a raid goes to somebody who is streaming, and primes
the same id cache so the raid itself costs no second lookup.
Each of those brought a scope, and an account linked before them holds a token that cannot perform them. That is not an error case, it is the ordinary upgrade path, and it is answered in three places:
- The key fades.
Action.twitch_scopenames the one permission an action cannot work without, andDeckController._twitch_allows()checks it againstmissing_scopes(). A connection is not enough — without this the refusal arrives on the press, which for these actions means live. Only the affected keys fade; an authorization whose scopes were never recorded answers empty and is treated as unknown rather than blocked. - The refusal says what to do. Twitch answers "User access token requires
the X scope." and stops.
missing_scope_message()turns that into the sentence that follows it, andTwitchScopeErrorcarries it. That type stays aTwitchHTTPErrorwith its status, because_read_channel()tolerates a 401 from the optional follower scope and keeps the rest of the snapshot — raising a plain error there lost the viewer count along with it, which is a regression a test caught and now pins. - The pre-flight reports it, as a warning naming the missing scope.
Running ads also needs the account to be an Affiliate or Partner, and Twitch
cannot be asked about it usefully. Its own issue tracker has
/channels/commercial answering an ordinary account with a 429 cooldown it
can never wait out — which is what someone hit while testing — and, worse,
sometimes with a plain success for an ad that never ran. Neither can be
read, so can_run_ads() establishes it from broadcaster_type on
GET /helix/users instead: partner or affiliate yes, empty no, and
None when it was never established, which is deliberately a third answer.
Action.twitch_needs_affiliate carries it to the same fade as a missing scope,
and start_commercial() refuses before sending. A lookup that failed must never
disable a key that would have worked, so only a definite False blocks
anything, and only the ad key: nothing else depends on it.
twitch/eventsub.py holds one WebSocket subscribed to chat messages, follows,
subscriptions, gifts, resubs and raids. It is the first persistent socket here
other than OBS, and three of its rules are the kind that fail silently:
- Silence is a failure. Twitch sends a keepalive whenever nothing else has, so going quiet past the session's own interval means the connection is gone while the socket still looks open. Only the watchdog notices; without it the deck reports nothing and looks perfectly healthy.
- Subscriptions belong to a session, not to a connection. They are created
against the welcome message's
session_id— except across asession_reconnect, where Twitch carries them over and recreating them would deliver every event twice. - One refused subscription costs only itself. Somebody who never granted
subscription reading must still get their chat and their raids, so a scope
failure is logged per type; only a session that could subscribe to nothing at
all is an error. Each entry of
SUBSCRIPTIONScarries the permission it needs, because Twitch refuses one the token cannot have with a flat "subscription missing proper authorization" that names nothing anyone can act on. The log names the scope and one status message says how to grant it — otherwise the keys watching those events are simply silent, which is indistinguishable from nothing having happened yet.
channel.follow is version 2 — version 1 was withdrawn — and a raid
condition may name only one side.
twitch/attention.py is deliberately not a counter. "3 messages" says
nothing about whether somebody is being ignored; "somebody has been waiting
four minutes" says exactly that, and it is the number that corresponds to the
viewer who writes once, gets nothing back and does not return. So the wait is
measured from the oldest unacknowledged alert, and urgency() turns it
into the escalation the key draws.
One history is kept and each key reads it through its own acknowledgement,
so two keys watching different things forget independently and pressing one
never silences another. FORGET_SECONDS matters as much: a key that never goes
quiet again is one nobody looks at. A key that has acknowledged nothing yet is
NEVER_ACKNOWLEDGED, which is -inf rather than 0.0 — monotonic time counts
from boot, so on a machine that started moments ago 0.0 is a perfectly ordinary
timestamp and an alert stamped before it would be dropped as already seen.
should_sound() is the mailbox rule, not the keystroke one. A sound per
message is unbearable the moment a chat wakes up, and the first thing anyone
does about that is turn it off — which puts them straight back to missing
messages. So it sounds when a key goes from quiet to somebody-waiting, and
again only after an optional reminder interval. The sound goes on the
notification executor, never an action worker, exactly like a timer's.
"Was this key already waiting" is asked of the alerts, never remembered as a
flag. _on_alert() derives it from the key's own pending set minus the alert
that has just arrived; _alerting holds only when a noise was last made, for
the reminder. A flag has to be cleared when the key goes quiet again and
nothing is in a position to see that: alerts expire on their own clock and the
key is never told. The first version kept one, so a key nobody pressed made its
noise once and then stayed silent for good — and the method meant to clear it
on a press was never called by anything.
The whole deck can light up, on the same rule as the sound. flash is
per key and off by default; when it is on, _start_flash() pulses every key
between the colour of what arrived and black, FLASH_PULSES times. It exists
for the case the sound does not cover — headphones, a game, somebody
concentrating — and it rides should_sound() deliberately: a flash per
message would be far worse than a noise per message, because a busy chat
would leave the deck strobing without a pause.
The middle key says what arrived, in one short upper-case word. The colour
alone says which of the four it was only to somebody who has learnt the four
colours; a word says it to anybody. FLASH_WORDS holds them, _flash_center()
finds the middle of whatever grid is connected, and FLASH_WORD_CHARS bounds
them at six characters — that bound is the whole design. compose() fits
centered text to the key's width, so a long word is drawn small, and measured
at 72 px "MESSAGE" and "FOLLOWER" came out at roughly half the size of "CHAT"
and "RAID": readable if peered at, which is exactly what somebody deep in a
game is not doing. Hence CHAT / FOLLOW / SUB / RAID, and TWITCH for
anything else.
The colour can be overridden per key, and defaults to the one per event.
flash_color depends_on the flash being on, so the row is hidden rather than
dropped and turning the flash off and on again keeps what was chosen.
_hex_color() validates it to #rgb / #rrggbb in twitch/actions.py,
not in the renderer: this value reaches Pillow on a worker thread, once per
pulse, and a colour typed with a digit missing would raise there rather than
simply not being used. Anything it rejects — including blank, which is the
default — falls back to FLASH_COLORS.
kind="color" is _ColorEntry in ui/steps.py: a Gtk.ColorDialogButton
plus a clear button. It exists because flash_color was a plain text field
first, and that answered neither half of what such a field is for — nothing was
shown while the value was inherited, and typing a colour by hand meant guessing
both the format and the shade.
It follows AppearanceBox's text-colour contract, not its background one,
and §5.24 is the reason: an empty value means inherit. So the button has to
show the colour that would be used while the key stores nothing, and its
notify::rgba handler is blocked while that colour is put on screen. Without
the block, displaying the default is indistinguishable from the user picking
that exact colour, and merely opening a key and saving it would freeze today's
default into it. set_value("") is what the clear button gives back.
Which colour an empty swatch shows is Param.default_color, or
default_color_source when a literal cannot answer. twitch_flash is the
case: the flash colour is one per kind of event, so the honest swatch is the
one belonging to what that key actually watches, and
flash_color_for_choice() resolves it — falling back to
FLASH_DEFAULT_COLOR for a choice like Everything that has no single
answer. A followers key showing the chat blue would be a default that is simply
wrong for it.
COLOR_SOURCE_PARENTS is what keeps the swatch current. It names the
parameter each source reads, so _watch_dependencies() follows that dropdown
for the same reason it follows a depends_on parent — otherwise switching a
key from followers to raids leaves the swatch green until the key is closed and
reopened. set_inherited() decides whether that changes anything on screen,
and deliberately only it: a colour the user chose is theirs. That is enforced
by value or self._inherited rather than by the short-circuit in
set_inherited(), which is why mutating the short-circuit is equivalent —
set_value() is idempotent.
The word's ink comes from renderer.contrasting_ink(), so a custom colour
cannot make it invisible. A fixed white word disappears on the pale yellow
somebody will inevitably pick; the helper is the hex form of the renderer's own
_contrasting, which the alert key's outlined clock already uses.
Five things about it are load-bearing:
- It stands aside from normal renders, and they stand aside from it.
_standing_aside()is whatrefresh()and_render_keys()consult, and it now answers for the screen saver and the flash — without the second, anobs.stateor a live-loop tick would paint real keys over half the pulses._flashingis raised before the work is queued and cleared before the closingrefresh(), or that refresh stands aside for a flash that has finished and leaves the deck black. - A frame is composed once, not once per key. A whole flash is three
images: the lit key, the lit key carrying the word, and the dark key, each
reused across every slot and every pulse. Composing per key would make one
flash 6 ×
key_countrenders — 192 on an XL — on the single worker that also draws every real key. - A sleeping deck is woken first. The saver owns the deck and the render
guards would drop the flash entirely, and asleep is exactly the state
somebody deep in a game is in, so the feature would fail precisely when it is
needed.
_wake_for_flash()uses the ordinaryrecord_activity()path and then waits, bounded byFLASH_WAKE_SECONDS, for the saver's own thread to let go between frames. The consequence is honest and worth knowing: waking counts as activity, so the idle timer starts again from there. - One at a time. A second flash over the first would only cut it short, and a deck already flashing is already asking to be looked at.
- It stays under three flashes a second. A Stream Deck is nowhere near the screen area photosensitivity guidance is written for, but staying the right side of that threshold costs nothing at all.
play_audio() takes a volume as a percentage and a stop signal it can
call. Handing it volume / 100.0 and the _stopping Event — both of which
look right — made every alert sound play at a hundredth of its volume and then
raise TypeError on the first turn of its loop. Nothing noticed, because a
ThreadPoolExecutor keeps a worker's exception in a Future nobody reads: hence
_alert_sound(), which reports a failure the way the timer's sound does. A
sound that cannot be played has to be as visible as one that can, or the key is
silent in exactly the way it is silent when nothing has happened. Tests that
assert the controller called something cannot see any of this; the ones that
matter feed what it passed to the real play_audio.
events.py normalizes six payload shapes into one Alert. Note that a
channel's first-ever message from someone is marked by Twitch with its own
message type (user_intro) rather than a flag, and that is the one nobody
can afford to miss — it is what the first filter is for. The filters are what
let one key work on a channel of three viewers and one of three hundred:
unfiltered, "somebody said something" stops meaning anything the moment the
room is busy.
The alert key draws its urgency as a border, and breathes in its own colour.
Both were arrived at by rendering it. A background colour is invisible behind
the waiting person's avatar — the same lesson the failure mark already carries
— so feedback() returns border and compose() draws it last, with a
failure still outranking it. And the running-key pulse blends towards the
accent, which turned a red "waiting five minutes" key into a calm blue; the
pulse flag lightens instead, so the colour keeps saying what it said. Its
phase comes from the clock rather than from the activity thread, whose own
phase only advances while something is running.
feedback() reads cached_avatar() and never fetches: it runs on a render
worker, and the picture was fetched when the alert arrived.
A value drawn over a picture is outlined, not scrimmed. The label gets
_scrim() because it sits along an edge, where a gradient is invisible; the
waiting clock sits in the middle of the subject, where a band would cover the
very thing the picture is there to show. Twitch's default avatars are flat
bright fields, and a white "15s" on a white one is not dim — it is gone.
_outline_width() scales the stroke with the text so it reads the same on a
Mini and an XL, and _contrasting() picks black or white against the ink
rather than a fixed colour, because a key whose text colour was set to
something dark needs a light outline. The outline is added only when there is a
picture; a plain key needs none and looks heavy-handed with one.
Four checks: the account and its scopes, the title, the category, and whether Twitch already shows the channel as live. Three things about them are deliberate.
They run even when OBS is closed. Everything else in run() asks OBS
something, so a disconnected OBS reports the rest as unchecked and returns —
but Twitch is a different service, and "is my title set" does not stop being
worth knowing because OBS happens not to be running.
They read the channel with refresh_channel(), not channel(). The cached
one answers from up to twenty seconds ago, which is the right trade for a key
repainting itself and exactly the wrong one for a decision about going live.
This runs on an action worker and is asked once, so it can afford to block.
No account is UNCHECKED, never FAIL. Somebody who does not stream to
Twitch has nothing wrong with their setup and the board must not tell them they
have; the four rows still appear, because a check that is quietly absent looks
exactly like one that passed. An unreachable Twitch is likewise unchecked
rather than reported as a missing title — that would send somebody hunting for
a problem they do not have.
Both check_twitch_title() and check_twitch_category() state their own limit
in the detail, as every check here does: they establish that something is set,
never that it is the right thing for today. Left on yesterday's game is the
classic mistake and it is not decidable from here.
DeckController._unavailable() replaced _needs_obs(). The documented rule is
unchanged — a key fades only when every one of its actions is blocked, and
an unregistered action is unknowable rather than unavailable — but the question
is now asked per action rather than per connection, because a single key can
mix them. _action_blocked() combines Action.requires_obs(params) against
the OBS connection, Action.requires_twitch(params) against the linked
account, and Action.requires_home_assistant(params) against the configured
server, plus Action.is_available(params) for a missing local program or
selected target — one rule.
The consequence worth knowing: a key holding a Twitch marker and an OBS chapter marker does not fade when OBS is closed, because it still places the Twitch marker. It fades only when none of the services a key needs is there. Conflating the conditions would fade a Twitch key whenever OBS merely happened to be closed.
twitch.stats repaints on the same _live_loop() that drives obs.stats and
the scene previews, at TWITCH_REFRESH_SECONDS, and asks for nothing while no
account is linked — repainting is what makes the key ask for a fresh snapshot,
so with no account there is nothing to ask.
Config.load() on a clean machine yields one profile, one page and no keys, so
a fresh install opened on an empty deck and an editor to decipher.
core/starter.py answers that, and two rules define it:
- Offered, never imposed.
app.pyrecordsis_first_run()before loading (loading is what creates the file), andMainWindow.offer_starter_keys()asks once, after the window is up, since the dialog is modal to it.apply_starter_keys()refuses a page that already holds anything, because the answer can arrive long after the question. - Every key works on arrival. That is the whole selection rule for
STARTER_KEYS: nothing that has to be pointed at a scene or an audio input, on a machine where OBS may not even be running. They are ordered by how much someone wants them, becausestarter_keys(capacity)stops at the deck's key count — a Mini has six.
The OBS keys among them are faded until OBS is reachable, which makes the first run explain the fade rather than look broken.
EditorPanel.current_key_config() must reconstruct the complete canonical
KeyConfig draft without persisting it. On load and after a successful save, the
editor stores a cloned baseline and reports unsaved changes by comparing the
current draft with that baseline. Do not replace this with a sticky dirty flag:
action changes, parameter values/order, appearance and AI-loaded proposals must
all count, while reverting every value must become clean again.
The editor retains both its source Page object and key index. A deferred
Save and continue must write to that source page, never whichever page became
active later. MainWindow gates key selection, key moves, page/profile switches
and creation, configuration import and window close through the unsaved-change
dialog. It offers Keep editing, Discard changes and, when the destination
will preserve the saved key, Save and continue. Paste or clear of the edited
key deliberately omits the save option because the next operation overwrites that
same key.
Clicking the already selected key must not reload the editor. Page-change handling tracks the selected page by object identity so renaming that same page preserves the selection, while a real page change clears it.
acknowledge_press() lights a button for PRESS_ECHO_MS and optionally
replaces its text meanwhile. Only the editor's Save and its three Test
buttons use it, and the reason is specific: this panel looks identical after
either of them ran, so the only thing that moved was a line in the status bar at
the far end of the window. Every other action button in the application closes
its dialog, opens something or rewrites its own status line, and needs none of
this.
The theme's own :active state does not cover it. A quick click is
released before that state is ever painted — press and release land in the same
frame — which is exactly what made Save feel like it had not registered. So the
press is acknowledged deliberately, and for long enough to be read.
Four details are load-bearing:
- The word is only safe on a button with room to spare. Save hexpands, so its allocation stays put while its natural width goes from 68 to 77 px (measured); on a button sized to its label, "Saved" would shove the rest of the row sideways under the pointer. The Test buttons therefore only light up.
- Only Save names an outcome, and only when there was one.
_save()checks whatsave()returned, so a press with no key selected confirms nothing. Test deliberately says no word at all: it starts the key, and a key that fails reports that itself with its own red mark — a button claiming success would be quicker and louder than the truth. - The first press of a run captures the label. A second one while the echo is still up would capture the echo instead, and the button would read "Saved" for the rest of the session.
- The transition lives on
.press-echo, the lit state on.press-echo-on. A removed class can only animate out from a rule the widget still matches, so collapsing them into one leaves the button snapping back instead of fading.
.press-echo-on brightens the background and draws a ring inside the
button in @accent_fg_color. Both are needed: Save is already accent-filled, so
a background turning accent would change nothing, while the flat Test buttons
need the fill or the ring alone reads as focus. The pair is guaranteed to
contrast whatever the desktop's accent is.
MainWindow owns one Gtk.DragSource / Gtk.DropTarget pair on the entire key
grid, not one pair per button. Both controllers use
Gtk.PropagationPhase.CAPTURE, the source accepts only
Gdk.BUTTON_PRIMARY, and the target enables preload. This lets the grid claim
the gesture before a child Gtk.Button or Gtk.Picture consumes it.
Drag data is an internal GObject.TYPE_STRING value prefixed with
linuxstreamdeck-key:. Preparation resolves the source from the pointer with
Gtk.Grid.pick() and walks parent widgets until it finds the owning key button.
An empty key returns no content provider and therefore cannot start a drag.
Motion and drop resolve the destination the same way, so configured keys can move
upward or downward into any empty slot, or swap with any occupied slot.
The drop handler must decode the typed payload and require its source index to
match the active drag. Foreign, malformed, stale, outside-grid and same-key drops
are rejected. CSS classes dim the source and subtly highlight the current valid
destination. Destination feedback clears on leave/drop, and drag end clears all
remaining feedback. A valid drop still goes through the unsaved-change
confirmation before DeckController.move_key_to(); the key configuration and
transient toggle/clock state travel together, and selection follows the
destination. Copy/paste remains a separate context-menu/shortcut operation.
A drag that rests on a folder key for SPRING_OPEN_MS opens it, so a key can be
dropped inside rather than swapped with the folder. Resting on the Back key
leaves the folder the same way, which is what stops it being a one-way door.
Only a pointer that stops counts: _arm_spring() restarts the countdown
whenever the hovered index changes, so crossing a folder on the way somewhere
else opens nothing.
The consequence is that _drag_source is a (folder path, key index) pair,
not an index. The grid changes under a drag in progress, so an index alone
names a slot the carried key is no longer in — and after springing into a
folder, slot 7 of that folder would compare equal to the slot 7 the drag started
from on the page, and the drop would be refused as "onto itself".
_is_drag_source() is the one place that comparison lives, and
_refresh_drag_source_feedback() dims the source only while its own grid is the
one on screen.
Three more rules keep it from doing damage:
- A dirty editor stops the spring rather than asking. A drag cannot put up a
modal dialog, and entering a folder clears the selection, so
_spring_open()checkshas_unsaved_changes()and says so in the status bar instead. Nothing is lost: the ordinary swap-on-drop still happens, with its usual confirmation. .spring-targetis deliberately not.drop-target. Dropping on a folder swaps the two keys; waiting on it goes inside. Two different outcomes on one key must not look the same, hence the warning colour against the accent one.- The depth limit is the same one
open_folder()applies (can_add_folder()), so a folder atMAX_FOLDER_DEPTHnever springs.
DeckController.move_key_to(source_path, source_index, dest_index) performs the
drop. It delegates to swap_keys() when both paths are the same, and otherwise
moves the key into the grid on screen, sending whatever was there back to the
source position. It refuses the reserved Back slot at either end, a source grid
that no longer resolves, a key whose folders do not fit at the destination
depth, and a folder dropped inside itself — the destination grid only exists
because the key being carried holds it. It reuses _swap_key_state() (shared
with swap_keys(), so what travels with a key cannot drift apart between them),
discards each moved folder's subtree state exactly as a same-grid swap does, and
calls forget_undo(): an undo entry names indices of one container, so
replaying one across this change would restore a key over the one just moved in
and leave the moved key in neither grid.
Persistence is atomic JSON with user-only (0600) permissions at
~/.config/linuxstreamdeck/config.json, with a config.json.bak backup written
on every save. Loading migrates both the old single-profile format and legacy
combined nav.page actions before the model reaches the editor/controller. The
OBS password is stored asynchronously in Secret Service / GNOME Keyring, never in
either JSON file. On first run, legacy plaintext password fields are migrated and
removed from both files; if Secret Service is unavailable, they are still removed
and the password is session-only. OpenAI and Claude API keys are also stored only
in Secret Service, under separate provider identities; AI preferences in config
never contain either key. The Twitch access and refresh tokens follow the same
rule under their own schema, as one JSON item so a renewal replaces both
together; TwitchSettings in config holds only public state: the Client ID,
automatic-category policy and Steam AppID → Twitch category mappings. The Home
Assistant long-lived access token is the fourth, under its own schema as one
plain string, because nothing about it is ever renewed;
HomeAssistantSettings holds only the server address, which travels with an
export on purpose so a configuration moved to another computer in the same
house still points at the right box.
The profiles menu exports .lsdconfig ZIP format v6 with the full JSON
configuration, including every device workspace, its screen-saver and clean-exit
display settings, available custom key icons, custom exit images and files referenced
by sys.audio or the sound parameter of sys.timer; identical audio content
is deduplicated even across both actions and workspaces. Version 6 deliberately
clears hardware vendor/product/serial bindings so another computer can assign
its own devices; version 5 added Arcade Sparks, permanent unlocks, completed
challenges and refill state, and versions 1–5 remain accepted. Audio is limited
to 200 MiB per file and 500 MiB total, while exit images share a 50 MiB limit. Built-in mdi:
icons stay as references, and OBS passwords and provider API keys are never
exported. close_action and editor_width travel with the configuration like
brightness does — the width is clamped on load and clamped again by the pane
to what the window can actually spare, so one carried over from a much wider
screen is harmless — but the autostart entry never does: it stays local to each
computer. Missing, unreadable, unsupported or oversized portable files remain
local references and produce an export warning.
Import accepts every version in SUPPORTED_EXPORT_VERSIONS (v1 to v6),
validates archive member paths and size
limits, restores bundled icons below CONFIG_DIR/imported-icons, audio below
CONFIG_DIR/imported-audio and exit images below
CONFIG_DIR/imported-exit-images, replaces the complete configuration and writes
the prior configuration to config.json.bak. It keeps the destination
computer's keyring credentials and ignores password fields in old exports, so an
OBS password must be entered once after moving to a new computer. The application
rebuilds every device runtime, applies its imported brightness, screen-saver and
clean-exit display settings, then reconnects OBS.
The key context menu exports one key as a .lsdkey ZIP through
Config.export_key_bundle() and reads it back with Config.import_key_bundle().
Both are classmethods that reuse the configuration bundle's archive layout,
EXPORT_ICON_PREFIX / EXPORT_AUDIO_PREFIX rewriting, size limits, member
validation and atomic _write_bundle_archive() writer. The manifest carries its
own KEY_EXPORT_FORMAT (linuxstreamdeck-key) and KEY_EXPORT_VERSION, so a
full .lsdconfig cannot be imported as a key and a .lsdkey cannot be imported
as a configuration; the key itself lives in key.json. Both formats were raised
when folder keys landed (bundle v4, key v2) so an older application refuses the
file instead of silently dropping whole folders; the loaders still accept every
earlier version.
Export never mutates the source KeyConfig (it works on an asdict() copy),
bundles only that key's custom icons plus sys.audio / sys.timer audio,
deduplicates identical audio across both actions and reports missing files as
warnings instead of failing. Import validates paths and sizes before writing,
restores assets below CONFIG_DIR/imported-icons and CONFIG_DIR/imported-audio,
runs the value through KeyConfig.from_dict() so legacy nav.page actions still
migrate, and returns the key without saving. MainWindow then applies it through
DeckController.paste_key(), which resets that position's toggle/clock state and
persists the configuration. Replacing the key currently open in the editor goes
through the unsaved-change guard with offer_save=False, exactly like paste.
- English only, everywhere. All user-facing text (action names, categories,
descriptions, parameter labels, choice values, dialogs, menus, tooltips,
status messages) and all code comments, docstrings, log messages, README,
shell scripts, the udev rule and
pyproject.toml. No Spanish and no accented characters in any versioned file. When you add any text, write it in English. - Match the surrounding style. Follow the existing naming, comment density and
idioms of the file you are editing. Keep helpers small and private (
_name). - Threading discipline is not optional — see §5.
- Reference code as
path:linein explanations.
These are hard-won and easy to reintroduce. Violating them causes intermittent, hard-to-diagnose failures.
The four whose violation is silent — nothing crashes, nothing asserts, and the
symptom only appears on some launches or under concurrency — are guarded by
tests/test_invariants.py. That file tests no feature: it exists so these rules
fail loudly instead of being enforced by prose alone. Each guard is
mutation-tested, meaning it has been confirmed to fail when its invariant is
deliberately broken; if you change one, re-confirm that, because a guard that
cannot fail is worse than no guard. When mutation-testing by hand, purge
__pycache__ between runs: bytecode is invalidated on source mtime and size,
so a revert that restores the same byte count within the same second is silently
ignored and you will chase a ghost.
-
Pillow text layout must be BASIC. Pillow's pip wheel bundles its own harfbuzz; used alongside GTK/Pango's system harfbuzz it intermittently draws blank glyphs (random per process — some launches all icons fine, others all blank). The fix, which must stay in place, is
ImageFont.truetype(path, size, layout_engine=ImageFont.Layout.BASIC)incore/icons.py,device/renderer.py,device/startup_animation.pyanddevice/screensaver.py, plus every game font loader ingames/render.pyandgames/rendering.py. Do not use raqm,anchor="mm", or oversized fonts for glyphs (the latter caused giant masks →DecompressionBombWarning+ blank keys). Configured-key glyph centering usestextbbox+ ink-bbox recenter; the startup title centers each character from its text bounding box within one hardware-key cell, and the screen-saver title uses the same BASIC-only discipline. User-selectable label sizes stay bounded for the same reason: keep them expressed asFONT_SIZE_DIVISORSof the key height rather than absolute point sizes, so no choice can grow into an oversized mask. Where the font is found is a separate trap, and it is silent. Every renderer used to carry its own list of font paths and all four lists held Debian and Arch locations only. Pillow answers a missing font withImageFont.load_default()rather than raising, so Fedora, openSUSE and the Flatpak runtime — which keeps DejaVu at/usr/share/fonts/dejavu/, a path none of the lists had — drew every key label, the startup title and the screen saver at roughly 40% size with nothing logged. Measured: "Record" at size 20 is 79 px wide with a real font and 32 px with the default one.core/fonts.pyis now the one list, and it ends with a copy the package carries so an AppImage can run on a machine with no fonts at all. Keep the bundled entry last, or it overrides the font the machine actually has. Guarded bytests/test_fonts.py.Guarded by
BasicFontLayoutTests, which records everyImageFont.truetypecall made while rendering an icon, key images, four screen-saver styles, the startup sequence, exit tiles and all nine built-in game renderers, and fails if any of them omittedlayout_engine. Clear the cached font loaders first or a patched loader is never reached. -
Rendering is not thread-safe →
RENDER_LOCK. Pillow/FreeType is not thread-safe. Configured keys render on a worker, the screen saver renders on its own thread, and the icon picker/preview render on the main thread; concurrent use produced blank (and blank-cached) glyphs. A shared reentrantRENDER_LOCK(defined incore/icons.py, imported by every device and game drawing module) serializes drawing. It is reentrant socompose()can calllibrary.renderwithout deadlocking. The glyph cache is manual and never caches failures (safety net). Guarded byRenderLockTests, which swaps the lock for a depth-tracking proxy, patchesImageDraw.Drawto record anything drawn at depth 0 while exercising all nine games too, and pins that every drawing module holds the same object: each imports it by value, so a second lock would serialize nothing while still looking correct. -
OBS requests must be fully serialized.
obsws_python.ReqClientuses a single websocket that is not thread-safe. Inobs/client.pythe_lockmust be held for the entirereq.send(...), not just while reading the client pointer. Requests come from two threads at once (the GTK thread filling editor dropdowns, and the render worker forfeedback()calls likeobs.source_visibility/ filters that query OBS live). Overlap corrupts the protocol → hang at ~73% CPU and disconnect. Guarded byObsRequestSerializationTests, the only test that builds a realOBSClientrather than a fake: a stubsendblocks while a second thread tries to get in, and a semaphore fails the test if two calls are ever insidesendat once, so it catches the overlap even if the timing shifts. It also pins that a raisingsendreleases the lock instead of wedging every later request. -
Single-instance app. It is a single-instance
Adw.Application. Editing.pyfiles does not affect an already-running process, and relaunching while a window is open merely re-activates the existing one. After a code change the running window must be fully closed before relaunching. Proof that a given instance is the live one: its log shows the deck "connected" line (HID access is exclusive — only one process can open the device). -
Do not launch the GUI yourself to verify changes. Background launches leave zombie instances and cross-activations, and the user ends up looking at a stale window with cached state (e.g. blank glyphs cached before a fix). To verify UI rendering, compose key PNGs offscreen (build objects directly, or use
Gtk.WidgetPaintable+Gtk.Snapshot+renderer.render_texture→save_to_png). To verify the running app, ask the user to close any old window and launch it themselves with./run.sh. -
AI output is an untrusted proposal, never an action. Keep provider API keys in
ApiKeyStore; a display mask must never become a request credential. Keep context optional and limited to bounded OBS/page/Steam names and Steam AppIDs, and excludesys.command,obs.rawandweb.requestfrom the provider catalogue: each reaches something outside this application on the strength of a string. Validate every response locally. Generation must not execute or save anything; only the user's explicit Save from the existing editor persists the key. -
Unsaved key edits must survive navigation decisions. Dirty state is a canonical
KeyConfigcomparison, not a sticky UI flag. Keep the source page reference stable across deferred dialog responses, preserve the guarded paths described in §3, and never reload on a same-key click or a rename event for the same page object. -
Action work must not starve running feedback. Keep action, timer-sound and render executors separate, count every queued/running invocation per device-scoped
RuntimeKey, and pulse only busy keys in the active view. Preserve the complete controller shutdown order: set the stopping signal; stop the clock scheduler; clear clock state and signal completion sounds; wake activity and clear the pending-render marker; shut down the action executor; shut down the timer-sound executor; join the activity thread; then shut down the render executor. -
Restartable actions must never overlap. Keep cancellation controls scoped by device-scoped
RuntimeKey, cancel the prior same-key invocation and wait for itsfinishedevent before starting the replacement. Long-running actions and waits must observe theirActionContext; canceled queued replacements must exit without executing so rapid presses collapse to the latest invocation. -
Portable assets must stay bounded and path-safe. Keep
.lsdconfigv1-v5 import compatibility while exporting v6, which preserves every deck workspace, folder subtree and Arcade progress while stripping computer-specific hardware bindings. Bundle only supportedsys.audiofiles andsys.timersoundparameters, deduplicate audio content across both actions and restore it only belowCONFIG_DIR/imported-audio. Bundle the supported BMP/JPEG/PNG/WebP custom exit images with content deduplication, enforce their shared 50 MiB limit and restore them only belowCONFIG_DIR/imported-exit-images. Validate every archive path and size before extraction. Single-key.lsdkeybundles import v1 and export v2 for folder support; they follow the same rules and must keep their distinct manifest format, so neither bundle type can be imported through the other's entry point. Export must not mutate theKeyConfigit is given, and import must not save the configuration itself. -
Physical startup must remain exclusive and cancellable. Generate frames offscreen under
RENDER_LOCK, keep their brightness at or below the configured target and restore that target infinally. Play them directly on the provisional device before assigningself.deck, installing its callback or emittingdeck.connected. Check the monitor stop event during writes and waits; only a completed or safely skipped animation may proceed to connection publication and the configured-key refresh. If shutdown cancels provisional startup, apply the configured clean-exit display before closing that device. -
Named page navigation must stay compatible and unambiguous. Keep the legacy
nav.pageloader migration for single/multi/toggle actions, but never expose that ID in the registry or AI catalogue. Populatenav.page.gochoices from the active profile, reject duplicate names and update every same-profile reference on rename. Next/previous must wrap, missing names must report status and a same-page selection must not save or emit redundant events. -
Grid DnD must survive child widgets and reject foreign data. Keep one CAPTURE-phase, primary-button source/target pair on the grid, preload the typed internal string payload and resolve source/destination by walking from the picked child to its key button. Empty keys cannot be sources; empty and occupied keys are valid destinations in any direction. Validate the payload against the active source, preserve unsaved-change confirmation, move toggle and clock state with the key and always clear source/destination feedback. Keep
_drag_sourcea(folder path, key index)pair and compare it through_is_drag_source(): a folder can spring open mid-drag, and an index alone then names a slot of a grid the carried key is not in. A spring must never run while the editor is dirty — a drag cannot stop to ask, and entering a folder clears the selection — and it must keep its own visual state distinct from the drop target, since resting and dropping do different things.move_key_to()refuses the reserved Back slot at either end, a folder that does not fit at the destination depth and a folder dropped inside itself, and drops the undo history, whose entries only name indices of one container. Keepself.editor.set_hexpand(False): without the explicit flag the panel inherits one from its hidden-until-selected button row, the grid box loses half its spare width on the click that selects a key, and the centered grid slides 247 px left at 1920 — so the pointer is two keys away and a folder's second click misses. Keep bothshrink_*_childFalse, which is what makes the key grid's own minimum the stop for the drag handle, and keep the restore bounded, run once and told apart from a drag. -
Stateful clocks must remain key-scoped and cheap. Keep countdown and stopwatch state keyed by device-scoped
RuntimeKeyand pass that identity throughActionContext. A single-actionimmediateclock must never occupy an action worker. The scheduler may poll at 0.1 seconds but should render only changed seconds in the visible view; switching views must not pause state. Move state with DnD, reset it on edit/paste/clear, clear it when indices or configuration are replaced, and emit each timer completion once. Completion audio must stay on its separate executor and stop on reset or shutdown. -
The screen saver must own the deck without stealing actions. Render every style as a coordinated full-deck frame under
RENDER_LOCKand BASIC text layout, with frame brightness capped by its independent intensity. While active, suppress normal controller/HID renders. Track physical key activity and deliberate virtual-deck interactions through explicitrecord_activity()calls. Never attach a broad/globalGtk.EventControllerLegacyactivity hook: nullable events previously causedAttributeErrorlog storms, near-100% CPU use and application freezes on Pop!_OS. Consume both edges of the first physical wake press, then restore normal brightness and configured keys. Preview must work while disabled and without hardware, and temporary dialog subscribers must unsubscribe on close. Suppress automatic activation while OBS is recording or streaming, wake an already active automatic saver when either begins, and restart a complete idle interval only after both stop. Keep manual preview ahead of suppression so an explicit preview remains available during an OBS session. Drive the policy only from immutableobs.outputspayloads: publish the first snapshot immediately after priming record and stream status, publish live output events before the general repaint event, and join the OBS event producer before resetting state and emitting the authoritative false snapshot on disconnect. Because prime requests and events use separate sockets, keep output state under_output_lockwith independent monotonic record/stream generations: a prime response may apply only when its captured generation is still current, live events advance their own generation, and teardown advances both after the event-worker join to invalidate in-flight primes. Keep every installed style's intentionalFRAME_DELAYScadence. Compare raw RGB pixels so unchanged physical keys skip native conversion and USB writes; publish only changed raw RGB keys toGdk.MemoryTexturefor the selected, visible, non-minimized virtual workspace. Hiding the window must not stop a connected physical animation, while an offline hidden workspace must wait without rendering until it has an output. Invalidate the appropriate caches on visibility, connection and saver transitions so the next destination gets one complete frame. On shutdown, signal the saver wakeup, join its thread before the monitor thread, and apply the clean-exit display before HID closes.
Treat suspend/resume transport loss separately from rendering failures.
Device health requires an open HID handle as well as an enumerated USB path;
a real TransportError from a writer must wake the monitor, stop further
writes to that handle and cause close/reopen in the same monitor pass. Do not
reconnect for image-conversion or arbitrary programming errors. Clear a
failed handle's request when detaching it so physical recovery never disables
virtual/manual preview, and publish a replacement handle atomically with the
request clear under the HID lock.
-
Never nest a built
GLib.Variantinside a format string. The dbusmenu reply is(u(ia{sv}av))and its layout is already a typed variant. Passing it as a tuple element ofGLib.Variant("(u(ia{sv}av))", …)makes PyGObject try to rebuild it from its unpacked value and fail withTypeError: Expected GLib.Variant, but got str. Assemble such replies withGLib.Variant.new_tuple(...)instead. This only shows up on a real D-Bus call, never when building the layout in isolation, so keep the reply-shape test. Menu-item artwork is embedded as 16x16 PNGicon-data, never resolved through the host icon theme. Keep Open, Profile, Games, Arcade, Stop and Quit mapped to distinct bundled MDI glyphs, perform their Pillow work under the sharedRENDER_LOCK, and fail open to the text-only item if rendering fails. Keep individual game rows text-only so their price and unlock labels stay compact. -
Hiding to the status area must always be reversible. Keep
hides_on_close()as the only decision point and keep all three of its conditions, so a missing, unregistered or vanished status area falls back to quitting. Hide by returningTruefromclose-requestand only setting the window invisible; destroying it would end the process and lose the session. Route icon-driven quit and profile switches through the window so the unsaved-change guard still runs, and present the window before any confirmation, since those dialogs are modal to it. Autostart state belongs in the XDG entry, never inconfig.jsonor an export. Never gate starting the icon onis_supported()and never register withcall_sync: the status area commonly appears after the application at session login, and a blocking call on the GTK main thread freezes the window for its whole timeout. Publish the real application SVG as multi-size, network-order ARGB32IconPixmapdata; crop only its fully transparent outer padding and preserve the visible artwork's aspect ratio instead of forcing square pixmaps. Keep that raster-only crop isolated from the original hicolor SVG used by the launcher and dock; while those pixels load, leaveIconNameempty so COSMIC cannot prefer a failed theme lookup, and fall back to the theme name when they do not. Keep the late-appearance and icon-pixmap regression tests. -
Press gestures must not leak across keys. Resolve them on release, keep the pending double-press timer keyed by device-scoped
RuntimeKey, and cancel it from every path that changes what that index means: key edit, drag/drop swap, page or profile change and shutdown. A key with no gesture mode must still run on the press, so plain keys keep their current latency. Never make the virtual deck wait out the double-press window; it runs the single-press list directly. -
Opening the deck disturbs the USB bus, and that is not our bug. hidapi detaches the kernel driver and unbinds the interface, which removes the deck's input node (
eventN) and, on a hub or dock, can make the whole dock re-enumerate. On a dock that also carries video, the desktop may restart its panel. This was diagnosed once on COSMIC and traced to the dock: connecting the deck directly made it stop. Do not "fix" it in the application or with aLIBINPUT_IGNORE_DEVICEudev rule — neither is the cause. When something like this appears, correlateudevadm monitorwith the affected process's PID, and check how the deck is physically connected before changing any code. -
Never fake media or modifier keys. Media transport goes through MPRIS and volume through the session mixer, both of which work unprivileged on Wayland. Only genuine application shortcuts use
core/keystrokes.py, and its backends must stay optional: no code path may assume ydotool exists, and a missing backend must surface as a status message, never an exception that breaks a multi-action sequence. Keepydotool_syntax()detection: emitting 1.x key codes to the 0.x binary that Debian and Ubuntu ship fails silently for the user.core/mixer.pyfollows the same rules and adds one of its own: read the mixer only throughpactl -f json, because plainpactlanswers in the user's language andwpctlcannot be parsed at all. Write an absolute level rather than pactl's relative+5%, which clamps nothing; move every stream of an application together; answerNonerather than "not muted" when the question could not be answered; and keep one cached reading serving all the keys, sincefeedback()runs on the single render worker. -
The clean-exit display must be the final HID state. Keep
device_defaultas a firmware reset,blankas black tiles followed by brightness 0, andcustomas a validated full-grid center crop written at normal configured brightness. Apply it only after device workers have joined and immediately before closing HID; if custom preparation or writes fail, fall back to the device default. Do not promise this state after forced termination, a crash or power loss. -
Folders must stay bounded, reversible and key-scoped. Slot
FOLDER_BACK_INDEXinside a folder is always the Back key: never store, select, drag, drop, paste, clear or swap it, or the physical deck can enter a folder with no way out. KeepMAX_FOLDER_DEPTHenforced in four places — loading (_folder_contents), creating (can_add_folder, which also gates the spring), pasting or importing a subtree (fits_here) and dragging one across a boundary (move_key_to). KeepKeyConfig.foldercompare=False, or editing a key inside a folder makes its parent key look permanently dirty. Keep the open folder out ofconfig.json: it is view state, and_leave_folders()must reset it on every page, profile and configuration change. Keep transient state on the fullRuntimeKey, and discard a subtree's state when its folder is replaced or moved but not when the folder key itself is merely edited. Every walk over a key's actions or assets must recurse with_walk_keys()/_walk_raw_keys(). -
Dials are keys everywhere except in storage. Keep
KIND_DIALonKeyConfigand its three lists inSTEP_FIELDS, so bundles, migrations and renames reach a dial for free; keep them inPage.dials, walked alongsidepage.keysby_key_configs()and the bundle exporter. KeepDIAL_PATHnegative so a dial can never share aRuntimeKeywith the key of the same number. Preserve the tick count of a turn and theMAX_DIAL_TICKSceiling, and derive touchscreen panels from the running edge so they tile without a seam. Everything dial-related must stay gated ondial_count, which is 0 on every model but the Plus. -
An empty appearance value means inherit, never "the default". That holds for
icon,font_size,text_colorand everykind="color"parameter: store nothing and let the renderer decide, or clearing a choice silently freezes the current default into the configuration. When the editor shows an inherited value it must block its own change handler while doing so. Acolorparameter whose inherited value depends on a sibling must name that sibling inCOLOR_SOURCE_PARENTS, or the swatch keeps showing a default that stopped being the right one. -
A checker may never overclaim. Keep
UNCHECKEDaboveOKinSTATE_ORDER, always named in the summary, and drawn faded with its own badge; keep everydetailstating its own limit and keep any verdict word out. Check cameras through the device handle in/proc, never through a screenshot, and resolve symlinks on both sides. Keep the volume meters on their own short-livedEventClientso the flood never reaches the request lock. Never let the stream key paststream_target(). Keepdismiss_board()the single way a report is put away and keepPreFlight._hold()pollingboard_active(), so a press on the deck and closing the report window both give the deck straight back instead of leaving it held for the rest ofPREFLIGHT_HOLD. -
A key that cannot do its job has to say so on the key. Keep the failure mark keyed by
RuntimeKey, expired by the activity thread (never by a timer per failure), drawn as a border so artwork and live previews cannot hide it, and dropped everywhere the other transient state is dropped. Keep the fade driven byAction.requires_obs(params)and applied only when every action of the key needs OBS; an unregistered action is unknown, not unavailable. Keep_mark_obs_dependency()deriving the flag from the category, or an action added later silently renders as usable while OBS is closed. KeepON_ERROR_CONTINUEas the default and normalize unknown values to it, so no stored key changes behaviour by being loaded. Keep the log file optional: failing to open it must never stop the application. -
A Twitch refresh token is spent exactly once, and key feedback must never wait on the network. Keep
_persist()storing a renewed pair before adopting it and keep_renew_lockserializing renewals, or two workers spend the same single-use token and the second refusal unlinks a good account. Keep_is_scope_error()out of the retry path: a missing scope is also a 401 and no renewal can grant it, so retrying spends a token on every repaint. Keepchannel()free of any request — it runs on a render worker insidefeedback()— and keep both staleness bounds, so a blip keeps the last value while an outage blanks the key rather than showing a number that stopped being true. Keep tokens in Secret Service and out ofConfig,config.json, its backup and every export; only the public Client ID and automatic-category policy/mappings may travel. Keep the poll loop matching Twitch's device-flow code through_normalize(), since the status code alone cannot tell "not yet" from "no" and the exact spelling already broke the flow once; keepslow_downa back-off rather than a refusal. Keepdescribe_error()between Twitch and the user, so no identifier is ever displayed. Keep the category a text field resolved at press time with an exact name beating the search ranking, and keep its suggestions debounced, off the GTK thread and guarded against a stale answer overwriting a newer one. Never let the account dialog claim a completed disconnection: Twitch has no API to remove the authorization, so revoking is all this can do and the user has to finish it on Twitch's own Connections page. Keep an alert key's "was it already waiting" derived from its pending alerts rather than from a flag, or the key sounds once and never again; keep the sound going toplay_audioas a percentage with a callable stop signal, and keep it wrapped so a failure is reported — an executor swallows a worker's exception, and a sound that cannot play is otherwise indistinguishable from nothing happening. Keep the deck flash on the same rule as the sound rather than per message, keep_standing_aside()covering it so normal renders cannot paint over its pulses, raise_flashingbefore queueing and clear it before the closingrefresh(), compose each frame once for the whole grid, and keep it under three flashes a second. Keep its word withinFLASH_WORD_CHARSand its ink fromrenderer.contrasting_ink():compose()fits centered text to the key width, so a longer word is drawn at half the size for a pulse that lasts a fifth of a second, and a fixed white one vanishes on a pale chosen colour. Validate that chosen colour intwitch/actions.py, never at the renderer — it reaches Pillow on a worker once per pulse. Never let 0.0 stand for "this key has never been acknowledged": monotonic time counts from boot, so on a machine that just started it is an ordinary timestamp, and anything stamped before it is silently dropped as already seen. -
A game owns both input and pixels until it restores the page. Keep the catalog, status-menu mapping, engine registry, per-game settings fields and render dispatch in agreement: an entry present in only some of them can appear in the menu but fail to start, render through the wrong path or save into another game. Engines stay pure with injected time/RNG; shared lifecycle belongs in
GameManager. Route physical input throughGameManagerbefore actions and preserve_owned_downuntil each release, including a release after Back made the session inactive. Suppress virtual actions, normal renders, report boards, alert flashes, editing and DnD while active. Every active game must consume every dial turn, dial push and touchscreen tap before configured dial actions; a game without a handler simply ignores the gesture. Screen-saver suppression must be a union of named reasons: releasinggamemay not release OBS, and game ownership must block even manual preview while ordinary OBS suppression must not. Derive layouts from live key count/columns, use the Plus LCD only when dials are present and keep Mini controls unique. Preserve the one/two/three compact Minesweeper densities and defer a result-time difficulty change until Start; derive valid compact Tic-Tac-Toe lines below nine keys; and keep Mastermind's latest Mini clue plus complete solution/Again/Back result controls. Neon Relay must preserve its guaranteed randomized boundary route, difficulty-specific visibility/safe-tile/shield/speed settings, sector acceleration, shield recovery, combo/crystal scoring, timed Overdrive and shuffled every-third-sector upgrades. On Plus, it rotates a whole dial column by the reported ticks and spends 40 charge on immediate stasis for a dial or strip press. Neon Jackpot must refuse layouts below eight display keys; keep its 4x2/5x3/8x4 reel/control layouts, predetermined outcomes despite braking, recovery only after an ordinary paid unheld loss, with either at most two one-spin holds or one nudge and never both, and no help after a win, free spin or held spin; keep fictional Sparks/bets/progressive jackpot, bolt free spins/multiplier and Plus reel controls. Preserve its committed Bet-to-Jackpot wager phase, outcome-neutral third-reel anticipation, three distinct impact windows, symbol-tier celebrations and four-act jackpot sequence. It must never add real-money, payment or cash-out behaviour. Draw under the sharedRENDER_LOCKwith BASIC fonts. Keep every game's cue map, exact WAV set, asset license, generator output and package-data entry isolated below that game's ownassets/games/<game_id>/directory. Bound/cancel audio work, persist records per geometry, game and difficulty, and stop the game worker before HID shutdown. Exit, disconnect and failure must restore the configured page without resurrecting a delayed report overlay.
Keep ArcadeEconomy as the one wallet authority: 250 starting Sparks, the
three starter unlocks, catalogue-defined permanent unlock/run costs, no Free
Play switch, and a 100-Spark rescue only when both zero balance and 24-hour
eligibility are true. UNLOCK needs confirmation; EARN SPARKS must release the
current session before requesting Neon Jackpot. Persist/export wallet,
progressive pot and its fifth-Spark contribution remainder, unlocks,
challenges, refill time and alert switches. Keep the pot floor/reset at 40,
preserve larger existing pots, and feed it exactly 20% of each paid wager
without per-spin rounding. Defer
refill sound/visuals during games and OBS output. Challenges are stable,
one-time IDs: scan durable configuration retroactively, but reward service
operations only from success events and never reward repetition or risky
actions.
- Automatic Twitch categories may observe, but never guess. Keep process
detection local, same-user and AppID-based, and accept only AppIDs that also
resolve to installed Steam manifests. Never infer a game from focus or a
window title. Scan and call Twitch only while the global policy is enabled,
unpaused, authorized and
obs.outputs.streamingis true; recording is not a Twitch stream. Preserve the twelve-second stable candidate, the one-new-AppID transition rule and the ambiguous-set refusal. Closing a game keeps the last category. Store stable Twitch IDs with their names, auto-save only strict exact-name matches and leave every other title pending for the user; a network error retries with back-off and is not an ambiguous match. A manual category wins until the detected AppID changes, including an external change observed after the post-PATCH grace period. Keep the policy global and its toggle key a view/control of that same state, never a per-key copy. Stop and joinAutomaticCategoryManagerbefore OBS and Twitch, and keep its state events repaint-only: action feedback and the GTK thread must never perform a process scan or Helix request.
Config.save() always writes to ~/.config/linuxstreamdeck/config.json
unless LSD_CONFIG_DIR is set. Any script that exercises code calling save()
(e.g. set_page, set_profile, add_profile, add_page, rename_page,
paste_key, clear_key, brightness, screen-saver or exit-display changes,
game lobby choices/high scores, Twitch automatic-category settings/mappings,
saving a key) will overwrite the user's real
config — this has happened and
lost real keys and settings.
Config.import_bundle() also saves a replacement configuration and writes imported
icons, audio and exit images below the config directory, so it must always be
isolated too.
Always redirect the config directory before importing the package:
LSD_CONFIG_DIR="$SOME_TMP_DIR" .venv/bin/python your_script.pySet LSD_CONFIG_DIR in the environment before linuxstreamdeck.core.config
is imported. A config.json.bak backup is written on each save, but do not rely
on it — isolate first.
The six 1600x900 images in marketing/ are reproducible feature presentations,
not invented interfaces or hardware mockups. marketing/generate.py sends
configured keys through the normal key renderer, deterministic states through
all nine real game engines and render dispatcher, including Neon Jackpot's
original transparent symbol sprites, screen savers through their
full-deck renderer, and the Stream Deck + strip through its LCD renderer. The
main-window panel embeds the canonical real GTK window capture in
docs/screenshot.png instead of synthesizing a window. Regenerate the gallery
only with an isolated config:
LSD_CONFIG_DIR="$(mktemp -d)" .venv/bin/python marketing/generate.pyThe numbers shown on live-feedback keys are representative supported values,
not readings from the developer's current system or accounts. Keep that
disclosure, and keep the hardware disclosure exact: the MK.2 is tested on
physical hardware while Mini, Neo, XL and Stream Deck + layouts are verified in
simulation. Do not hand-edit generated gallery PNGs; update the generator or its
canonical docs/screenshot.png input and regenerate all six images together.
marketing/generate_press_kit.py then builds marketing/press-kit/ and the
byte-reproducible landing download at
landing/assets/downloads/linuxstreamdeck-press-kit.zip. The package SVG at
packaging/com.javocsoft.LinuxStreamDeck.svg is the only icon source: the
generator copies it byte-for-byte as the master vector and rasterizes the
transparent 1024/512/256/128 PNGs plus prepared light/dark previews. It copies
the six gallery PNGs unchanged, includes the repository GPL text, writes
SHA256SUMS for every other press-kit file, and archives the result under
stable timestamps and permissions. Rebuild it after the gallery, package icon
or press-kit README:
.venv/bin/python marketing/generate_press_kit.py
node landing/check.mjsThe press-kit README must preserve the gallery's representative-value and
hardware disclosures; the copied compositions are not live desktop or hardware
captures. Do not hand-edit its generated icons, screenshots, checksums or ZIP.
landing/check.mjs asserts that the landing contains the download link and that
the archive exists, in addition to its catalogue checks; it does not replace
regenerating or inspecting the archive.
The project's documentation must stay in sync with the code. Documentation files:
README.md— user-facing overview, features, install/usage.AGENTS.md— this file (agent operational guide).CLAUDE.md— Claude-specific notes; imports this file.CUSTOMAGENTS.md— provider-neutral catalogue of the custom subagents.agent-definitions/— canonical prompts, provider settings and the generator for Claude/Codex agent adapters.docs/— assets such asscreenshot.png.marketing/— reproducible feature gallery, press kit, both generators and their provenance notes; the generated press-kit ZIP is served fromlanding/assets/downloads/.
Custom agent prompts must be edited only below agent-definitions/; files below
.claude/agents/ and .codex/agents/ are generated provider adapters. After an
agent prompt or manifest change, run:
python3 agent-definitions/sync.py
python3 agent-definitions/sync.py --checkThere is a dedicated documenter agent for both Claude and Codex. Its job is
to review every Markdown file and bring it up to date with the current code and
canonical agent definitions. Invoke it when code or agent changes affect the
docs, or when asked to "update the documentation". It must include generated
agent Markdown in its audit and finish with the synchronization check above.