Compare commits

..

9 Commits

Author SHA1 Message Date
Levi Woodard
6d0748e4c5 Add camera module: drive UVC webcam controls via cameractrls
New `camera` module in modules.example.yaml (and the embedded copy) with
four functions: `set` (any control=value list, incl. preset/colour buttons),
`toggle` (flip between two values), `adjust` (step a numeric control by a
delta, clamped to the min/max parsed from `cameractrls -l`, with optional
`extra` controls applied first), and `is` (poll helper, exit 0 on match).

Device comes from CAMERA_DEVICE in .env (default /dev/video0) or a per-key
`device` param. cameractrls exits 0 for unknown controls, so polls must
use `is`; stderr is discarded to drop Python deprecation warnings.

README: Camera module section with setup, function table, a folder
example, and the oksvg viewBox-offset centring gotcha for SVG icons.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017iodiNMYL9iuT7f6WCe5iv
2026-10-08 14:02:29 -06:00
Levi Woodard
4158165410 Add folder support: keys that open sub-pages with a back key
A key with `folder: NAME` swaps the deck to the keys defined under
`folders.NAME`. Every folder shows a back key (slot 0 by default, drawn
with a built-in chevron) that pops back to the parent; folders nest.

The daemon now runs one page at a time: per-key setup moved into
setupKey(), openPage() draws a page under its own context, and
page.stop() tears it down before the next page is drawn (and on exit,
so device death no longer waits on uncancelled goroutines).

streamdeck-ctl gains `key set -folder`, reports kind "folder", lists
every folder in status, and refuses to `key press` a navigation-only
key. The Omarchy grid shows a folder glyph.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ARLa4moGb154fzc9TevgV
2026-10-01 18:39:24 -06:00
Levi Woodard
c143db6b68 Fix HID read busy-spin: pass 250ms, not 250ns, to ReadWithTimeout
go-hid's ReadWithTimeout takes a time.Duration, so the bare 250 was
250ns, which truncates to a 0 ms hid_read_timeout. On both Linux
(poll) and macOS (cond_timedwait) 0 ms means non-blocking, so the
button loop spun continuously: ~1.2 cores and ~7.5k context
switches/sec at idle, from startup.

Pass 250*time.Millisecond and match hid.ErrTimeout with errors.Is
ahead of the string fallback. Idle CPU drops to ~0%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yH8jHn1SNzNnL58CRwTi9
(cherry picked from commit 69d2926a1a)
2026-09-29 18:49:41 -06:00
Levi Woodard
2aa4c65e42 Add Omarchy shell plugin for the Stream Deck
QML bar widget + popout panel (dev.woodard.streamdeck) that drives
streamdeck-ctl: key grid shaped by the connected model, per-key editor,
brightness slider, and daemon controls. Themed via qs.Commons.

Makefile gains build-ctl, install-ctl, install-plugin, validate-plugin
and uninstall-plugin targets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjuZBBfzXqZgxRhxzJvkkC
2026-09-27 18:21:33 -06:00
Levi Woodard
8cff4f8418 Add streamdeck-ctl JSON control CLI
streamdeck-ctl reports device/daemon/key status as JSON and edits the
config (brightness, key set/clear/press, daemon control) so front-ends
don't have to reimplement YAML handling or device probing.

- internal/config/edit.go: comment-preserving, node-level YAML edits with
  atomic writes; key indices written as !!int so config.Load accepts them.
- internal/device: add model names, Lookup() and Present() for probing
  without opening the HID handle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjuZBBfzXqZgxRhxzJvkkC
2026-09-27 18:21:33 -06:00
lwoodard
cef6f72712 Slack modules: declare charset in Content-Type
Add "; charset=utf-8" to the JSON Content-Type header so Slack's
users.profile.set no longer returns the missing_charset warning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-12 09:50:15 -06:00
lwoodard
d1d5d88508 Add text_command + refresh live-text keys
Introduce a per-key `text_command` + `refresh` feature: the service
periodically runs a shell command and renders its stdout as the key's
text overlay, compositing over the base icon (or a black background for
text-only keys) each tick. `text` acts as the static fallback shown
before the first run or when the command errors/outputs nothing. A
refresh goroutine owns the key's image and also handles an optional poll
block (choosing icon_true/icon_false per tick). Adds `$(...)` sugar in
`text` as shorthand for `text_command`.

- internal/config: add TextCommand/Refresh fields to KeyConfig
- cmd/streamdeck: refreshTextKey goroutine, runTextCommand, unwrapCmdSubst
- config.example.yaml: document the feature with a `flow` pomodoro
  live-countdown example (status --short, toggle/skip/stop/gui)
- modules.example.yaml: example config updates

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TEoLyNbtbjbDq7aTWAymUG
2026-08-07 13:23:43 -06:00
lwoodard
a542ad60cd macOS watchdog: detect deck via ioreg sessionID
system_profiler SPUSBDataType was silently omitting the deck on some Macs
(watchdog exited early, never restarted anything) and, when it did report,
the trailing bus number in "Location ID: X / N" flapped without an actual
replug — causing spurious restarts. ioreg sees the device reliably and
sessionID changes only on real re-enumeration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-09 11:25:54 -06:00
Levi Woodard
a51fd2beff Fixing watchdog 2026-05-10 13:35:16 -06:00
27 changed files with 4568 additions and 149 deletions

1
.gitignore vendored
View File

@@ -3,6 +3,7 @@
/streamdeck-go
/streamdeck-helper
/streamdeck-init
/streamdeck-ctl
/bin/
*.exe

View File

@@ -1,10 +1,16 @@
BINARY := streamdeck-go
HELPER := streamdeck-helper
INIT := streamdeck-init
CTL := streamdeck-ctl
PREFIX ?= $(HOME)/.local
CONFIG_DIR := $(HOME)/.config/streamdeck-go
GROUP := streamdeck
# ── Omarchy shell plugin ──────────────────────────────────────────────────────
PLUGIN_ID := dev.woodard.streamdeck
PLUGIN_SRC := $(CURDIR)/omarchy-plugin
PLUGIN_DIR := $(HOME)/.config/omarchy/plugins/$(PLUGIN_ID)
# ── OS detection ──────────────────────────────────────────────────────────────
OS := $(shell uname -s)
@@ -24,7 +30,9 @@ else
UDEV_RULE := /etc/udev/rules.d/99-streamdeck.rules
endif
.PHONY: build build-helper build-init install install-helper install-watchdog reinstall uninstall uninstall-helper uninstall-watchdog udev
.PHONY: build build-helper build-init build-ctl install install-ctl install-helper install-watchdog \
install-plugin uninstall-plugin validate-plugin reinstall uninstall uninstall-helper \
uninstall-watchdog udev
# ── Build ─────────────────────────────────────────────────────────────────────
@@ -37,6 +45,9 @@ build-helper:
build-init:
go build -o $(INIT) ./cmd/streamdeck-init/
build-ctl:
go build -o $(CTL) ./cmd/streamdeck-ctl/
# ── Install ───────────────────────────────────────────────────────────────────
# Interactive install — prompts for dotfiles directory, installs binary + service.
@@ -223,6 +234,47 @@ reinstall: build
fi
endif
# ── streamdeck-ctl + Omarchy shell plugin ─────────────────────────────────────
# The CLI the Omarchy plugin drives. Also useful on its own for scripting.
install-ctl: build-ctl
mkdir -p $(BIN_DIR)
install -m 755 $(CTL) $(BIN_DIR)/$(CTL)
@echo "Installed $(BIN_DIR)/$(CTL)"
# Symlink rather than copy so edits to the QML are live after a shell rescan.
install-plugin: install-ctl
@if [ ! -d "$(HOME)/.config/omarchy" ]; then \
echo "Omarchy shell config not found at ~/.config/omarchy — is Omarchy installed?"; \
exit 1; \
fi
mkdir -p $(HOME)/.config/omarchy/plugins
@if [ -e "$(PLUGIN_DIR)" ] && [ ! -L "$(PLUGIN_DIR)" ]; then \
echo "$(PLUGIN_DIR) exists and is not a symlink — remove it first."; \
exit 1; \
fi
ln -sfn "$(PLUGIN_SRC)" "$(PLUGIN_DIR)"
# Validate the source tree, not the link: `omarchy plugin validate` refuses a
# symlinked plugin folder by design, but the shell resolves it happily.
@omarchy plugin validate "$(PLUGIN_SRC)" || true
@omarchy-shell shell rescanPlugins >/dev/null 2>&1 || true
@echo ""
@echo "Plugin linked: $(PLUGIN_DIR) -> $(PLUGIN_SRC)"
@echo "Enable it with: omarchy plugin enable $(PLUGIN_ID) right"
validate-plugin:
omarchy plugin validate "$(PLUGIN_SRC)"
@command -v qmllint >/dev/null 2>&1 && \
qmllint -I $${OMARCHY_PATH:-/usr/share/omarchy}/shell "$(PLUGIN_SRC)"/*.qml || \
echo "qmllint not installed — skipped QML lint"
uninstall-plugin:
omarchy plugin disable $(PLUGIN_ID) 2>/dev/null || true
rm -f "$(PLUGIN_DIR)"
rm -f $(BIN_DIR)/$(CTL)
@omarchy-shell shell rescanPlugins >/dev/null 2>&1 || true
@echo "Plugin unlinked and $(CTL) removed."
# ── Uninstall ─────────────────────────────────────────────────────────────────
ifeq ($(OS),Darwin)

View File

@@ -6,6 +6,20 @@ privileged helper — see [README § Privileged commands](README.md)).
---
## Shell plugin
Beyond the key commands below, streamdeck-go ships an Omarchy **bar widget** that
puts deck status, brightness, and inline key editing in the bar itself:
```bash
make install-plugin
omarchy plugin enable dev.woodard.streamdeck right
```
See [omarchy-plugin/README.md](omarchy-plugin/README.md).
---
## Terminal (Ghostty)
```yaml

117
README.md
View File

@@ -352,6 +352,45 @@ keys:
command: ""
```
### Folders
A key can open a **folder** — a second page of keys that replaces the deck until
you go back. Folders nest to any depth, and every folder gets a back key (slot 0
by default, drawn with a built-in arrow). Pressing the folder key's `command`, if
it has one, still runs.
```yaml
keys:
12:
icon: folder.png
text: Media
folder: media # name of an entry under folders:
folders:
media:
back: # optional — defaults to key 0, built-in arrow, "Back"
key: 0
icon: back.png # omit to use the built-in arrow
text: Back
keys: # same key types as the root page: icons, GIFs,
1: # text, toggles, modules, live text, priv: commands
icon: play.png
command: playerctl play-pause
2:
icon: more.png
folder: media-extra # folders can open other folders
media-extra:
keys:
1:
text: Next
command: playerctl next
```
Each page's animations, polls, and live-text refreshes are stopped when you leave
it and restarted when you return. A config reload always lands on the root page.
Folder keys show up in `streamdeck-ctl status` with kind `folder`, and every
folder's keys are listed under `folders`.
### Status / toggle keys
A key can poll any shell command on an interval and show one of two icons based on the result. Pressing the button runs `command` as usual, and the icon re-checks ~400 ms later so it reflects the new state immediately.
@@ -823,6 +862,84 @@ keys:
**Note — absolute paths in modules:** The example templates call `/usr/local/bin/obs-cmd` rather than just `obs-cmd`. This is because launchd (macOS) and systemd (Linux) give the service a minimal `PATH` that doesn't include `/usr/local/bin` or Homebrew. Use the absolute path returned by `which obs-cmd` in your own module templates, or set `PATH` in the launchd plist / systemd unit.
#### Camera module (Linux)
The example `modules.yaml` includes a `camera` module that drives any UVC webcam's V4L2 controls — zoom, pan/tilt, focus, exposure, white balance, brightness/contrast and so on — through [`cameractrls`](https://github.com/soyersoyer/cameractrls).
**Setup:**
1. Install `cameractrls` (Arch: `pacman -S cameractrls`; other distros: see the project README). The module calls `/usr/bin/cameractrls`.
2. Point the module at your camera's stable device path (it defaults to `/dev/video0`, which renumbers across reboots):
```bash
ls -l /dev/v4l/by-id/ # pick the *-video-index0 entry for your camera
echo 'CAMERA_DEVICE=/dev/v4l/by-id/usb-046d_Logitech_BRIO_XXXX-video-index0' >> ~/.config/streamdeck-go/.env
```
Any function also accepts a per-key `device` param for multi-camera setups.
3. List the control names and ranges your camera exposes:
```bash
cameractrls -d /dev/video0 -l
```
**Functions:**
| Function | Purpose | Params |
|---|---|---|
| `set` | Set one or more controls (comma-separated). Also fires button controls such as `preset=save_1` / `preset=load_1` or `color_preset=vivid` | `controls` |
| `toggle` | Flip a control between two values — 0/1 switches or menu controls | `control`, `a`, `b` |
| `adjust` | Step a numeric control by `delta` (negative to decrease), clamped to the min/max the camera reports. `extra` is applied first on the same call, e.g. `auto_exposure=manual_mode` so stepping exposure also leaves auto | `control`, `delta`, `extra` |
| `is` | Poll helper: exits 0 when `control == value` | `control`, `value` |
**Example — a camera folder with FOV presets, a stepper pair and a status toggle:**
```yaml
keys:
18:
icon: camera.svg
text: "Camera"
folder: camera
folders:
camera:
keys:
1:
icon: fov-wide.svg
text: "Wide"
module: camera
function: set
params: { controls: "logitech_brio_fov=90" }
3:
icon: zoom-out.svg
text: "Zoom −"
module: camera
function: adjust
params: { control: zoom_absolute, delta: "-25" }
4:
icon: zoom-in.svg
text: "Zoom +"
module: camera
function: adjust
params: { control: zoom_absolute, delta: "25" }
14:
icon_true: af-on.svg # green = autofocus on
icon_false: af-off.svg # grey = manual focus
text: "Autofocus"
module: camera
function: toggle
params: { control: focus_automatic_continuous, a: "1", b: "0" }
poll:
module: camera
function: is
params: { control: focus_automatic_continuous, value: "1" }
interval: 3s
```
**Notes:**
- `cameractrls` exits 0 even for an unknown control name, so poll blocks must use the `is` function — never rely on a bare exit code.
- Each call starts a Python process (~100 ms). A 3 s poll interval is comfortable; polls only run while the folder is open.
- `cameractrls -l` prints Python deprecation warnings on stderr; the module templates discard stderr so the output stays clean.
- SVG icons are rasterised by `oksvg`, which does **not** honour a viewBox x/y offset for centring (it applies the offset in pixels before scaling). To centre a glyph and leave room for a text label, keep `viewBox="0 0 S S"` and wrap the paths in `<g transform="translate(x y)">`.
#### Using modules in config.yaml
Reference a module function instead of writing inline commands:

450
cmd/streamdeck-ctl/main.go Normal file
View File

@@ -0,0 +1,450 @@
// Command streamdeck-ctl inspects and edits the streamdeck-go configuration
// from outside the daemon.
//
// It exists so that front-ends — principally the Omarchy shell plugin — can read
// a machine-readable snapshot of the deck and rewrite keys without reimplementing
// YAML handling or device probing. Every mutating command writes config.yaml
// atomically; the daemon's fsnotify watcher picks the change up and hot-reloads,
// so edits take effect without restarting anything.
//
// All output is JSON on stdout. Errors go to stderr and set a non-zero exit code.
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"os/exec"
"runtime"
"strconv"
"strings"
"git.i0t.app/lwoodard/streamdeck-go/internal/config"
"git.i0t.app/lwoodard/streamdeck-go/internal/modules"
)
func main() {
cfgPath := flag.String("config", config.DefaultConfigPath(), "path to config file")
flag.Usage = usage
flag.Parse()
args := flag.Args()
if len(args) == 0 {
usage()
os.Exit(2)
}
var err error
switch args[0] {
case "status":
err = cmdStatus(*cfgPath)
case "brightness":
err = cmdBrightness(*cfgPath, args[1:])
case "key":
err = cmdKey(*cfgPath, args[1:])
case "daemon":
err = cmdDaemon(args[1:])
case "help", "-h", "--help":
usage()
return
default:
err = fmt.Errorf("unknown command %q", args[0])
}
if err != nil {
fmt.Fprintf(os.Stderr, "streamdeck-ctl: %v\n", err)
// Emit a JSON error too, so a front-end parsing stdout gets something
// structured rather than an empty buffer.
emit(map[string]any{"ok": false, "error": err.Error()})
os.Exit(1)
}
}
func usage() {
fmt.Fprint(os.Stderr, `streamdeck-ctl — inspect and edit the streamdeck-go config
usage: streamdeck-ctl [-config PATH] <command> [args]
commands:
status print a JSON snapshot: device, daemon, keys, icons, modules
brightness <0-100> set deck brightness (hot-reloaded by the daemon)
key set <index> [flags] set or merge fields on a key
key clear <index> remove a key entirely
key press <index> run the key's command now
daemon <start|stop|restart|toggle|status>
key set flags (only the flags you pass are written):
-icon NAME -icon-true NAME -icon-false NAME
-text STR -text-color STR -text-command STR -refresh DUR
-command STR -module NAME -function NAME
-folder NAME (pressing the key opens that folder)
-param K=V (repeatable)
-poll-command STR -poll-match STR -poll-interval DUR
-unset FIELD (repeatable — removes a field)
`)
}
func emit(v any) {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
_ = enc.Encode(v)
}
// ── brightness ───────────────────────────────────────────────────────────────
func cmdBrightness(cfgPath string, args []string) error {
if len(args) != 1 {
return fmt.Errorf("brightness: expected a value 0-100")
}
value, err := strconv.Atoi(args[0])
if err != nil {
return fmt.Errorf("brightness: %q is not a number", args[0])
}
if value < 0 || value > 100 {
return fmt.Errorf("brightness: %d is out of range (0-100)", value)
}
if err := config.SetBrightness(cfgPath, value); err != nil {
return err
}
emit(map[string]any{"ok": true, "brightness": value})
return nil
}
// ── key ──────────────────────────────────────────────────────────────────────
func cmdKey(cfgPath string, args []string) error {
if len(args) < 2 {
return fmt.Errorf("key: expected <set|clear|press> <index>")
}
action := args[0]
index, err := strconv.Atoi(args[1])
if err != nil {
return fmt.Errorf("key: %q is not a key index", args[1])
}
if index < 0 {
return fmt.Errorf("key: index %d is negative", index)
}
switch action {
case "set":
return keySet(cfgPath, index, args[2:])
case "clear":
if err := config.ClearKey(cfgPath, index); err != nil {
return err
}
emit(map[string]any{"ok": true, "index": index, "cleared": true})
return nil
case "press":
return keyPress(cfgPath, index)
default:
return fmt.Errorf("key: unknown action %q", action)
}
}
// stringList collects a repeatable string flag.
type stringList []string
func (s *stringList) String() string { return strings.Join(*s, ",") }
func (s *stringList) Set(v string) error { *s = append(*s, v); return nil }
func keySet(cfgPath string, index int, args []string) error {
fs := flag.NewFlagSet("key set", flag.ContinueOnError)
// Flag name → YAML field name. Only flags actually passed get written, so a
// front-end can PATCH a single field without clobbering the rest of the key.
scalars := map[string]*string{
"icon": fs.String("icon", "", "icon filename inside icons_dir"),
"icon-true": fs.String("icon-true", "", "icon shown when poll matches"),
"icon-false": fs.String("icon-false", "", "icon shown when poll does not match"),
"text": fs.String("text", "", "text overlay (\\n for line breaks)"),
"text-color": fs.String("text-color", "", "white|black|red|blue|#RRGGBB"),
"text-command": fs.String("text-command", "", "command whose stdout becomes the overlay text"),
"refresh": fs.String("refresh", "", "how often to re-run text-command, e.g. 1s"),
"command": fs.String("command", "", "shell command to run on press"),
"folder": fs.String("folder", "", "folder (from the folders: block) the key opens"),
"module": fs.String("module", "", "module name from modules.yaml"),
"function": fs.String("function", "", "function name within the module"),
"poll-command": fs.String("poll-command", "", "command used to poll toggle state"),
"poll-match": fs.String("poll-match", "", "substring in poll output meaning 'on'"),
"poll-interval": fs.String("poll-interval", "", "poll interval, e.g. 2s"),
}
yamlField := map[string]string{
"icon": "icon", "icon-true": "icon_true", "icon-false": "icon_false",
"text": "text", "text-color": "text_color", "text-command": "text_command",
"refresh": "refresh", "command": "command", "module": "module", "function": "function",
"folder": "folder",
}
var params stringList
var unset stringList
fs.Var(&params, "param", "module parameter as KEY=VALUE (repeatable)")
fs.Var(&unset, "unset", "remove a field (repeatable)")
if err := fs.Parse(args); err != nil {
return err
}
edit := config.KeyEdit{Set: map[string]any{}}
poll := map[string]string{}
pollTouched := false
// flag.Visit only reports flags the caller actually set — that distinction is
// what makes this a merge rather than a full overwrite.
fs.Visit(func(f *flag.Flag) {
ptr, ok := scalars[f.Name]
if !ok {
return
}
if field, isTop := yamlField[f.Name]; isTop {
edit.Set[field] = *ptr
return
}
switch f.Name {
case "poll-command":
poll["command"] = *ptr
pollTouched = true
case "poll-match":
poll["match"] = *ptr
pollTouched = true
case "poll-interval":
poll["interval"] = *ptr
pollTouched = true
}
})
if len(params) > 0 {
parsed := map[string]string{}
for _, kv := range params {
name, value, found := strings.Cut(kv, "=")
if !found || name == "" {
return fmt.Errorf("key set: -param %q is not KEY=VALUE", kv)
}
parsed[name] = value
}
edit.Set["params"] = parsed
}
if pollTouched {
// The poll block is written whole: it is small, and merging into a nested
// mapping would need the existing values read back anyway.
existing := existingPoll(cfgPath, index)
for name, value := range poll {
existing[name] = value
}
for name, value := range existing {
if value == "" {
delete(existing, name)
}
}
if len(existing) == 0 {
edit.Unset = append(edit.Unset, "poll")
} else {
edit.Set["poll"] = existing
}
}
edit.Unset = append(edit.Unset, normalizeUnset(unset)...)
if len(edit.Set) == 0 && len(edit.Unset) == 0 {
return fmt.Errorf("key set: nothing to change — pass at least one field flag")
}
if err := config.ApplyKeyEdit(cfgPath, index, edit); err != nil {
return err
}
emit(map[string]any{"ok": true, "index": index, "set": edit.Set, "unset": edit.Unset})
return nil
}
// normalizeUnset accepts either flag-style or YAML-style field names, so callers
// can pass -unset icon-true or -unset icon_true.
func normalizeUnset(names []string) []string {
out := make([]string, 0, len(names))
for _, name := range names {
out = append(out, strings.ReplaceAll(strings.TrimSpace(name), "-", "_"))
}
return out
}
// existingPoll reads the current poll block for a key so partial poll edits merge
// instead of dropping the fields the caller didn't mention.
func existingPoll(cfgPath string, index int) map[string]string {
out := map[string]string{}
cfg, err := config.Load(cfgPath)
if err != nil {
return out
}
key, ok := cfg.Keys[index]
if !ok || key.Poll == nil {
return out
}
if key.Poll.Command != "" {
out["command"] = key.Poll.Command
}
if key.Poll.Match != "" {
out["match"] = key.Poll.Match
}
if key.Poll.Interval != "" {
out["interval"] = key.Poll.Interval
}
return out
}
// keyPress runs the key's command exactly as the daemon would, including module
// resolution, so a front-end can fire a key without touching the hardware.
func keyPress(cfgPath string, index int) error {
cfg, err := config.Load(cfgPath)
if err != nil {
return err
}
key, ok := cfg.Keys[index]
if !ok {
return fmt.Errorf("key %d is not configured", index)
}
if key.Folder != "" && key.Command == "" && key.Module == "" {
return fmt.Errorf("key %d opens folder %q — navigation only happens on the deck", index, key.Folder)
}
command := key.Command
if key.Module != "" && key.Function != "" {
reg, err := modules.LoadRegistry(config.ModulesPath(cfgPath))
if err != nil {
return err
}
command, err = reg.Resolve(key.Module, key.Function, key.Params)
if err != nil {
return err
}
}
if strings.TrimSpace(command) == "" {
return fmt.Errorf("key %d has no command to run", index)
}
if after, found := strings.CutPrefix(command, "priv:"); found {
return fmt.Errorf("key %d runs privileged command %q — press it on the deck instead", index, after)
}
// Mirror the daemon's press semantics: fire and forget. Waiting here would
// wedge the caller (and the panel's serialized action pipeline) on any key
// that launches a long-lived app. The exit code covers "could not start";
// the command's own outcome is its business, as on the hardware.
cmd := exec.Command("sh", "-c", command)
cmd.Env = pressEnvironment()
if err := cmd.Start(); err != nil {
return fmt.Errorf("key %d: start command: %w", index, err)
}
go func() { _ = cmd.Wait() }() // reap if it finishes before we exit
emit(map[string]any{"ok": true, "index": index, "started": true})
return nil
}
// pressEnvironment builds the env a pressed key runs under. The daemon runs a
// key with the systemd user manager's environment, but this CLI is usually
// invoked from the shell (a different, often smaller env) — so a module key
// whose token was imported only into the user manager would work on hardware
// and silently no-op from the panel. Merging the manager's variables over the
// caller's closes that gap on Linux; macOS keeps the caller's env (launchd has
// no equivalent bulk query).
func pressEnvironment() []string {
env := os.Environ()
if runtime.GOOS == "darwin" {
return env
}
out, err := exec.Command("systemctl", "--user", "show-environment").Output()
if err != nil {
return env
}
// Overlay by name rather than appending: with duplicate keys in an env
// array, which copy getenv returns is libc-dependent.
merged := make(map[string]string, len(env))
order := make([]string, 0, len(env))
set := func(line string) {
name, value, found := strings.Cut(line, "=")
if !found || name == "" {
return
}
if _, seen := merged[name]; !seen {
order = append(order, name)
}
merged[name] = value
}
for _, line := range env {
set(line)
}
for _, line := range strings.Split(string(out), "\n") {
// systemd shell-quotes values that need it ($'...'); those few can't be
// used verbatim, and dropping them beats injecting mangled quoting.
if strings.Contains(line, "=$'") {
continue
}
// Manager value wins over the caller's: that's what the hardware sees.
set(line)
}
result := make([]string, 0, len(order))
for _, name := range order {
result = append(result, name+"="+merged[name])
}
return result
}
// ── daemon ───────────────────────────────────────────────────────────────────
func cmdDaemon(args []string) error {
if len(args) != 1 {
return fmt.Errorf("daemon: expected start|stop|restart|toggle|status")
}
action := args[0]
if action == "toggle" {
if daemonState().Active {
action = "stop"
} else {
action = "start"
}
}
switch action {
case "status":
emit(map[string]any{"ok": true, "daemon": daemonState()})
return nil
case "start", "stop", "restart":
if err := controlDaemon(action); err != nil {
return err
}
emit(map[string]any{"ok": true, "action": action, "daemon": daemonState()})
return nil
default:
return fmt.Errorf("daemon: unknown action %q", action)
}
}
func controlDaemon(action string) error {
if runtime.GOOS == "darwin" {
label := "com.woodarddigital.streamdeck-go"
plist := os.Getenv("HOME") + "/Library/LaunchAgents/" + label + ".plist"
var cmd *exec.Cmd
switch action {
case "start":
cmd = exec.Command("launchctl", "load", "-w", plist)
case "stop":
cmd = exec.Command("launchctl", "unload", "-w", plist)
case "restart":
_ = exec.Command("launchctl", "unload", "-w", plist).Run()
cmd = exec.Command("launchctl", "load", "-w", plist)
}
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("launchctl %s: %v: %s", action, err, strings.TrimSpace(string(out)))
}
return nil
}
out, err := exec.Command("systemctl", "--user", action, daemonUnit).CombinedOutput()
if err != nil {
return fmt.Errorf("systemctl --user %s %s: %v: %s", action, daemonUnit, err, strings.TrimSpace(string(out)))
}
return nil
}

View File

@@ -0,0 +1,443 @@
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strings"
"time"
"git.i0t.app/lwoodard/streamdeck-go/internal/config"
"git.i0t.app/lwoodard/streamdeck-go/internal/device"
"git.i0t.app/lwoodard/streamdeck-go/internal/modules"
)
const daemonUnit = "streamdeck-go.service"
// Status is the full snapshot consumed by front-ends. Everything a panel needs
// to render in one call — polling several small commands from QML would be both
// slower and racier.
type Status struct {
OK bool `json:"ok"`
ConfigPath string `json:"configPath"`
ModulesPath string `json:"modulesPath"`
IconsDir string `json:"iconsDir"`
Brightness int `json:"brightness"`
ConfigError string `json:"configError,omitempty"`
Daemon DaemonStatus `json:"daemon"`
Device DeviceStatus `json:"device"`
Keys []KeyStatus `json:"keys"`
Folders []FolderStatus `json:"folders"`
Icons []string `json:"icons"`
Modules []ModuleStatus `json:"modules"`
Warnings []string `json:"warnings,omitempty"`
}
type DaemonStatus struct {
Unit string `json:"unit"`
Active bool `json:"active"`
Enabled bool `json:"enabled"`
State string `json:"state"` // systemd ActiveState, e.g. "active", "failed"
Sub string `json:"sub"` // systemd SubState, e.g. "running"
SinceSec int64 `json:"sinceSec"` // seconds since it entered the active state, 0 if unknown
}
type DeviceStatus struct {
Connected bool `json:"connected"`
Known bool `json:"known"` // product ID is in the supported-models table
Model string `json:"model"`
VendorID string `json:"vendorId"`
ProductID string `json:"productId"`
KeyCount int `json:"keyCount"`
Cols int `json:"cols"`
Rows int `json:"rows"`
ImageWidth int `json:"imageWidth"`
ImageHeight int `json:"imageHeight"`
Error string `json:"error,omitempty"`
}
// KeyStatus describes one configured key. Only populated slots appear; the grid
// shape comes from Device.Cols/Rows so a front-end can lay out empty slots.
type KeyStatus struct {
Index int `json:"index"`
Kind string `json:"kind"` // empty|static|text|toggle|module|folder
Label string `json:"label"` // short human label for a grid cell
Icon string `json:"icon,omitempty"`
IconPath string `json:"iconPath,omitempty"` // absolute, for previews
IconTrue string `json:"iconTrue,omitempty"`
IconFalse string `json:"iconFalse,omitempty"`
Text string `json:"text,omitempty"`
TextColor string `json:"textColor,omitempty"`
TextCommand string `json:"textCommand,omitempty"`
Refresh string `json:"refresh,omitempty"`
Command string `json:"command,omitempty"`
Module string `json:"module,omitempty"`
Function string `json:"function,omitempty"`
Params map[string]string `json:"params,omitempty"`
Privileged bool `json:"privileged"`
Poll *PollStatus `json:"poll,omitempty"`
Folder string `json:"folder,omitempty"` // folder the key opens on press
}
// FolderStatus is one sub-page from the folders: block, with the same key
// shape as the root page so a front-end can render it with the same grid.
type FolderStatus struct {
Name string `json:"name"`
BackKey int `json:"backKey"`
BackIcon string `json:"backIcon,omitempty"`
BackText string `json:"backText,omitempty"`
Keys []KeyStatus `json:"keys"`
}
type PollStatus struct {
Command string `json:"command,omitempty"`
Match string `json:"match,omitempty"`
Interval string `json:"interval,omitempty"`
Module string `json:"module,omitempty"`
Function string `json:"function,omitempty"`
}
type ModuleStatus struct {
Name string `json:"name"`
Functions []FunctionStatus `json:"functions"`
}
type FunctionStatus struct {
Name string `json:"name"`
Params map[string]string `json:"params,omitempty"`
}
func cmdStatus(cfgPath string) error {
status := Status{
OK: true,
ConfigPath: cfgPath,
ModulesPath: config.ModulesPath(cfgPath),
Keys: []KeyStatus{},
Folders: []FolderStatus{},
Icons: []string{},
Modules: []ModuleStatus{},
}
cfg, err := config.Load(cfgPath)
if err != nil {
// A missing or malformed config is worth reporting in the panel rather
// than failing the whole call — daemon and device state are still useful.
status.ConfigError = err.Error()
status.Daemon = daemonState()
status.Device = deviceState(device.VendorID, 0x00ba)
emit(status)
return nil
}
status.IconsDir = cfg.IconsDir
status.Brightness = cfg.Brightness
status.Daemon = daemonState()
status.Device = deviceState(cfg.Device.VendorID, cfg.Device.ProductID)
reg, err := modules.LoadRegistry(status.ModulesPath)
if err != nil {
status.Warnings = append(status.Warnings, fmt.Sprintf("modules.yaml: %v", err))
reg = &modules.Registry{}
}
status.Modules = moduleList(reg)
status.Keys = keyList(cfg, reg)
status.Folders = folderList(cfg, reg)
status.Icons = iconList(cfg.IconsDir)
emit(status)
return nil
}
func keyList(cfg *config.Config, reg *modules.Registry) []KeyStatus {
return keyEntries(cfg.Keys, cfg.IconsDir, reg)
}
// folderList renders every folder in name order.
func folderList(cfg *config.Config, reg *modules.Registry) []FolderStatus {
names := make([]string, 0, len(cfg.Folders))
for name := range cfg.Folders {
names = append(names, name)
}
sort.Strings(names)
out := make([]FolderStatus, 0, len(names))
for _, name := range names {
f := cfg.Folders[name]
out = append(out, FolderStatus{
Name: name,
BackKey: f.BackKey(),
BackIcon: f.Back.Icon,
BackText: f.Back.Text,
Keys: keyEntries(f.Keys, cfg.IconsDir, reg),
})
}
return out
}
func keyEntries(keys map[int]config.KeyConfig, iconsDir string, reg *modules.Registry) []KeyStatus {
indices := make([]int, 0, len(keys))
for index := range keys {
indices = append(indices, index)
}
sort.Ints(indices)
out := make([]KeyStatus, 0, len(indices))
for _, index := range indices {
key := keys[index]
entry := KeyStatus{
Index: index,
Icon: key.Icon,
IconTrue: key.IconTrue,
IconFalse: key.IconFalse,
Text: key.Text,
TextColor: key.TextColor,
TextCommand: key.TextCommand,
Refresh: key.Refresh,
Command: key.Command,
Module: key.Module,
Function: key.Function,
Params: key.Params,
Folder: key.Folder,
}
if icon := firstNonEmpty(key.Icon, key.IconTrue, key.IconFalse); icon != "" {
entry.IconPath = resolveIcon(iconsDir, icon)
}
// The rendered template can inline secrets ({{env "TOKEN"}}), so it never
// leaves this process — it is only inspected for the priv: prefix.
entry.Privileged = strings.HasPrefix(entry.Command, "priv:")
if !entry.Privileged && key.Module != "" && key.Function != "" {
if resolved, err := reg.Resolve(key.Module, key.Function, key.Params); err == nil {
entry.Privileged = strings.HasPrefix(resolved, "priv:")
}
}
if key.Poll != nil {
entry.Poll = &PollStatus{
Command: key.Poll.Command,
Match: key.Poll.Match,
Interval: key.Poll.Interval,
Module: key.Poll.Module,
Function: key.Poll.Function,
}
}
entry.Kind = keyKind(key)
entry.Label = keyLabel(key)
out = append(out, entry)
}
return out
}
func keyKind(key config.KeyConfig) string {
switch {
case key.Folder != "":
return "folder"
case key.Poll != nil:
return "toggle"
case key.Module != "":
return "module"
case key.Icon == "" && (key.Text != "" || key.TextCommand != ""):
return "text"
case key.Icon != "":
return "static"
default:
return "empty"
}
}
// keyLabel produces a short label for a grid cell: whatever identifies the key
// most directly to the person who wrote it.
func keyLabel(key config.KeyConfig) string {
if key.Text != "" {
if line, _, _ := strings.Cut(key.Text, "\n"); strings.TrimSpace(line) != "" {
return strings.TrimSpace(line)
}
}
if key.Folder != "" {
return key.Folder
}
if key.Function != "" {
return strings.ReplaceAll(key.Function, "_", " ")
}
if icon := firstNonEmpty(key.Icon, key.IconTrue, key.IconFalse); icon != "" {
base := filepath.Base(icon)
return strings.TrimSuffix(base, filepath.Ext(base))
}
if key.Command != "" {
command := strings.TrimPrefix(key.Command, "priv:")
if field := strings.Fields(command); len(field) > 0 {
return filepath.Base(field[0])
}
}
if key.TextCommand != "" {
return "live text"
}
return "key"
}
func moduleList(reg *modules.Registry) []ModuleStatus {
if reg == nil || reg.Modules == nil {
return []ModuleStatus{}
}
names := make([]string, 0, len(reg.Modules))
for name := range reg.Modules {
names = append(names, name)
}
sort.Strings(names)
out := make([]ModuleStatus, 0, len(names))
for _, name := range names {
def := reg.Modules[name]
fnNames := make([]string, 0, len(def))
for fnName := range def {
fnNames = append(fnNames, fnName)
}
sort.Strings(fnNames)
functions := make([]FunctionStatus, 0, len(fnNames))
for _, fnName := range fnNames {
functions = append(functions, FunctionStatus{Name: fnName, Params: def[fnName].Params})
}
out = append(out, ModuleStatus{Name: name, Functions: functions})
}
return out
}
var iconExtensions = map[string]bool{
".png": true, ".jpg": true, ".jpeg": true, ".svg": true, ".gif": true,
}
func iconList(dir string) []string {
entries, err := os.ReadDir(dir)
if err != nil {
return []string{}
}
out := make([]string, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
if iconExtensions[strings.ToLower(filepath.Ext(entry.Name()))] {
out = append(out, entry.Name())
}
}
sort.Strings(out)
return out
}
// resolveIcon mirrors the daemon's lookup: names are relative to icons_dir
// unless they are already absolute.
func resolveIcon(iconsDir, name string) string {
if filepath.IsAbs(name) {
return name
}
return filepath.Join(iconsDir, name)
}
func deviceState(vendorID, productID uint16) DeviceStatus {
status := DeviceStatus{
VendorID: fmt.Sprintf("0x%04x", vendorID),
ProductID: fmt.Sprintf("0x%04x", productID),
}
if model, ok := device.Lookup(productID); ok {
status.Known = true
status.Model = model.Name
status.KeyCount = model.KeyCount
status.Cols = model.Cols
status.Rows = model.Rows
status.ImageWidth = model.ImageWidth
status.ImageHeight = model.ImageHeight
} else {
status.Model = "Unknown model"
}
present, err := device.Present(vendorID, productID)
if err != nil {
status.Error = err.Error()
return status
}
status.Connected = present
return status
}
func daemonState() DaemonStatus {
status := DaemonStatus{Unit: daemonUnit}
if runtime.GOOS == "darwin" {
// launchd has no equivalent of ActiveState; `launchctl list` printing the
// label at all means it is loaded, and a PID in column one means running.
label := "com.woodarddigital.streamdeck-go"
out, err := exec.Command("launchctl", "list").Output()
if err != nil {
status.State = "unknown"
return status
}
for _, line := range strings.Split(string(out), "\n") {
if !strings.HasSuffix(strings.TrimSpace(line), label) {
continue
}
status.Enabled = true
fields := strings.Fields(line)
if len(fields) > 0 && fields[0] != "-" {
status.Active = true
status.State = "active"
status.Sub = "running"
} else {
status.State = "inactive"
}
return status
}
status.State = "inactive"
return status
}
out, err := exec.Command("systemctl", "--user", "show", daemonUnit,
"-p", "ActiveState", "-p", "SubState", "-p", "UnitFileState",
"-p", "ActiveEnterTimestamp").Output()
if err != nil {
status.State = "unknown"
return status
}
fields := map[string]string{}
for _, line := range strings.Split(string(out), "\n") {
name, value, found := strings.Cut(strings.TrimSpace(line), "=")
if found {
fields[name] = value
}
}
status.State = fields["ActiveState"]
status.Sub = fields["SubState"]
status.Active = status.State == "active"
status.Enabled = strings.HasPrefix(fields["UnitFileState"], "enabled")
// Realtime rather than the *Monotonic pair: systemd's monotonic clock
// excludes suspend while /proc/uptime includes it, so mixing them overstated
// uptime by the suspended duration after every sleep. The timestamp is
// rendered in the local zone ("Sat 2026-08-16 10:26:11 MDT"), where the
// abbreviation parses unambiguously.
if ts := fields["ActiveEnterTimestamp"]; ts != "" && ts != "n/a" {
if entered, err := time.ParseInLocation("Mon 2006-01-02 15:04:05 MST", ts, time.Local); err == nil {
if since := time.Since(entered); since > 0 {
status.SinceSec = int64(since.Seconds())
}
}
}
return status
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}

View File

@@ -0,0 +1,30 @@
package main
import (
"image/color"
"testing"
)
// The built-in back arrow must leave the label band (bottom third) untouched
// and actually draw something above it.
func TestBackArrowLayout(t *testing.T) {
img := backArrow(96, 96, color.White)
lit := 0
for y := 0; y < 64; y++ {
for x := 0; x < 96; x++ {
if r, _, _, _ := img.At(x, y).RGBA(); r > 0 {
lit++
}
}
}
if lit < 200 {
t.Errorf("arrow region has only %d lit pixels", lit)
}
for y := 72; y < 96; y++ {
for x := 0; x < 96; x++ {
if r, _, _, _ := img.At(x, y).RGBA(); r > 0 {
t.Fatalf("label band pixel (%d,%d) is lit", x, y)
}
}
}
}

View File

@@ -14,6 +14,7 @@ import (
_ "image/jpeg"
_ "image/png"
"log"
"math"
"net"
"os"
"os/exec"
@@ -158,6 +159,12 @@ func main() {
// run initialises the deck and handles button presses until ctx is cancelled
// or the device dies. Returns true if the device died, false if ctx was cancelled.
//
// The deck shows one page at a time: the root page (cfg.Keys) or a folder
// (cfg.Folders[name]). Pressing a key with a `folder:` field pushes that folder;
// pressing a folder's back key pops it. Each page owns its own goroutines
// (animations, polls, live text) and is torn down completely before the next
// page is drawn, so a folder's keys never fight the root page's for a slot.
func run(ctx context.Context, sd *device.StreamDeck, cfg *config.Config, reg *modules.Registry) (deviceDied bool) {
// Recover from any panic inside this goroutine tree.
defer func() {
@@ -173,104 +180,15 @@ func run(ctx context.Context, sd *device.StreamDeck, cfg *config.Config, reg *mo
if err := sd.SetBrightness(cfg.Brightness); err != nil {
log.Printf("warn: brightness: %v", err)
}
for i := 0; i < sd.KeyCount(); i++ {
if err := sd.ClearKey(i); err != nil {
log.Printf("warn: clear key %d: %v", i, err)
}
}
// Track animation/poll goroutines so we can wait for them on exit.
var wg sync.WaitGroup
// Navigation stack of folder names; empty means the root page is showing.
var stack []string
current := openPage(ctx, sd, cfg, reg, "")
defer func() { current.stop() }()
// triggers lets button presses immediately re-poll toggle keys.
triggers := make(map[int]chan struct{})
for keyIdx, keyCfg := range cfg.Keys {
// Resolve module-based commands to shell strings before any other logic.
// keyCfg is a copy from the map, so we must write it back after modification.
if keyCfg.Module != "" {
resolved, err := reg.Resolve(keyCfg.Module, keyCfg.Function, keyCfg.Params)
if err != nil {
log.Printf("key %d: module: %v", keyIdx, err)
continue
}
keyCfg.Command = resolved
cfg.Keys[keyIdx] = keyCfg
}
if keyCfg.Poll != nil && keyCfg.Poll.Module != "" {
resolved, err := reg.Resolve(keyCfg.Poll.Module, keyCfg.Poll.Function, keyCfg.Poll.Params)
if err != nil {
log.Printf("key %d: poll module: %v", keyIdx, err)
continue
}
keyCfg.Poll.Command = resolved
cfg.Keys[keyIdx] = keyCfg
}
// Toggle/status key: managed by a polling goroutine.
if keyCfg.Poll != nil {
if keyCfg.IconTrue == "" || keyCfg.IconFalse == "" {
log.Printf("key %d: toggle key requires icon_on and icon_off", keyIdx)
continue
}
trigger := make(chan struct{}, 1)
triggers[keyIdx] = trigger
wg.Add(1)
go func(idx int, kCfg config.KeyConfig, trig chan struct{}) {
defer wg.Done()
defer func() {
if r := recover(); r != nil {
log.Printf("panic in pollKey %d: %v", idx, r)
}
}()
pollKey(ctx, sd, idx, kCfg, cfg.IconsDir, trig)
}(keyIdx, keyCfg, trigger)
continue
}
// Regular key: load icon once.
if keyCfg.Icon == "" {
// No icon — render text-only key on a black background.
if keyCfg.Text != "" {
bg := image.NewRGBA(image.Rect(0, 0, sd.ImageWidth(), sd.ImageHeight()))
img := overlayText(bg, keyCfg.Text, keyCfg.TextColor, sd.ImageWidth())
if err := sd.SetKeyImage(keyIdx, img); err != nil {
log.Printf("key %d: set image: %v", keyIdx, err)
}
}
continue
}
iconPath := filepath.Join(cfg.IconsDir, keyCfg.Icon)
if strings.ToLower(filepath.Ext(keyCfg.Icon)) == ".gif" {
frames, delays, err := loadGIF(sd, iconPath)
if err != nil {
log.Printf("key %d: load gif %q: %v", keyIdx, keyCfg.Icon, err)
continue
}
wg.Add(1)
go func(idx int, f [][]byte, d []time.Duration) {
defer wg.Done()
defer func() {
if r := recover(); r != nil {
log.Printf("panic in animateKey %d: %v", idx, r)
}
}()
animateKey(ctx, sd, idx, f, d)
}(keyIdx, frames, delays)
} else {
img, err := loadImage(iconPath)
if err != nil {
log.Printf("key %d: load icon %q: %v", keyIdx, keyCfg.Icon, err)
continue
}
if keyCfg.Text != "" {
img = overlayText(img, keyCfg.Text, keyCfg.TextColor, sd.ImageWidth())
}
if err := sd.SetKeyImage(keyIdx, img); err != nil {
log.Printf("key %d: set image: %v", keyIdx, err)
}
}
showPage := func(name string) {
current.stop()
current = openPage(ctx, sd, cfg, reg, name)
}
log.Printf("stream deck ready (%d keys)", sd.KeyCount())
@@ -281,7 +199,6 @@ func run(ctx context.Context, sd *device.StreamDeck, cfg *config.Config, reg *mo
for {
select {
case <-ctx.Done():
wg.Wait()
return false
default:
}
@@ -291,8 +208,6 @@ func run(ctx context.Context, sd *device.StreamDeck, cfg *config.Config, reg *mo
consecutiveErrors++
if consecutiveErrors >= 3 {
log.Printf("device error (gave up after %d attempts): %v", consecutiveErrors, err)
// Cancel animations, wait for them to stop, then signal device death.
wg.Wait()
return true
}
log.Printf("read error (%d/3): %v", consecutiveErrors, err)
@@ -306,11 +221,26 @@ func run(ctx context.Context, sd *device.StreamDeck, cfg *config.Config, reg *mo
}
for i, pressed := range buttons {
if pressed && !prev[i] {
keyCfg, ok := cfg.Keys[i]
if !ok || keyCfg.Command == "" {
continue
if !pressed || prev[i] {
continue
}
if i == current.backKey {
stack = stack[:len(stack)-1]
parent := ""
if len(stack) > 0 {
parent = stack[len(stack)-1]
}
log.Printf("key %d pressed → back to %s", i, pageName(parent))
showPage(parent)
break // this report belongs to the old page
}
keyCfg, ok := current.keys[i]
if !ok {
continue
}
if keyCfg.Command != "" {
log.Printf("key %d pressed → %s", i, keyCfg.Command)
go func(cmd string) {
defer func() {
@@ -320,21 +250,289 @@ func run(ctx context.Context, sd *device.StreamDeck, cfg *config.Config, reg *mo
}()
runCommand(cmd)
}(keyCfg.Command)
}
// For toggle keys, signal the poll goroutine to re-check state
// shortly after the command runs (gives the system time to update).
if ch, ok := triggers[i]; ok {
select {
case ch <- struct{}{}:
default:
}
// For toggle keys, signal the poll goroutine to re-check state
// shortly after the command runs (gives the system time to update).
if ch, ok := current.triggers[i]; ok {
select {
case ch <- struct{}{}:
default:
}
}
if keyCfg.Folder != "" {
if _, defined := cfg.Folders[keyCfg.Folder]; !defined {
log.Printf("key %d: folder %q is not defined under folders:", i, keyCfg.Folder)
continue
}
log.Printf("key %d pressed → open folder %q", i, keyCfg.Folder)
stack = append(stack, keyCfg.Folder)
showPage(keyCfg.Folder)
break // this report belongs to the old page
}
}
// Keys still held across a page swap stay "pressed" here, so the new
// page sees no edge for them until they are physically released.
prev = buttons
}
}
// pageName names a page for log lines.
func pageName(folder string) string {
if folder == "" {
return "root page"
}
return fmt.Sprintf("folder %q", folder)
}
// page is one screenful of keys and the goroutines keeping them drawn.
type page struct {
keys map[int]config.KeyConfig // module templates already resolved
triggers map[int]chan struct{} // per-key re-poll channels
backKey int // slot that navigates up, -1 on the root page
cancel context.CancelFunc
wg sync.WaitGroup
}
// stop tears the page down: cancels its goroutines, waits for them, and blanks
// every key so the next page starts from an empty deck.
func (p *page) stop() {
p.cancel()
p.wg.Wait()
}
// openPage clears the deck and draws the root page (folder == "") or the named
// folder, starting whatever goroutines its keys need. Keys that fail to set up
// are logged and skipped — one bad icon must not blank the whole page.
func openPage(parent context.Context, sd *device.StreamDeck, cfg *config.Config, reg *modules.Registry, folder string) *page {
ctx, cancel := context.WithCancel(parent)
p := &page{
keys: map[int]config.KeyConfig{},
triggers: map[int]chan struct{}{},
backKey: -1,
cancel: cancel,
}
for i := 0; i < sd.KeyCount(); i++ {
if err := sd.ClearKey(i); err != nil {
log.Printf("warn: clear key %d: %v", i, err)
}
}
source := cfg.Keys
if folder != "" {
f := cfg.Folders[folder]
source = f.Keys
p.backKey = f.BackKey()
if _, shadowed := source[p.backKey]; shadowed {
log.Printf("folder %q: key %d is the back key — its own definition is ignored", folder, p.backKey)
}
drawBackKey(sd, p.backKey, f.Back, cfg.IconsDir)
}
for keyIdx, keyCfg := range source {
if keyIdx == p.backKey {
continue
}
if keyCfg.Folder != "" {
if _, defined := cfg.Folders[keyCfg.Folder]; !defined {
log.Printf("key %d: folder %q is not defined under folders:", keyIdx, keyCfg.Folder)
}
}
resolved, ok := setupKey(ctx, sd, p, keyIdx, keyCfg, cfg.IconsDir, reg)
if ok {
p.keys[keyIdx] = resolved
}
}
return p
}
// setupKey resolves module templates, draws the key's initial image, and starts
// any goroutine the key needs (animation, poll, live text). It returns the key
// config with commands resolved — the button loop dispatches from that copy.
// ok is false when the key could not be set up at all.
func setupKey(ctx context.Context, sd *device.StreamDeck, p *page, keyIdx int, keyCfg config.KeyConfig, iconsDir string, reg *modules.Registry) (resolved config.KeyConfig, ok bool) {
// Resolve module-based commands to shell strings before any other logic.
if keyCfg.Module != "" {
cmd, err := reg.Resolve(keyCfg.Module, keyCfg.Function, keyCfg.Params)
if err != nil {
log.Printf("key %d: module: %v", keyIdx, err)
return keyCfg, false
}
keyCfg.Command = cmd
}
if keyCfg.Poll != nil && keyCfg.Poll.Module != "" {
cmd, err := reg.Resolve(keyCfg.Poll.Module, keyCfg.Poll.Function, keyCfg.Poll.Params)
if err != nil {
log.Printf("key %d: poll module: %v", keyIdx, err)
return keyCfg, false
}
poll := *keyCfg.Poll // don't mutate the shared config
poll.Command = cmd
keyCfg.Poll = &poll
}
// Optional $(...) sugar → text_command.
if keyCfg.TextCommand == "" {
if inner, ok := unwrapCmdSubst(keyCfg.Text); ok {
keyCfg.TextCommand = inner
keyCfg.Text = ""
}
}
spawn := func(name string, fn func()) {
p.wg.Add(1)
go func() {
defer p.wg.Done()
defer func() {
if r := recover(); r != nil {
log.Printf("panic in %s %d: %v", name, keyIdx, r)
}
}()
fn()
}()
}
// Dynamic text key: a refresh goroutine owns this key's image. It also
// handles an optional poll block (choosing icon_true/icon_false per tick),
// so it must take precedence over the plain toggle path below.
if keyCfg.TextCommand != "" {
baseIcon := keyCfg.Icon
if keyCfg.Poll != nil {
baseIcon = keyCfg.IconTrue
}
if strings.ToLower(filepath.Ext(baseIcon)) == ".gif" ||
strings.ToLower(filepath.Ext(keyCfg.IconFalse)) == ".gif" {
log.Printf("key %d: text_command not supported with GIF icon — skipping refresh", keyIdx)
return keyCfg, true
}
trigger := make(chan struct{}, 1)
p.triggers[keyIdx] = trigger
kCfg := keyCfg
spawn("refreshTextKey", func() { refreshTextKey(ctx, sd, keyIdx, kCfg, iconsDir, trigger) })
return keyCfg, true
}
// Toggle/status key: managed by a polling goroutine.
if keyCfg.Poll != nil {
if keyCfg.IconTrue == "" || keyCfg.IconFalse == "" {
log.Printf("key %d: toggle key requires icon_true and icon_false", keyIdx)
return keyCfg, true
}
trigger := make(chan struct{}, 1)
p.triggers[keyIdx] = trigger
kCfg := keyCfg
spawn("pollKey", func() { pollKey(ctx, sd, keyIdx, kCfg, iconsDir, trigger) })
return keyCfg, true
}
// Regular key: load icon once.
if keyCfg.Icon == "" {
// No icon — render text-only key on a black background.
if keyCfg.Text != "" {
bg := image.NewRGBA(image.Rect(0, 0, sd.ImageWidth(), sd.ImageHeight()))
img := overlayText(bg, keyCfg.Text, keyCfg.TextColor, sd.ImageWidth())
if err := sd.SetKeyImage(keyIdx, img); err != nil {
log.Printf("key %d: set image: %v", keyIdx, err)
}
}
return keyCfg, true
}
iconPath := filepath.Join(iconsDir, keyCfg.Icon)
if strings.ToLower(filepath.Ext(keyCfg.Icon)) == ".gif" {
frames, delays, err := loadGIF(sd, iconPath)
if err != nil {
log.Printf("key %d: load gif %q: %v", keyIdx, keyCfg.Icon, err)
return keyCfg, true
}
spawn("animateKey", func() { animateKey(ctx, sd, keyIdx, frames, delays) })
return keyCfg, true
}
img, err := loadImage(iconPath)
if err != nil {
log.Printf("key %d: load icon %q: %v", keyIdx, keyCfg.Icon, err)
return keyCfg, true
}
if keyCfg.Text != "" {
img = overlayText(img, keyCfg.Text, keyCfg.TextColor, sd.ImageWidth())
}
if err := sd.SetKeyImage(keyIdx, img); err != nil {
log.Printf("key %d: set image: %v", keyIdx, err)
}
return keyCfg, true
}
// drawBackKey renders a folder's back key: the configured icon if there is
// one, otherwise a built-in chevron on black, with a "Back" label by default.
func drawBackKey(sd *device.StreamDeck, keyIdx int, back config.BackConfig, iconsDir string) {
if keyIdx < 0 || keyIdx >= sd.KeyCount() {
log.Printf("back key %d is outside the deck (%d keys)", keyIdx, sd.KeyCount())
return
}
var img image.Image
if back.Icon != "" {
loaded, err := loadImage(filepath.Join(iconsDir, back.Icon))
if err != nil {
log.Printf("back key %d: load icon %q: %v — using built-in arrow", keyIdx, back.Icon, err)
} else {
img = loaded
}
}
if img == nil {
img = backArrow(sd.ImageWidth(), sd.ImageHeight(), parseTextColor(back.TextColor))
}
text := back.Text
if text == "" && back.Icon == "" {
text = "Back"
}
if text != "" {
img = overlayText(img, text, back.TextColor, sd.ImageWidth())
}
if err := sd.SetKeyImage(keyIdx, img); err != nil {
log.Printf("back key %d: set image: %v", keyIdx, err)
}
}
// backArrow draws a left-pointing chevron in the upper part of a black key,
// leaving the bottom clear for the label.
func backArrow(w, h int, c color.Color) image.Image {
img := image.NewRGBA(image.Rect(0, 0, w, h))
draw.Draw(img, img.Bounds(), image.Black, image.Point{}, draw.Src)
fw, fh := float64(w), float64(h)
tip := [2]float64{fw * 0.36, fh * 0.40}
top := [2]float64{fw * 0.62, fh * 0.16}
bottom := [2]float64{fw * 0.62, fh * 0.64}
thickness := fw * 0.075
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
px, py := float64(x)+0.5, float64(y)+0.5
d := math.Min(distToSegment(px, py, top, tip), distToSegment(px, py, tip, bottom))
if d <= thickness {
img.Set(x, y, c)
}
}
}
return img
}
// distToSegment is the distance from point p to the segment a→b.
func distToSegment(px, py float64, a, b [2]float64) float64 {
dx, dy := b[0]-a[0], b[1]-a[1]
lenSq := dx*dx + dy*dy
t := 0.0
if lenSq > 0 {
t = ((px-a[0])*dx + (py-a[1])*dy) / lenSq
t = math.Max(0, math.Min(1, t))
}
cx, cy := a[0]+t*dx, a[1]+t*dy
return math.Hypot(px-cx, py-cy)
}
// pollKey watches the state of a toggle key and keeps its icon up to date.
// It polls on an interval and also re-polls when triggered (e.g. after a button press).
func pollKey(ctx context.Context, sd *device.StreamDeck, keyIdx int, keyCfg config.KeyConfig, iconsDir string, trigger <-chan struct{}) {
@@ -488,6 +686,119 @@ func queryPollState(poll *config.PollConfig) int {
return 0
}
// refreshTextKey periodically runs keyCfg.TextCommand and renders its stdout as
// the key's text overlay, compositing over the key's base icon each tick (never
// accumulating — overlayText always renders onto a fresh image from the base).
// If the key also has a Poll block, the base icon is chosen from the polled
// state (icon_true/icon_false) so a single goroutine owns the key's image.
func refreshTextKey(ctx context.Context, sd *device.StreamDeck, keyIdx int, keyCfg config.KeyConfig, iconsDir string, trigger <-chan struct{}) {
// Interval: default 1s, floor 250ms.
interval := time.Second
if keyCfg.Refresh != "" {
if d, err := time.ParseDuration(keyCfg.Refresh); err == nil && d > 0 {
interval = d
} else if err != nil {
log.Printf("key %d: invalid refresh %q, using 1s", keyIdx, keyCfg.Refresh)
}
}
if interval < 250*time.Millisecond {
interval = 250 * time.Millisecond
}
// Preload immutable base image(s). An empty name yields a black background.
load := func(name string) (image.Image, bool) {
if name == "" {
return image.NewRGBA(image.Rect(0, 0, sd.ImageWidth(), sd.ImageHeight())), true
}
img, err := loadImage(filepath.Join(iconsDir, name))
if err != nil {
log.Printf("key %d: load icon %q: %v", keyIdx, name, err)
return nil, false
}
return img, true
}
pollMode := keyCfg.Poll != nil && keyCfg.IconTrue != "" && keyCfg.IconFalse != ""
var baseImg, imgTrue, imgFalse image.Image
if pollMode {
var ok1, ok2 bool
imgTrue, ok1 = load(keyCfg.IconTrue)
imgFalse, ok2 = load(keyCfg.IconFalse)
if !ok1 || !ok2 {
return
}
} else {
var ok bool
baseImg, ok = load(keyCfg.Icon) // "" → black background
if !ok {
return
}
}
render := func() {
text := runTextCommand(keyCfg.TextCommand)
if text == "" {
text = keyCfg.Text // static fallback
}
base := baseImg
if pollMode {
if queryPollState(keyCfg.Poll) == 1 {
base = imgTrue
} else {
base = imgFalse
}
}
img := overlayText(base, text, keyCfg.TextColor, sd.ImageWidth())
if err := sd.SetKeyImage(keyIdx, img); err != nil {
log.Printf("key %d: set dynamic text image: %v", keyIdx, err)
}
}
render() // set immediately on startup
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
render()
case <-trigger:
// Wait briefly for a press-triggered command to take effect, then re-render.
select {
case <-ctx.Done():
return
case <-time.After(300 * time.Millisecond):
}
render()
}
}
}
// runTextCommand runs cmd via sh -c and returns its stdout with trailing
// whitespace/newlines trimmed. Stderr is dropped so it can't pollute the label.
func runTextCommand(cmd string) string {
out, err := exec.Command("sh", "-c", cmd).Output()
if err != nil {
log.Printf("text_command %q: %v", cmd, err)
}
return strings.TrimRight(string(out), " \t\r\n")
}
// unwrapCmdSubst returns the inner command if s is exactly "$( ... )".
func unwrapCmdSubst(s string) (string, bool) {
s = strings.TrimSpace(s)
if strings.HasPrefix(s, "$(") && strings.HasSuffix(s, ")") {
inner := strings.TrimSpace(s[2 : len(s)-1])
if inner != "" {
return inner, true
}
}
return "", false
}
// mustConnect blocks until the device opens successfully.
//
// Strategy: try quickly at first (device may just be enumerating), then settle
@@ -707,9 +1018,14 @@ func overlayText(img image.Image, text, textColorStr string, keySize int) image.
// Outline offsets for readability on any background.
offsets := [8]image.Point{
{-1, -1}, {0, -1}, {1, -1},
{-1, 0}, {1, 0},
{-1, 1}, {0, 1}, {1, 1},
{-1, -1},
{0, -1},
{1, -1},
{-1, 0},
{1, 0},
{-1, 1},
{0, 1},
{1, 1},
}
for i, line := range lines {
@@ -910,11 +1226,11 @@ func defaultConfigPath() string {
func ensureConfigDir(cfgPath string) error {
dir := filepath.Dir(cfgPath)
iconsDir := filepath.Join(dir, "icons")
if err := os.MkdirAll(iconsDir, 0755); err != nil {
if err := os.MkdirAll(iconsDir, 0o755); err != nil {
return err
}
if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
return os.WriteFile(cfgPath, []byte(defaultConfig(iconsDir)), 0644)
return os.WriteFile(cfgPath, []byte(defaultConfig(iconsDir)), 0o644)
}
return nil
}
@@ -946,4 +1262,3 @@ device:
keys: {}
`
}

View File

@@ -61,3 +61,66 @@ keys:
# command: pactl get-source-mute @DEFAULT_SOURCE@
# interval: 2s
# match: "yes"
# --- Dynamic text from command output ---
#
# text_command: a shell command whose stdout is rendered as the key's label,
# re-run every `refresh` interval (default 1s; floored to 250ms). Newlines
# in the output become label line breaks; trailing whitespace is trimmed.
# `text` (if set) is the static fallback shown before the first run or when
# the command errors / outputs nothing. Composited over `icon` each tick.
#
# NOTE: use the ABSOLUTE binary path — the service runs with a minimal PATH.
# Apple Silicon Homebrew installs may live at /opt/homebrew/bin/flow instead.
#
# Pomodoro (`flow`) live countdown — press toggles start/pause:
# 8:
# icon: pomodoro.png # base icon; text on top
# text_command: "/usr/local/bin/flow status --short" # prints e.g. WORK\n24:12 (real newline)
# refresh: 1s
# text_color: "#FFFFFF"
# command: "/usr/local/bin/flow toggle"
#
# Separate icon-swap indicator (running vs paused):
# 9:
# icon_true: running.png
# icon_false: paused.png
# command: "/usr/local/bin/flow toggle"
# poll:
# command: "/usr/local/bin/flow status" # prints: state=running phase=work remaining=24:12
# interval: 1s
# match: "state=running"
#
# Control keys:
# 10:
# icon: skip.png
# command: "/usr/local/bin/flow skip"
# 11:
# icon: stop.png
# command: "/usr/local/bin/flow stop"
# 12:
# icon: gui.png
# command: "/usr/local/bin/flow gui"
#
# A text-only live clock (no icon → white text on black), refreshed each second:
# 13:
# text_command: "date +%H:%M:%S"
# refresh: 1s
# Folders: a key with `folder:` opens a second page of keys. Every folder shows
# a back key (slot 0 by default, built-in arrow) that returns to the parent.
# Folders can open other folders.
#
# 7:
# icon: folder.png
# text: Media
# folder: media
#
# folders:
# media:
# back: # optional: key, icon, text, text_color
# key: 0
# keys:
# 1:
# icon: play.png
# command: playerctl play-pause

View File

@@ -27,6 +27,12 @@ type KeyConfig struct {
TextColor string `yaml:"text_color"` // text color: "white" (default), "black", "red", "blue", or hex "#RRGGBB"
Command string `yaml:"command"` // shell command to run on press
// Dynamic text: periodically run TextCommand and render its stdout as the
// key's text overlay. Refresh is the interval (default 1s, floor 250ms).
// Text (above) is the static fallback / initial value.
TextCommand string `yaml:"text_command"` // shell command whose stdout becomes the overlay text
Refresh string `yaml:"refresh"` // how often to re-run text_command, e.g. "1s" (default: "1s")
// Toggle/status keys: show different icons based on polled state.
IconTrue string `yaml:"icon_true"` // icon when poll match is true
IconFalse string `yaml:"icon_false"` // icon when poll match is false
@@ -36,6 +42,34 @@ type KeyConfig struct {
Module string `yaml:"module"`
Function string `yaml:"function"`
Params map[string]string `yaml:"params"`
// Folder key: pressing it swaps the deck to the named folder's keys
// (defined under the top-level `folders:` block). Folders nest — a folder's
// key may itself have a folder — and every folder shows a back key.
Folder string `yaml:"folder"`
}
// BackConfig customises the back key every folder shows. All fields are
// optional: the default is key 0 drawn with a built-in arrow and "Back" label.
type BackConfig struct {
Key *int `yaml:"key"` // slot index for the back key (default 0)
Icon string `yaml:"icon"` // icon filename relative to icons_dir (default: built-in arrow)
Text string `yaml:"text"` // label (default "Back")
TextColor string `yaml:"text_color"` // same values as KeyConfig.TextColor
}
// FolderConfig is a sub-page of keys shown while a folder is open.
type FolderConfig struct {
Back BackConfig `yaml:"back"`
Keys map[int]KeyConfig `yaml:"keys"`
}
// BackKey returns the slot index the folder's back key occupies.
func (f FolderConfig) BackKey() int {
if f.Back.Key != nil {
return *f.Back.Key
}
return 0
}
// DefaultConfigPath returns the default config file path, respecting XDG_CONFIG_HOME.
@@ -59,6 +93,9 @@ type Config struct {
Brightness int `yaml:"brightness"`
Device DeviceConfig `yaml:"device"`
Keys map[int]KeyConfig `yaml:"keys"`
// Folders are sub-pages opened by keys with a `folder:` field. The root
// page is Keys; a folder's keys replace it until its back key is pressed.
Folders map[string]FolderConfig `yaml:"folders"`
}
// DeviceConfig allows overriding USB IDs (defaults work for Stream Deck XL v2).

402
internal/config/edit.go Normal file
View File

@@ -0,0 +1,402 @@
package config
import (
"bytes"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"gopkg.in/yaml.v3"
)
// Editing works at the yaml.Node level rather than by marshalling the Config
// struct back out. The struct round-trip would drop every comment in the file
// and materialise defaults the user never wrote; node-level edits touch only
// the mapping entries that actually change.
//
// Writes are atomic (temp file in the same directory, then rename). The daemon
// watches both the config file and its parent directory, so the rename lands as
// a CREATE event on the directory watch and triggers a hot reload.
// KeyEdit describes a change to a single key. Fields present in Set are written
// (creating the key block if needed); names listed in Unset are removed. Unset
// is applied first, so a field named in both ends up set. Values may be strings,
// ints, or map[string]string (params).
type KeyEdit struct {
Set map[string]any
Unset []string
}
// SetBrightness rewrites the top-level brightness value, clamped to 0–100.
func SetBrightness(path string, value int) error {
if value < 0 {
value = 0
}
if value > 100 {
value = 100
}
return editDocument(path, func(root *yaml.Node) error {
mapSet(root, "brightness", scalarNode(strconv.Itoa(value), "!!int"))
return nil
})
}
// ApplyKeyEdit merges an edit into the keys block for the given index, creating
// the keys block or the key entry if either is missing.
func ApplyKeyEdit(path string, index int, edit KeyEdit) error {
return editDocument(path, func(root *yaml.Node) error {
keys := mapGet(root, "keys")
if keys == nil || keys.Kind != yaml.MappingNode {
keys = &yaml.Node{Kind: yaml.MappingNode}
mapSet(root, "keys", keys)
}
// A fresh config from the installer carries `keys: {}` — a flow-style
// empty mapping. Force block style or new entries render inline.
keys.Style = 0
entry := mapGet(keys, strconv.Itoa(index))
if entry == nil || entry.Kind != yaml.MappingNode {
entry = &yaml.Node{Kind: yaml.MappingNode}
// The index must be an int scalar: config.Load unmarshals keys into
// map[int]KeyConfig, and yaml.v3 refuses to coerce a !!str key ("25":)
// into an int — a string-tagged key here bricks the config on reload.
mapSetNode(keys, scalarNode(strconv.Itoa(index), "!!int"), entry)
}
entry.Style = 0
for _, name := range edit.Unset {
mapDelete(entry, name)
}
// Deterministic ordering so repeated edits don't shuffle the file, and so
// a brand-new key block reads in a sensible order.
for _, name := range sortedFieldNames(edit.Set) {
node, err := valueNode(edit.Set[name])
if err != nil {
return fmt.Errorf("field %q: %w", name, err)
}
mapSet(entry, name, node)
}
return nil
})
}
// ClearKey removes a key entry entirely. Removing a key that isn't there is not
// an error — the caller's intent (that slot ends up empty) is already satisfied.
func ClearKey(path string, index int) error {
return editDocument(path, func(root *yaml.Node) error {
keys := mapGet(root, "keys")
if keys == nil || keys.Kind != yaml.MappingNode {
return nil
}
mapDelete(keys, strconv.Itoa(index))
return nil
})
}
// fieldOrder is the order key fields are written in when a key block is built or
// extended, so generated YAML reads the way the hand-written examples do.
var fieldOrder = []string{
"icon", "icon_true", "icon_false",
"text", "text_color", "text_command", "refresh",
"module", "function", "params",
"command", "folder", "poll",
}
func sortedFieldNames(m map[string]any) []string {
rank := make(map[string]int, len(fieldOrder))
for i, name := range fieldOrder {
rank[name] = i
}
names := make([]string, 0, len(m))
for name := range m {
names = append(names, name)
}
sort.Slice(names, func(i, j int) bool {
ri, oki := rank[names[i]]
rj, okj := rank[names[j]]
if oki != okj {
return oki // known fields sort before unknown ones
}
if oki && ri != rj {
return ri < rj
}
return names[i] < names[j]
})
return names
}
// editDocument parses path, hands the root mapping to fn, and writes the result
// back atomically. The file must already exist and parse.
func editDocument(path string, fn func(root *yaml.Node) error) error {
raw, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read config %q: %w", path, err)
}
// yaml.v3 keeps comments but discards blank lines, so a config edited from a
// GUI would slowly lose its paragraph breaks. Standing them in as sentinel
// comments carries them through the round-trip.
data, blanksProtected := protectBlankLines(raw)
var doc yaml.Node
if err := yaml.Unmarshal(data, &doc); err != nil {
return fmt.Errorf("parse config %q: %w", path, err)
}
var root *yaml.Node
switch {
case doc.Kind == yaml.DocumentNode && len(doc.Content) > 0:
root = doc.Content[0]
case doc.Kind == 0:
// Empty (or comment-only) file — start a mapping so edits have somewhere to go.
root = &yaml.Node{Kind: yaml.MappingNode}
doc = yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{root}}
default:
return fmt.Errorf("config %q: unexpected document shape", path)
}
if root.Kind != yaml.MappingNode {
return fmt.Errorf("config %q: top level is not a mapping", path)
}
if err := fn(root); err != nil {
return err
}
var buf bytes.Buffer
enc := yaml.NewEncoder(&buf)
enc.SetIndent(2)
if err := enc.Encode(&doc); err != nil {
return fmt.Errorf("encode config: %w", err)
}
if err := enc.Close(); err != nil {
return fmt.Errorf("encode config: %w", err)
}
out := buf.Bytes()
if blanksProtected {
out = restoreBlankLines(out)
}
return writeAtomic(path, out)
}
// blankSentinel stands in for a blank line across the parse/encode round-trip.
// It is deliberately obscure so it cannot collide with a real comment.
const blankSentinel = "#__streamdeck_go_blank_line__"
// protectBlankLines rewrites blank lines as sentinel comments. It reports false
// (and leaves the input untouched) when the document contains a block scalar,
// where an inserted line would become part of the string's content rather than
// structure. Losing blank-line formatting is a cosmetic regression; corrupting a
// multi-line command is not, so the ambiguous case declines to act.
func protectBlankLines(data []byte) ([]byte, bool) {
if blockScalarPattern.Match(data) {
return data, false
}
lines := strings.Split(string(data), "\n")
changed := false
for i, line := range lines {
if strings.TrimSpace(line) == "" && i != len(lines)-1 {
lines[i] = blankSentinel
changed = true
}
}
if !changed {
return data, false
}
return []byte(strings.Join(lines, "\n")), true
}
// restoreBlankLines turns sentinel comments back into blank lines. The encoder
// may have indented them along with the comment block they joined, so the match
// is on the trimmed line.
func restoreBlankLines(data []byte) []byte {
lines := strings.Split(string(data), "\n")
for i, line := range lines {
if strings.TrimSpace(line) == blankSentinel {
lines[i] = ""
}
}
return []byte(strings.Join(lines, "\n"))
}
// blockScalarPattern matches a literal or folded block scalar header, e.g.
// `command: |`, `command: >-`, or `command: |2 # note`.
var blockScalarPattern = regexp.MustCompile(`(?m):[ \t]*[|>][-+0-9]*[ \t]*(#.*)?$`)
// writeAtomic writes to a temp file in the same directory and renames it into
// place, so a reader (or the daemon's fsnotify reload) never sees a half-written
// config. The original mode is preserved when it can be read.
func writeAtomic(path string, data []byte) error {
dir := filepath.Dir(path)
mode := os.FileMode(0o644)
if info, err := os.Stat(path); err == nil {
mode = info.Mode().Perm()
}
tmp, err := os.CreateTemp(dir, ".streamdeck-config-*.yaml")
if err != nil {
return fmt.Errorf("create temp file in %q: %w", dir, err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName) // no-op once the rename succeeds
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("write temp file: %w", err)
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return fmt.Errorf("sync temp file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temp file: %w", err)
}
if err := os.Chmod(tmpName, mode); err != nil {
return fmt.Errorf("chmod temp file: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("replace %q: %w", path, err)
}
return nil
}
// ── yaml.Node mapping helpers ────────────────────────────────────────────────
//
// A MappingNode stores Content as a flat [key, value, key, value, ...] slice.
func mapGet(m *yaml.Node, key string) *yaml.Node {
if m == nil || m.Kind != yaml.MappingNode {
return nil
}
for i := 0; i+1 < len(m.Content); i += 2 {
if m.Content[i].Value == key {
return m.Content[i+1]
}
}
return nil
}
// mapSet replaces the value for key, or appends the pair if it isn't present.
// Replacing keeps the existing key node so its comments stay attached.
func mapSet(m *yaml.Node, key string, value *yaml.Node) {
for i := 0; i+1 < len(m.Content); i += 2 {
if m.Content[i].Value == key {
// Carry the old value's comments onto the replacement — they describe
// the setting, not the specific value being overwritten.
old := m.Content[i+1]
if value.HeadComment == "" {
value.HeadComment = old.HeadComment
}
if value.LineComment == "" {
value.LineComment = old.LineComment
}
if value.FootComment == "" {
value.FootComment = old.FootComment
}
m.Content[i+1] = value
return
}
}
m.Content = append(m.Content, scalarNode(key, "!!str"), value)
}
// mapSetNode is mapSet with a caller-built key node, for keys that must carry a
// non-string tag (e.g. the !!int key indices under `keys:`).
func mapSetNode(m *yaml.Node, key, value *yaml.Node) {
for i := 0; i+1 < len(m.Content); i += 2 {
if m.Content[i].Value == key.Value {
m.Content[i+1] = value
return
}
}
m.Content = append(m.Content, key, value)
}
func mapDelete(m *yaml.Node, key string) {
if m == nil || m.Kind != yaml.MappingNode {
return
}
for i := 0; i+1 < len(m.Content); i += 2 {
if m.Content[i].Value == key {
m.Content = append(m.Content[:i], m.Content[i+2:]...)
return
}
}
}
func scalarNode(value, tag string) *yaml.Node {
return &yaml.Node{Kind: yaml.ScalarNode, Tag: tag, Value: value}
}
// valueNode converts a Go value from a KeyEdit into a yaml.Node.
func valueNode(v any) (*yaml.Node, error) {
switch typed := v.(type) {
case string:
return stringNode(typed), nil
case int:
return scalarNode(strconv.Itoa(typed), "!!int"), nil
case bool:
return scalarNode(strconv.FormatBool(typed), "!!bool"), nil
case map[string]string:
node := &yaml.Node{Kind: yaml.MappingNode}
names := make([]string, 0, len(typed))
for name := range typed {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
node.Content = append(node.Content, scalarNode(name, "!!str"), stringNode(typed[name]))
}
return node, nil
default:
return nil, fmt.Errorf("unsupported value type %T", v)
}
}
// stringNode emits a string scalar, quoting it when leaving it bare would change
// its meaning on re-parse (empty, leading/trailing space, or a value YAML would
// read back as a bool/number/null).
func stringNode(s string) *yaml.Node {
node := scalarNode(s, "!!str")
if needsQuoting(s) {
node.Style = yaml.DoubleQuotedStyle
}
return node
}
func needsQuoting(s string) bool {
if s == "" {
return true
}
if s != trimSpace(s) {
return true
}
var probe any
if err := yaml.Unmarshal([]byte(s), &probe); err != nil {
// Not parseable bare (e.g. contains ": ") — quote it and let the encoder escape.
return true
}
if _, isString := probe.(string); !isString {
return true
}
return false
}
func trimSpace(s string) string {
start, end := 0, len(s)
for start < end && isSpace(s[start]) {
start++
}
for end > start && isSpace(s[end-1]) {
end--
}
return s[start:end]
}
func isSpace(b byte) bool {
return b == ' ' || b == '\t' || b == '\n' || b == '\r'
}

View File

@@ -0,0 +1,126 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
func writeConfig(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return path
}
// Regression: a new key's index must be written as an int scalar. A !!str key
// ("25":) makes config.Load fail — yaml.v3 refuses string keys for map[int] —
// which bricked the config on the daemon's next hot reload.
func TestApplyKeyEditNewSlotLoadsBack(t *testing.T) {
path := writeConfig(t, "icons_dir: ./icons\nbrightness: 70\nkeys: {}\n")
err := ApplyKeyEdit(path, 25, KeyEdit{Set: map[string]any{
"icon": "test.png",
"command": "echo hi",
}})
if err != nil {
t.Fatalf("ApplyKeyEdit: %v", err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load after edit: %v", err)
}
key, ok := cfg.Keys[25]
if !ok {
t.Fatalf("key 25 missing after edit; keys=%v", cfg.Keys)
}
if key.Icon != "test.png" || key.Command != "echo hi" {
t.Fatalf("key 25 = %+v", key)
}
raw, _ := os.ReadFile(path)
if strings.Contains(string(raw), `"25"`) {
t.Fatalf("index written as a quoted string:\n%s", raw)
}
}
// Editing an existing key must merge, and comments/blank lines must survive.
func TestApplyKeyEditMergePreservesFormatting(t *testing.T) {
path := writeConfig(t, `icons_dir: ./icons
# the deck
keys:
0:
icon: a.png # keep me
command: run-a
`)
if err := ApplyKeyEdit(path, 0, KeyEdit{Set: map[string]any{"text": "Hi"}}); err != nil {
t.Fatalf("ApplyKeyEdit: %v", err)
}
raw, _ := os.ReadFile(path)
text := string(raw)
for _, want := range []string{"# the deck", "# keep me", "command: run-a", "text: Hi", "\n\n"} {
if !strings.Contains(text, want) {
t.Fatalf("output missing %q:\n%s", want, text)
}
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Keys[0].Icon != "a.png" || cfg.Keys[0].Text != "Hi" {
t.Fatalf("merge lost fields: %+v", cfg.Keys[0])
}
}
// Unset is applied before Set, so a field named in both ends up set.
func TestApplyKeyEditSetWinsOverUnset(t *testing.T) {
path := writeConfig(t, "keys:\n 0:\n command: old\n")
err := ApplyKeyEdit(path, 0, KeyEdit{
Set: map[string]any{"command": "new"},
Unset: []string{"command"},
})
if err != nil {
t.Fatalf("ApplyKeyEdit: %v", err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Keys[0].Command != "new" {
t.Fatalf("command = %q, want %q", cfg.Keys[0].Command, "new")
}
}
func TestClearKeyAndSetBrightness(t *testing.T) {
path := writeConfig(t, "brightness: 70\nkeys:\n 3:\n command: x\n")
if err := ClearKey(path, 3); err != nil {
t.Fatalf("ClearKey: %v", err)
}
if err := ClearKey(path, 99); err != nil { // absent key is not an error
t.Fatalf("ClearKey absent: %v", err)
}
if err := SetBrightness(path, 140); err != nil { // clamped
t.Fatalf("SetBrightness: %v", err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(cfg.Keys) != 0 {
t.Fatalf("keys not cleared: %v", cfg.Keys)
}
if cfg.Brightness != 100 {
t.Fatalf("brightness = %d, want 100", cfg.Brightness)
}
}

View File

@@ -0,0 +1,82 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestLoadFolders(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
src := `
keys:
3:
icon: folder.png
folder: media
folders:
media:
back:
key: 7
icon: back.png
text: Up
keys:
0:
icon: play.png
command: play
1:
icon: more.png
folder: nested
nested:
keys:
1:
text: Deep
command: echo deep
`
if err := os.WriteFile(path, []byte(src), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if got := cfg.Keys[3].Folder; got != "media" {
t.Errorf("key 3 folder = %q, want media", got)
}
media, ok := cfg.Folders["media"]
if !ok {
t.Fatal("folder media missing")
}
if media.BackKey() != 7 || media.Back.Icon != "back.png" || media.Back.Text != "Up" {
t.Errorf("media back = %+v (key %d)", media.Back, media.BackKey())
}
if media.Keys[0].Command != "play" || media.Keys[1].Folder != "nested" {
t.Errorf("media keys = %+v", media.Keys)
}
nested := cfg.Folders["nested"]
if nested.BackKey() != 0 {
t.Errorf("nested back key = %d, want default 0", nested.BackKey())
}
if nested.Keys[1].Text != "Deep" {
t.Errorf("nested keys = %+v", nested.Keys)
}
}
func TestApplyKeyEditFolder(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(path, []byte("keys: {}\n"), 0o644); err != nil {
t.Fatal(err)
}
err := ApplyKeyEdit(path, 5, KeyEdit{Set: map[string]any{"icon": "f.png", "folder": "media"}})
if err != nil {
t.Fatalf("ApplyKeyEdit: %v", err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Keys[5].Folder != "media" || cfg.Keys[5].Icon != "f.png" {
t.Errorf("key 5 = %+v", cfg.Keys[5])
}
}

View File

@@ -13,14 +13,14 @@ modules:
exec: |
curl -s -X POST https://slack.com/api/users.profile.set \
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
-H "Content-Type: application/json" \
-H "Content-Type: application/json; charset=utf-8" \
-d '{"profile":{"status_emoji":"{{.emoji}}","status_text":"{{.text}}","status_expiration":{{expiry .expiry}}}}'
clear_status:
exec: |
curl -s -X POST https://slack.com/api/users.profile.set \
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
-H "Content-Type: application/json" \
-H "Content-Type: application/json; charset=utf-8" \
-d '{"profile":{"status_emoji":"","status_text":"","status_expiration":0}}'
set_presence:
@@ -29,7 +29,7 @@ modules:
exec: |
curl -s -X POST https://slack.com/api/users.setPresence \
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
-H "Content-Type: application/json" \
-H "Content-Type: application/json; charset=utf-8" \
-d '{"presence":"{{.presence}}"}'
snooze:
@@ -38,18 +38,18 @@ modules:
exec: |
curl -s -X POST https://slack.com/api/dnd.setSnooze \
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
-H "Content-Type: application/json" \
-H "Content-Type: application/json; charset=utf-8" \
-d '{"num_minutes":{{.minutes}}}'
go_offline:
exec: |
curl -s -X POST https://slack.com/api/users.setPresence \
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
-H "Content-Type: application/json" \
-H "Content-Type: application/json; charset=utf-8" \
-d '{"presence":"away"}' && \
curl -s -X POST https://slack.com/api/users.profile.set \
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
-H "Content-Type: application/json" \
-H "Content-Type: application/json; charset=utf-8" \
-d '{"profile":{"status_emoji":":dumpsterfire:","status_text":"Offline","status_expiration":0}}'
end_snooze:
@@ -57,6 +57,30 @@ modules:
curl -s -X POST https://slack.com/api/dnd.endSnooze \
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}"
# Pomodoro / focus timer — the `flow` CLI.
#
# Absolute path required (minimal service PATH). Apple Silicon Homebrew installs
# live at /opt/homebrew/bin/flow — set that in ~/.config/streamdeck-go/.env:
# FLOW_CMD=/opt/homebrew/bin/flow
#
# Control verbs (start/pause/resume/toggle/skip/stop/reset/gui) run on key press.
# `status` prints one line like `state=running phase=work remaining=24:12` for
# icon-swap poll blocks (match: "state=running").
#
# The LIVE countdown label uses a key's `text_command` field directly (not a
# module), e.g. text_command: "/usr/local/bin/flow status --short" which prints
# a two-line label like WORK\n24:12 (an actual newline between the lines).
flow:
start: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} start' }
pause: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} pause' }
resume: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} resume' }
toggle: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} toggle' }
skip: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} skip' }
stop: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} stop' }
reset: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} reset' }
gui: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} gui' }
status: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} status' }
# OBS Studio — media player, streaming, and scene/transition control via obs-cmd
#
# Requires: obs-cmd (https://github.com/grigio/obs-cmd)
@@ -182,3 +206,67 @@ modules:
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene transition-set "{{.transition}}" && \
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene transition-duration {{.duration}} && \
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene switch "{{.scene}}"
# ---------------------------------------------------------------------------
# Camera — cameractrls (https://github.com/soyersoyer/cameractrls), Linux only.
#
# Drives any UVC webcam's V4L2 controls (zoom, pan/tilt, focus, exposure, white
# balance, brightness/contrast/...). Install: pacman -S cameractrls / pip.
#
# Device: set CAMERA_DEVICE in ~/.config/streamdeck-go/.env to the stable path, e.g.
# CAMERA_DEVICE=/dev/v4l/by-id/usb-046d_Logitech_BRIO_XXXX-video-index0
# (defaults to /dev/video0; any function also takes a per-key `device` param).
#
# Discover control names/ranges: cameractrls -d /dev/video0 -l
# Note: cameractrls exits 0 even for unknown controls, so poll blocks must use
# the `is` function (which greps `-l` output) rather than exit codes.
camera:
# Set one or more controls, comma-separated (eg. "zoom_absolute=150,contrast=140").
# Buttons work too: "preset=save_1", "preset=load_1", "color_preset=vivid".
set:
params:
device: ""
controls: "zoom_absolute=100"
exec: /usr/bin/cameractrls -d {{if .device}}{{.device}}{{else}}{{envDefault "CAMERA_DEVICE" "/dev/video0"}}{{end}} -c "{{.controls}}" 2>/dev/null
# Flip a control between two values (a <-> b). Works for 0/1 and menu controls.
toggle:
params:
device: ""
control: auto_exposure
a: aperture_priority_mode
b: manual_mode
exec: |
cur=$(/usr/bin/cameractrls -d {{if .device}}{{.device}}{{else}}{{envDefault "CAMERA_DEVICE" "/dev/video0"}}{{end}} -l 2>/dev/null | sed -n 's/^ {{.control}} = \([^[:space:]]*\).*/\1/p')
if [ "$cur" = "{{.a}}" ]; then v="{{.b}}"; else v="{{.a}}"; fi
/usr/bin/cameractrls -d {{if .device}}{{.device}}{{else}}{{envDefault "CAMERA_DEVICE" "/dev/video0"}}{{end}} -c "{{.control}}=$v" 2>/dev/null
# Step a numeric control by delta (negative to decrease), clamped to its min/max.
# `extra` is applied first on the same call — eg. "auto_exposure=manual_mode"
# so stepping exposure also takes the camera out of auto.
adjust:
params:
device: ""
control: contrast
delta: "16"
extra: ""
exec: |
line=$(/usr/bin/cameractrls -d {{if .device}}{{.device}}{{else}}{{envDefault "CAMERA_DEVICE" "/dev/video0"}}{{end}} -l 2>/dev/null | grep '^ {{.control}} = ')
cur=$(printf '%s\n' "$line" | sed -n 's/^ [a-z0-9_]* = \([-0-9]*\).*/\1/p')
min=$(printf '%s\n' "$line" | sed -n 's/.*min: \([-0-9]*\).*/\1/p')
max=$(printf '%s\n' "$line" | sed -n 's/.*max: \([-0-9]*\).*/\1/p')
[ -n "$cur" ] || exit 1
v=$((cur + {{.delta}}))
[ -n "$min" ] && [ "$v" -lt "$min" ] && v=$min
[ -n "$max" ] && [ "$v" -gt "$max" ] && v=$max
ctl="{{.control}}=$v"
[ -n "{{.extra}}" ] && ctl="{{.extra}},$ctl"
/usr/bin/cameractrls -d {{if .device}}{{.device}}{{else}}{{envDefault "CAMERA_DEVICE" "/dev/video0"}}{{end}} -c "$ctl" 2>/dev/null
# Poll helper for toggle keys: exit 0 when control == value.
is:
params:
device: ""
control: auto_exposure
value: aperture_priority_mode
exec: /usr/bin/cameractrls -d {{if .device}}{{.device}}{{else}}{{envDefault "CAMERA_DEVICE" "/dev/video0"}}{{end}} -l 2>/dev/null | grep -q '^ {{.control}} = {{.value}}[[:space:]]'

View File

@@ -3,6 +3,7 @@ package device
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"image"
"image/jpeg"
@@ -10,6 +11,7 @@ import (
"runtime"
"strings"
"sync"
"time"
"github.com/sstallion/go-hid"
"golang.org/x/image/draw"
@@ -19,6 +21,7 @@ const VendorID = 0x0fd9
// ModelInfo describes hardware-specific constants for a Stream Deck model.
type ModelInfo struct {
Name string
KeyCount int
Cols int
Rows int
@@ -31,9 +34,35 @@ type ModelInfo struct {
// models maps USB product IDs to their hardware specs.
var models = map[uint16]ModelInfo{
0x00ba: {KeyCount: 32, Cols: 8, Rows: 4, ImageWidth: 96, ImageHeight: 96, FlipX: true, FlipY: true}, // XL v2
0x006c: {KeyCount: 32, Cols: 8, Rows: 4, ImageWidth: 96, ImageHeight: 96, FlipX: true, FlipY: true}, // XL v1
0x006d: {KeyCount: 15, Cols: 5, Rows: 3, ImageWidth: 72, ImageHeight: 72, FlipX: true, FlipY: true}, // MK.2
0x00ba: {Name: "Stream Deck XL v2", KeyCount: 32, Cols: 8, Rows: 4, ImageWidth: 96, ImageHeight: 96, FlipX: true, FlipY: true},
0x006c: {Name: "Stream Deck XL v1", KeyCount: 32, Cols: 8, Rows: 4, ImageWidth: 96, ImageHeight: 96, FlipX: true, FlipY: true},
0x006d: {Name: "Stream Deck MK.2", KeyCount: 15, Cols: 5, Rows: 3, ImageWidth: 72, ImageHeight: 72, FlipX: true, FlipY: true},
}
// Lookup returns the hardware spec for a product ID without opening the device.
// Callers that only need geometry (key count, grid shape) can use this while the
// daemon holds the HID handle.
func Lookup(productID uint16) (ModelInfo, bool) {
m, ok := models[productID]
return m, ok
}
// Present reports whether a Stream Deck with the given USB IDs is currently
// enumerated. It does not open the device, so it is safe to call while the
// daemon has it open.
func Present(vendorID, productID uint16) (bool, error) {
if err := hid.Init(); err != nil {
return false, fmt.Errorf("hid init: %w", err)
}
found := false
err := hid.Enumerate(vendorID, productID, func(*hid.DeviceInfo) error {
found = true
return nil
})
if err != nil {
return false, fmt.Errorf("hid enumerate: %w", err)
}
return found, nil
}
const (
@@ -167,8 +196,13 @@ func (sd *StreamDeck) ClearKey(keyIndex int) error {
// Returns (nil, nil) on timeout — callers should check context and retry.
func (sd *StreamDeck) ReadButtons() ([]bool, error) {
data := make([]byte, readReportSize)
n, err := sd.dev.ReadWithTimeout(data, 250)
// ReadWithTimeout takes a time.Duration: a bare 250 would be 250ns, which
// truncates to a 0 ms (non-blocking) hid_read_timeout and busy-spins the loop.
n, err := sd.dev.ReadWithTimeout(data, 250*time.Millisecond)
if err != nil {
if errors.Is(err, hid.ErrTimeout) {
return nil, nil
}
// Linux hidraw returns errors (not (0,nil)) for non-fatal conditions:
// timeout waiting for data, or EINTR (signal interrupted).
// macOS IOHIDManager usually returns (0, nil) on timeout, but may also

View File

@@ -96,3 +96,4 @@ The biggest architectural difference is privileged commands:
| Log location | `journalctl --user -u streamdeck-go` | `~/Library/Logs/streamdeck-go.log` |
| Config path | `~/.config/streamdeck-go/` (XDG) | `~/.config/streamdeck-go/` (XDG — works fine on macOS for CLI tools) |
| Sleep/wake | Handled by reconnect loop | Handled by reconnect loop (same code) |
| HID read timeout | `hid_read_timeout` → `poll()`; 0 ms = non-blocking | `hid_read_timeout` → `pthread_cond_timedwait`; 0 ms = non-blocking. Same fix applies: pass `250*time.Millisecond` |

View File

@@ -13,14 +13,14 @@ modules:
exec: |
curl -s -X POST https://slack.com/api/users.profile.set \
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
-H "Content-Type: application/json" \
-H "Content-Type: application/json; charset=utf-8" \
-d '{"profile":{"status_emoji":"{{.emoji}}","status_text":"{{.text}}","status_expiration":{{expiry .expiry}}}}'
clear_status:
exec: |
curl -s -X POST https://slack.com/api/users.profile.set \
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
-H "Content-Type: application/json" \
-H "Content-Type: application/json; charset=utf-8" \
-d '{"profile":{"status_emoji":"","status_text":"","status_expiration":0}}'
set_presence:
@@ -29,7 +29,7 @@ modules:
exec: |
curl -s -X POST https://slack.com/api/users.setPresence \
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
-H "Content-Type: application/json" \
-H "Content-Type: application/json; charset=utf-8" \
-d '{"presence":"{{.presence}}"}'
snooze:
@@ -38,18 +38,18 @@ modules:
exec: |
curl -s -X POST https://slack.com/api/dnd.setSnooze \
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
-H "Content-Type: application/json" \
-H "Content-Type: application/json; charset=utf-8" \
-d '{"num_minutes":{{.minutes}}}'
go_offline:
exec: |
curl -s -X POST https://slack.com/api/users.setPresence \
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
-H "Content-Type: application/json" \
-H "Content-Type: application/json; charset=utf-8" \
-d '{"presence":"away"}' && \
curl -s -X POST https://slack.com/api/users.profile.set \
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
-H "Content-Type: application/json" \
-H "Content-Type: application/json; charset=utf-8" \
-d '{"profile":{"status_emoji":":dumpsterfire:","status_text":"Offline","status_expiration":0}}'
end_snooze:
@@ -57,6 +57,30 @@ modules:
curl -s -X POST https://slack.com/api/dnd.endSnooze \
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}"
# Pomodoro / focus timer — the `flow` CLI.
#
# Absolute path required (minimal service PATH). Apple Silicon Homebrew installs
# live at /opt/homebrew/bin/flow — set that in ~/.config/streamdeck-go/.env:
# FLOW_CMD=/opt/homebrew/bin/flow
#
# Control verbs (start/pause/resume/toggle/skip/stop/reset/gui) run on key press.
# `status` prints one line like `state=running phase=work remaining=24:12` for
# icon-swap poll blocks (match: "state=running").
#
# The LIVE countdown label uses a key's `text_command` field directly (not a
# module), e.g. text_command: "/usr/local/bin/flow status --short" which prints
# a two-line label like WORK\n24:12 (an actual newline between the lines).
flow:
start: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} start' }
pause: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} pause' }
resume: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} resume' }
toggle: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} toggle' }
skip: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} skip' }
stop: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} stop' }
reset: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} reset' }
gui: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} gui' }
status: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} status' }
# OBS Studio — media player, streaming, and scene/transition control via obs-cmd
#
# Requires: obs-cmd (https://github.com/grigio/obs-cmd)
@@ -182,3 +206,67 @@ modules:
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene transition-set "{{.transition}}" && \
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene transition-duration {{.duration}} && \
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene switch "{{.scene}}"
# ---------------------------------------------------------------------------
# Camera — cameractrls (https://github.com/soyersoyer/cameractrls), Linux only.
#
# Drives any UVC webcam's V4L2 controls (zoom, pan/tilt, focus, exposure, white
# balance, brightness/contrast/...). Install: pacman -S cameractrls / pip.
#
# Device: set CAMERA_DEVICE in ~/.config/streamdeck-go/.env to the stable path, e.g.
# CAMERA_DEVICE=/dev/v4l/by-id/usb-046d_Logitech_BRIO_XXXX-video-index0
# (defaults to /dev/video0; any function also takes a per-key `device` param).
#
# Discover control names/ranges: cameractrls -d /dev/video0 -l
# Note: cameractrls exits 0 even for unknown controls, so poll blocks must use
# the `is` function (which greps `-l` output) rather than exit codes.
camera:
# Set one or more controls, comma-separated (eg. "zoom_absolute=150,contrast=140").
# Buttons work too: "preset=save_1", "preset=load_1", "color_preset=vivid".
set:
params:
device: ""
controls: "zoom_absolute=100"
exec: /usr/bin/cameractrls -d {{if .device}}{{.device}}{{else}}{{envDefault "CAMERA_DEVICE" "/dev/video0"}}{{end}} -c "{{.controls}}" 2>/dev/null
# Flip a control between two values (a <-> b). Works for 0/1 and menu controls.
toggle:
params:
device: ""
control: auto_exposure
a: aperture_priority_mode
b: manual_mode
exec: |
cur=$(/usr/bin/cameractrls -d {{if .device}}{{.device}}{{else}}{{envDefault "CAMERA_DEVICE" "/dev/video0"}}{{end}} -l 2>/dev/null | sed -n 's/^ {{.control}} = \([^[:space:]]*\).*/\1/p')
if [ "$cur" = "{{.a}}" ]; then v="{{.b}}"; else v="{{.a}}"; fi
/usr/bin/cameractrls -d {{if .device}}{{.device}}{{else}}{{envDefault "CAMERA_DEVICE" "/dev/video0"}}{{end}} -c "{{.control}}=$v" 2>/dev/null
# Step a numeric control by delta (negative to decrease), clamped to its min/max.
# `extra` is applied first on the same call — eg. "auto_exposure=manual_mode"
# so stepping exposure also takes the camera out of auto.
adjust:
params:
device: ""
control: contrast
delta: "16"
extra: ""
exec: |
line=$(/usr/bin/cameractrls -d {{if .device}}{{.device}}{{else}}{{envDefault "CAMERA_DEVICE" "/dev/video0"}}{{end}} -l 2>/dev/null | grep '^ {{.control}} = ')
cur=$(printf '%s\n' "$line" | sed -n 's/^ [a-z0-9_]* = \([-0-9]*\).*/\1/p')
min=$(printf '%s\n' "$line" | sed -n 's/.*min: \([-0-9]*\).*/\1/p')
max=$(printf '%s\n' "$line" | sed -n 's/.*max: \([-0-9]*\).*/\1/p')
[ -n "$cur" ] || exit 1
v=$((cur + {{.delta}}))
[ -n "$min" ] && [ "$v" -lt "$min" ] && v=$min
[ -n "$max" ] && [ "$v" -gt "$max" ] && v=$max
ctl="{{.control}}=$v"
[ -n "{{.extra}}" ] && ctl="{{.extra}},$ctl"
/usr/bin/cameractrls -d {{if .device}}{{.device}}{{else}}{{envDefault "CAMERA_DEVICE" "/dev/video0"}}{{end}} -c "$ctl" 2>/dev/null
# Poll helper for toggle keys: exit 0 when control == value.
is:
params:
device: ""
control: auto_exposure
value: aperture_priority_mode
exec: /usr/bin/cameractrls -d {{if .device}}{{.device}}{{else}}{{envDefault "CAMERA_DEVICE" "/dev/video0"}}{{end}} -l 2>/dev/null | grep -q '^ {{.control}} = {{.value}}[[:space:]]'

View File

@@ -0,0 +1,407 @@
import QtQuick
import QtQuick.Layouts
import qs.Commons
import qs.Ui
import "Model.js" as Model
// Editor for one key. It edits a local draft and only writes to config.yaml when
// Save is pressed, so a half-typed command never reaches the running deck.
//
// Inputs are deliberately *not* two-way bound — neither the text fields nor the
// dropdowns. Typing into a QML TextField (or Ui/Dropdown selecting internally)
// writes the property directly and destroys any declarative binding on it, which
// would leave Revert unable to repaint the control. Instead the draft is the
// single source of truth: edit handlers push user input into it, and
// syncFields()/paramsSynced() push the draft back out whenever it changes
// underneath the user. Controls with focus (or an open popup) are skipped so a
// background poll can never move the cursor mid-keystroke.
Item {
id: root
property var service: null
property var status: null
property int keyIndex: -1
property color foreground: Color.foreground
property color accent: Color.accent
property string fontFamily: Style.font.family
signal closed()
signal saved()
readonly property color dim: Qt.darker(foreground, 1.55)
readonly property var currentKey: {
if (!status || keyIndex < 0) return null
for (var i = 0; i < status.keys.length; i++) {
if (Number(status.keys[i].index) === keyIndex) return status.keys[i]
}
return null
}
readonly property var original: Model.editorDraft(currentKey)
readonly property bool isNew: currentKey === null
// The working copy.
property var draft: Model.editorDraft(null)
// `touched` is set by user edits rather than derived from draft-vs-original.
// A derived flag would be read by onCurrentKeyChanged while `original` was
// still evaluating, which Qt reports as a binding loop.
property bool touched: false
readonly property bool dirty: touched && Model.draftDiffers(draft, original)
readonly property bool usingModule: String(draft.module || "") !== ""
// The parameter Repeater's model. Rebuilt ONLY when the parameter name set
// changes (module/function switch), never on keystrokes or status polls —
// a model whose identity churned with `draft` or `status` would destroy and
// recreate the delegates, dropping focus after every typed character.
property var paramNames: []
// Fired when param VALUES in the draft changed underneath the delegates
// (reset, module switch). Delegates re-pull their value unless focused.
signal paramsSynced()
onKeyIndexChanged: reset()
// A background poll replaces the status object every few seconds. Adopt the
// refreshed key only when the user has nothing in flight.
//
// The adoption is deferred: `currentKey` is evaluated lazily, so the first
// read of `dirty` (via `original`) is what triggers this handler. Writing
// `draft` synchronously here would invalidate `dirty` while it is still being
// computed, which Qt reports as a binding loop. Qt.callLater moves the write
// to after the current evaluation pass.
onCurrentKeyChanged: if (!touched) Qt.callLater(adoptIfClean)
Component.onCompleted: reset()
// Escape closes the editor — it bubbles up here from whichever field has
// focus, since QQC2 TextField doesn't consume it.
Keys.onEscapePressed: closed()
// The shipped-panel idiom for inline editors: focus a real control the moment
// the editor appears (see network's password field). Landing focus on a plain
// Item would leave typed keys dead and Tab navigation stranded.
onVisibleChanged: if (visible) Qt.callLater(focusFirstField)
function focusFirstField() {
if (!visible) return
commandField.forceActiveFocus()
}
function adoptIfClean() {
if (!touched) reset()
}
function reset() {
draft = Model.editorDraft(currentKey)
touched = false
rebuildParams()
syncFields()
paramsSynced()
}
function rebuildParams() {
var rows = status ? Model.paramRows(status, draft.module, draft["function"], draft.params) : []
var names = rows.map(function(row) { return { name: row.name, placeholder: row.placeholder } })
// Identity guard: an unchanged name set must not touch the model, or the
// Repeater rebuilds delegates for nothing.
if (JSON.stringify(names) !== JSON.stringify(paramNames)) paramNames = names
}
// Push draft values into the controls. Focused fields and open popups are
// left alone so a sync landing mid-interaction cannot fight the user.
function syncFields() {
if (!commandField.activeFocus) commandField.text = draft.command
if (!labelField.activeFocus) labelField.text = draft.text
if (!colorField.activeFocus) colorField.text = draft.textColor
if (!moduleDropdown.popupOpen) moduleDropdown.value = draft.module
if (!functionDropdown.popupOpen) functionDropdown.value = draft["function"]
if (!iconDropdown.popupOpen) iconDropdown.value = draft.icon
}
function paramValueFor(name) {
var value = draft.params ? draft.params[name] : undefined
return (value === undefined || value === null) ? "" : String(value)
}
function setField(name, value) {
if (String(draft[name] || "") === String(value)) return
var next = Model.shallowCopy(draft)
next.params = Model.shallowCopy(draft.params)
next[name] = value
// Switching or clearing the module invalidates the function and its params.
if (name === "module") {
next["function"] = ""
next.params = {}
}
if (name === "function") next.params = {}
draft = next
touched = true
if (name === "module" || name === "function") {
rebuildParams()
paramsSynced()
}
syncFields()
}
function setParam(name, value) {
if (String(draft.params[name] || "") === String(value)) return
var next = Model.shallowCopy(draft)
next.params = Model.shallowCopy(draft.params)
next.params[name] = value
draft = next
touched = true
}
function save() {
if (!service) return
service.saveKey(keyIndex, Model.editArguments(draft, original))
touched = false
saved()
}
function removeKey() {
if (!service) return
service.clearKey(keyIndex)
touched = false
closed()
}
implicitHeight: column.implicitHeight
ColumnLayout {
id: column
anchors.left: parent.left
anchors.right: parent.right
spacing: Style.space(10)
// ── header ───────────────────────────────────────────────────────────────
RowLayout {
Layout.fillWidth: true
spacing: Style.space(8)
Text {
Layout.fillWidth: true
text: (root.isNew ? "New key " : "Key ") + root.keyIndex
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.subtitle
}
PanelActionButton {
iconText: "󰑓"
tooltipText: "Revert changes"
foreground: root.foreground
fontFamily: root.fontFamily
enabled: root.dirty
opacity: enabled ? 1.0 : 0.35
onClicked: root.reset()
}
PanelActionButton {
iconText: "󰩹"
tooltipText: "Delete this key"
foreground: Color.urgent
fontFamily: root.fontFamily
enabled: !root.isNew
opacity: enabled ? 1.0 : 0.35
onClicked: root.removeKey()
}
PanelActionButton {
iconText: "󰅖"
tooltipText: "Close editor"
foreground: root.foreground
fontFamily: root.fontFamily
onClicked: root.closed()
}
}
// Parts of the key this editor doesn't cover are preserved on save (merge
// semantics), but say so rather than leaving them invisible.
Text {
Layout.fillWidth: true
visible: !!(root.currentKey && (root.currentKey.poll || root.currentKey.textCommand))
text: "This key also has " +
(root.currentKey && root.currentKey.poll ? "a poll block" : "live text") +
" — saved edits keep it, but edit it in config.yaml"
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
wrapMode: Text.WordWrap
}
// ── action: module function, or a plain shell command ────────────────────
FieldLabel {
text: "MODULE"
visible: root.status && root.status.modules.length > 0
}
Dropdown {
id: moduleDropdown
Layout.fillWidth: true
visible: root.status && root.status.modules.length > 0
showLabel: false
foreground: root.foreground
accent: root.accent
fontFamily: root.fontFamily
options: root.status ? Model.moduleOptions(root.status) : []
onChanged: function(v) { root.setField("module", v) }
}
FieldLabel { text: "FUNCTION"; visible: root.usingModule }
Dropdown {
id: functionDropdown
Layout.fillWidth: true
visible: root.usingModule
showLabel: false
foreground: root.foreground
accent: root.accent
fontFamily: root.fontFamily
options: root.status ? Model.functionOptions(root.status, root.draft.module) : []
onChanged: function(v) { root.setField("function", v) }
}
FieldLabel {
text: "PARAMETERS"
visible: root.usingModule && root.paramNames.length > 0
}
Repeater {
model: root.usingModule ? root.paramNames : []
RowLayout {
id: paramRow
required property var modelData
Layout.fillWidth: true
spacing: Style.space(8)
Text {
Layout.preferredWidth: Style.space(76)
text: paramRow.modelData.name
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
elide: Text.ElideRight
}
TextField {
id: paramField
Layout.fillWidth: true
foreground: root.foreground
accent: root.accent
placeholderText: paramRow.modelData.placeholder
Component.onCompleted: text = root.paramValueFor(paramRow.modelData.name)
onTextEdited: root.setParam(paramRow.modelData.name, text)
}
Connections {
target: root
function onParamsSynced() {
if (!paramField.activeFocus) paramField.text = root.paramValueFor(paramRow.modelData.name)
}
}
}
}
FieldLabel { text: root.usingModule ? "COMMAND — OVERRIDES THE MODULE" : "COMMAND" }
TextField {
id: commandField
Layout.fillWidth: true
foreground: root.foreground
accent: root.accent
placeholderText: "shell command to run on press"
onTextEdited: root.setField("command", text)
}
PanelSeparator { Layout.fillWidth: true; foreground: root.foreground }
// ── appearance ───────────────────────────────────────────────────────────
FieldLabel { text: "ICON" }
Dropdown {
id: iconDropdown
Layout.fillWidth: true
showLabel: false
foreground: root.foreground
accent: root.accent
fontFamily: root.fontFamily
options: root.status ? Model.iconOptions(root.status) : []
onChanged: function(v) { root.setField("icon", v) }
}
RowLayout {
Layout.fillWidth: true
spacing: Style.space(8)
ColumnLayout {
Layout.fillWidth: true
spacing: Style.spacing.labelGap
FieldLabel { text: "LABEL" }
TextField {
id: labelField
Layout.fillWidth: true
foreground: root.foreground
accent: root.accent
placeholderText: "text drawn on the key"
onTextEdited: root.setField("text", text)
}
}
ColumnLayout {
Layout.preferredWidth: Style.space(104)
spacing: Style.spacing.labelGap
FieldLabel { text: "COLOUR" }
TextField {
id: colorField
Layout.fillWidth: true
foreground: root.foreground
accent: root.accent
placeholderText: "white"
onTextEdited: root.setField("textColor", text)
}
}
}
// ── footer ───────────────────────────────────────────────────────────────
RowLayout {
Layout.fillWidth: true
spacing: Style.space(8)
Text {
Layout.fillWidth: true
text: root.dirty ? "Unsaved changes" : "Saved edits reload the deck immediately"
color: root.dirty ? root.foreground : root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
elide: Text.ElideRight
}
Button {
text: "Save"
foreground: root.foreground
accent: root.accent
fontFamily: root.fontFamily
enabled: root.dirty
opacity: enabled ? 1.0 : 0.4
onClicked: root.save()
}
}
}
// FieldLabel is the small-caps heading above each control.
component FieldLabel: PanelSectionHeader {
Layout.fillWidth: true
foreground: root.foreground
fontFamily: root.fontFamily
}
}

200
omarchy-plugin/KeyGrid.qml Normal file
View File

@@ -0,0 +1,200 @@
import QtQuick
import qs.Commons
import "Model.js" as Model
// The deck laid out as it sits on the desk. Grid shape comes from the device
// report (8x4 on an XL, 5x3 on an MK.2), so this is not hardcoded to one model.
Item {
id: root
property var slots: []
property int cols: 8
property int rows: 4
property int cursorIndex: -1
property bool cursorActive: false
property color foreground: Color.foreground
property color accent: Color.accent
property string fontFamily: Style.font.family
// Icons are previewed from disk when the file is a raster format; SVG and GIF
// fall back to the kind glyph rather than risking a slow or failed decode in
// the panel's paint path.
property bool showIconPreviews: true
signal keyActivated(int index)
signal keyFocused(int index)
signal keySecondary(int index)
// Themes are not tagged light/dark, so derive it from the panel background's
// perceived luminance (Rec. 601 weights).
readonly property color panelBackground: Color.popups.background
readonly property bool lightTheme: (0.299 * panelBackground.r
+ 0.587 * panelBackground.g
+ 0.114 * panelBackground.b) > 0.5
// Stands in for the deck's physical key face.
readonly property color keyFace: Qt.rgba(0.08, 0.08, 0.08, 1.0)
readonly property real spacing: Style.space(4)
readonly property real cellWidth: cols > 0 ? (width - spacing * (cols - 1)) / cols : 0
// Stream Deck keys are square; keeping the cells square makes the panel read
// as the physical device rather than an abstract table.
readonly property real cellHeight: cellWidth
implicitHeight: rows > 0 ? cellHeight * rows + spacing * (rows - 1) : 0
Repeater {
// Modelled on the slot COUNT, not the slots array: `slots` gets a fresh
// identity on every status poll, and a Repeater over a JS array rebuilds
// every delegate on identity change — 32 cells of object churn and a frame
// of Image flicker per poll. With an int model the delegates persist and
// only the bindings that actually changed re-evaluate.
model: root.cols * root.rows
Item {
id: cell
required property int index
readonly property var slot: (index < root.slots.length)
? root.slots[index]
: ({ index: index, configured: false, kind: "empty", label: "" })
readonly property int keyIndex: Number(slot.index)
readonly property bool configured: slot.configured === true
readonly property bool hasCursor: root.cursorActive && root.cursorIndex === keyIndex
readonly property bool hot: mouse.containsMouse || hasCursor
x: (index % root.cols) * (root.cellWidth + root.spacing)
y: Math.floor(index / root.cols) * (root.cellHeight + root.spacing)
width: root.cellWidth
height: root.cellHeight
Rectangle {
id: face
anchors.fill: parent
radius: Style.cornerRadius > 0 ? Style.space(3) : 0
// Every colour here resolves through Style/Color, so the grid repaints
// itself when the Omarchy theme changes.
color: cell.hot ? Style.hoverFillFor(root.foreground, root.accent)
: cell.configured ? Style.normalFillFor(root.foreground, root.accent)
: "transparent"
border.width: cell.hot ? Style.hoverBorderWidth : Style.normalBorderWidth
border.color: cell.hot ? Style.hoverBorderFor(root.foreground, root.accent)
: Util.alpha(root.foreground, cell.configured ? 0.28 : 0.12)
Behavior on color { ColorAnimation { duration: 90 } }
}
// Deck icons are usually light artwork on transparency, drawn for the
// hardware's black key faces — on a light theme they would wash out. Under
// a light theme only, back the preview with a dark plate standing in for
// the physical key. Dark themes need no plate and get none.
Rectangle {
anchors.centerIn: preview
width: preview.width * 1.12
height: preview.height * 1.12
radius: Style.cornerRadius > 0 ? Style.space(2) : 0
visible: preview.visible && root.lightTheme
color: root.keyFace
opacity: 0.9
}
// Icon preview, when the key has one and it is a format Image decodes
// cheaply. Falls back to the kind glyph below.
Image {
id: preview
anchors.centerIn: parent
// Lifted off centre so the artwork clears the label strip along the
// bottom edge of the cell.
anchors.verticalCenterOffset: -Style.space(4)
width: parent.width * 0.46
height: width
visible: root.showIconPreviews && status === Image.Ready
fillMode: Image.PreserveAspectFit
smooth: true
asynchronous: true
cache: true
sourceSize.width: Math.max(24, Math.round(width))
sourceSize.height: Math.max(24, Math.round(width))
source: {
if (!root.showIconPreviews || !cell.configured) return ""
var path = String(cell.slot.iconPath || "")
if (path === "") return ""
var lower = path.toLowerCase()
if (!lower.endsWith(".png") && !lower.endsWith(".jpg") && !lower.endsWith(".jpeg")) return ""
return Util.fileUrl(path)
}
opacity: cell.configured ? 1.0 : 0.4
}
Text {
anchors.centerIn: parent
anchors.verticalCenterOffset: -Style.space(4)
visible: cell.configured && !preview.visible
text: Model.kindGlyph(String(cell.slot.kind || ""))
color: root.foreground
opacity: 0.75
font.family: root.fontFamily
font.pixelSize: Math.max(Style.font.caption, cell.height * 0.34)
}
// Slot number, always visible so the grid maps onto config.yaml indices.
Text {
anchors.top: parent.top
anchors.left: parent.left
anchors.topMargin: Style.space(2)
anchors.leftMargin: Style.space(3)
text: cell.keyIndex
color: root.foreground
opacity: cell.configured ? 0.45 : 0.28
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
Text {
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.bottomMargin: Style.space(2)
anchors.leftMargin: Style.space(2)
anchors.rightMargin: Style.space(2)
visible: cell.configured && Model.gridLabel(cell.slot) !== ""
text: Model.gridLabel(cell.slot)
color: root.foreground
opacity: 0.8
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
// A privileged key is worth flagging: pressing it from here is refused by
// the CLI, and it behaves differently on the deck itself.
Rectangle {
anchors.top: parent.top
anchors.right: parent.right
anchors.topMargin: Style.space(3)
anchors.rightMargin: Style.space(3)
visible: cell.slot.privileged === true
width: Style.space(4)
height: width
radius: width / 2
color: Color.urgent
opacity: 0.85
}
MouseArea {
id: mouse
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.RightButton
cursorShape: Qt.PointingHandCursor
onEntered: root.keyFocused(cell.keyIndex)
onClicked: function(event) {
if (event.button === Qt.RightButton) root.keySecondary(cell.keyIndex)
else root.keyActivated(cell.keyIndex)
}
}
}
}
}

21
omarchy-plugin/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Levi Woodard
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

311
omarchy-plugin/Model.js Normal file
View File

@@ -0,0 +1,311 @@
.pragma library
// Pure helpers for the Stream Deck panel. Kept out of the QML so the parsing and
// formatting rules can be reasoned about (and changed) without touching layout.
var EMPTY_STATUS = {
ok: false,
configPath: "",
modulesPath: "",
iconsDir: "",
brightness: 0,
configError: "",
daemon: { unit: "", active: false, enabled: false, state: "unknown", sub: "", sinceSec: 0 },
device: { connected: false, known: false, model: "", keyCount: 0, cols: 0, rows: 0 },
keys: [],
icons: [],
modules: [],
warnings: []
}
// parseStatus turns streamdeck-ctl's stdout into a status object, filling in
// anything the CLI omitted so bindings never dereference undefined.
function parseStatus(raw) {
var parsed
try {
parsed = JSON.parse(String(raw || ""))
} catch (e) {
return { ok: false, error: "Could not parse streamdeck-ctl output" }
}
if (!parsed || typeof parsed !== "object") {
return { ok: false, error: "Unexpected streamdeck-ctl output" }
}
var status = {}
for (var field in EMPTY_STATUS) status[field] = EMPTY_STATUS[field]
for (var key in parsed) status[key] = parsed[key]
status.daemon = Object.assign({}, EMPTY_STATUS.daemon, parsed.daemon || {})
status.device = Object.assign({}, EMPTY_STATUS.device, parsed.device || {})
status.keys = parsed.keys || []
status.icons = parsed.icons || []
status.modules = parsed.modules || []
status.warnings = parsed.warnings || []
return status
}
// keySlots expands the sparse key list from the CLI into one entry per physical
// slot, so the grid can render empty positions without the view doing lookups.
function keySlots(status) {
var cols = Number(status.device.cols) || 8
var rows = Number(status.device.rows) || 4
var count = Number(status.device.keyCount) || (cols * rows)
var configured = {}
for (var i = 0; i < status.keys.length; i++) {
configured[Number(status.keys[i].index)] = status.keys[i]
}
var slots = []
for (var index = 0; index < count; index++) {
if (configured[index]) {
// Copy rather than annotate — this library's helpers must not mutate the
// status object they were handed.
slots.push(Object.assign({ configured: true }, configured[index]))
} else {
slots.push({ index: index, configured: false, kind: "empty", label: "" })
}
}
return slots
}
// statusLine is the one-line summary under the panel title.
function statusLine(status) {
if (status.configError) return "Config error"
if (!status.device.connected) return status.daemon.active ? "Waiting for deck" : "Deck disconnected"
if (!status.daemon.active) return "Daemon stopped"
var used = countConfigured(status)
var total = Number(status.device.keyCount) || 0
return used + " of " + total + " keys in use"
}
function countConfigured(status) {
// Count only keys the connected device can actually show, so an XL config
// against an MK.2 can't read "17 of 15 keys in use".
var limit = Number(status.device.keyCount) || Infinity
var used = 0
for (var i = 0; i < status.keys.length; i++) {
if (status.keys[i].kind !== "empty" && Number(status.keys[i].index) < limit) used++
}
return used
}
// deviceLine names the hardware, falling back to the raw USB IDs when the
// product ID isn't in the daemon's supported-models table.
function deviceLine(status) {
if (status.device.known && status.device.model) return status.device.model
if (status.device.productId) return "Unknown (" + status.device.productId + ")"
return "Unknown"
}
function daemonLine(status) {
var daemon = status.daemon
if (daemon.active) {
var uptime = formatDuration(Number(daemon.sinceSec) || 0)
return uptime ? "Running · " + uptime : "Running"
}
if (daemon.state === "failed") return "Failed"
if (daemon.state === "unknown") return "Not installed"
return daemon.enabled ? "Stopped" : "Stopped (disabled)"
}
function formatDuration(seconds) {
if (!seconds || seconds < 60) return seconds > 0 ? seconds + "s" : ""
var minutes = Math.floor(seconds / 60)
if (minutes < 60) return minutes + "m"
var hours = Math.floor(minutes / 60)
if (hours < 24) return hours + "h " + (minutes % 60) + "m"
var days = Math.floor(hours / 24)
return days + "d " + (hours % 24) + "h"
}
// kindGlyph maps a key kind to a Nerd Font glyph for the grid cell. Kinds come
// from streamdeck-ctl, so this list tracks KeyStatus.Kind in status.go.
function kindGlyph(kind) {
switch (kind) {
case "folder": return "󰉋" // folder
case "toggle": return "󰄣" // toggle-switch
case "module": return "󰅵" // puzzle piece
case "text": return "󰉼" // text
case "static": return "󰃐" // image
default: return ""
}
}
// describeKey is the detail line shown when a grid cell is focused. Module keys
// show only module · function — their rendered command can inline secrets, so
// streamdeck-ctl no longer emits it at all.
function describeKey(key) {
if (!key || !key.configured) return "Empty slot — click to configure"
var parts = []
if (key.module && key.function) parts.push(key.module + " · " + key.function)
else if (key.command) parts.push(truncate(key.command, 70))
if (key.poll) parts.push("polls every " + (key.poll.interval || "2s"))
if (key.privileged) parts.push("privileged")
return parts.length ? parts.join(" · ") : "No command"
}
function truncate(text, limit) {
var value = String(text || "").replace(/\s+/g, " ").trim()
return value.length > limit ? value.substring(0, limit - 1) + "…" : value
}
// gridLabel keeps cell text to something that fits an 8-column grid.
function gridLabel(key) {
if (!key || !key.configured) return ""
return truncate(key.label || "", 10)
}
// iconOptions builds the dropdown model for icon selection: "(none)" plus every
// image file the CLI found in icons_dir.
function iconOptions(status) {
var options = [{ value: "", label: "(none)" }]
for (var i = 0; i < status.icons.length; i++) {
options.push({ value: status.icons[i], label: status.icons[i] })
}
return options
}
function moduleOptions(status) {
var options = [{ value: "", label: "(no module)" }]
for (var i = 0; i < status.modules.length; i++) {
options.push({ value: status.modules[i].name, label: status.modules[i].name })
}
return options
}
function functionOptions(status, moduleName) {
var options = [{ value: "", label: "(no function)" }]
if (!moduleName) return options
for (var i = 0; i < status.modules.length; i++) {
if (status.modules[i].name !== moduleName) continue
var functions = status.modules[i].functions || []
for (var j = 0; j < functions.length; j++) {
options.push({ value: functions[j].name, label: functions[j].name })
}
}
return options
}
// functionParams returns the declared default params for a module function, used
// to seed the editor's parameter rows.
function functionParams(status, moduleName, functionName) {
for (var i = 0; i < status.modules.length; i++) {
if (status.modules[i].name !== moduleName) continue
var functions = status.modules[i].functions || []
for (var j = 0; j < functions.length; j++) {
if (functions[j].name === functionName) return functions[j].params || {}
}
}
return {}
}
// paramRows merges a function's declared defaults with the values already set on
// the key, so the editor shows every parameter the function accepts.
function paramRows(status, moduleName, functionName, current) {
var defaults = functionParams(status, moduleName, functionName)
var names = {}
var name
for (name in defaults) names[name] = true
for (name in (current || {})) names[name] = true
var ordered = Object.keys(names).sort()
var rows = []
for (var i = 0; i < ordered.length; i++) {
name = ordered[i]
var value = (current && current[name] !== undefined) ? current[name] : defaults[name]
rows.push({
name: name,
value: value === undefined || value === null ? "" : String(value),
placeholder: defaults[name] === undefined ? "" : String(defaults[name])
})
}
return rows
}
// editorDraft snapshots a key into the flat shape the editor binds to.
function editorDraft(key) {
return {
index: key ? Number(key.index) : 0,
icon: key && key.icon ? key.icon : "",
text: key && key.text ? key.text : "",
textColor: key && key.textColor ? key.textColor : "",
command: key && key.command ? key.command : "",
module: key && key.module ? key.module : "",
function: key && key.function ? key.function : "",
params: key && key.params ? shallowCopy(key.params) : {}
}
}
function shallowCopy(source) {
var copy = {}
for (var name in source) copy[name] = source[name]
return copy
}
// editArguments turns an editor draft into streamdeck-ctl `key set` flags.
// Fields the user emptied are passed as -unset so they leave the YAML entirely
// rather than lingering as empty strings.
function editArguments(draft, original) {
var args = []
function apply(flag, field, value) {
var text = String(value || "").trim()
var had = String((original && original[field]) || "").trim() !== ""
if (text !== "") args.push(flag, text)
else if (had) args.push("-unset", yamlField(field))
}
apply("-icon", "icon", draft.icon)
apply("-text", "text", draft.text)
apply("-text-color", "textColor", draft.textColor)
apply("-command", "command", draft.command)
apply("-module", "module", draft.module)
apply("-function", "function", draft.function)
// Params only travel with a module function; without one they have nothing to
// substitute into, and the daemon would ignore them.
if (String(draft.module || "").trim() !== "" && String(draft.function || "").trim() !== "") {
var wrote = false
for (var name in draft.params) {
var value = draft.params[name]
if (value === undefined || value === null) continue
args.push("-param", name + "=" + value)
wrote = true
}
if (!wrote && original && original.params && Object.keys(original.params).length > 0) {
args.push("-unset", "params")
}
} else if (original && original.params && Object.keys(original.params).length > 0) {
args.push("-unset", "params")
}
return args
}
// yamlField maps the editor's camelCase field names to the YAML keys the CLI's
// -unset flag expects.
function yamlField(field) {
switch (field) {
case "textColor": return "text_color"
case "function": return "function"
default: return field
}
}
// draftDiffers reports whether the editor has unsaved changes.
function draftDiffers(draft, original) {
var fields = ["icon", "text", "textColor", "command", "module", "function"]
for (var i = 0; i < fields.length; i++) {
var field = fields[i]
if (String(draft[field] || "") !== String(original[field] || "")) return true
}
var name
for (name in draft.params) {
if (String(draft.params[name] || "") !== String((original.params || {})[name] || "")) return true
}
for (name in (original.params || {})) {
if (draft.params[name] === undefined) return true
}
return false
}

501
omarchy-plugin/Panel.qml Normal file
View File

@@ -0,0 +1,501 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell.Io
import qs.Commons
import qs.Ui
import "Model.js" as Model
// Bar widget and popout panel for streamdeck-go.
//
// Every colour and metric resolves through qs.Commons (Color/Style), so the
// widget repaints itself to match whatever Omarchy theme is active rather than
// carrying a palette of its own.
Panel {
id: root
moduleName: "dev.woodard.streamdeck"
ipcTarget: "dev.woodard.streamdeck"
manageIpc: false
// ── theme-derived palette ──────────────────────────────────────────────────
readonly property color foreground: bar ? bar.foreground : Color.foreground
readonly property color accent: Color.accent
readonly property color urgent: bar ? bar.urgent : Color.urgent
readonly property color dim: Qt.darker(foreground, 1.55)
readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family
// The bar icon dims when the deck is unreachable, the same convention the
// built-in network and bluetooth widgets use.
readonly property bool live: deck.connected && deck.daemonActive
readonly property color barIconColor: live ? barForeground : Qt.darker(barForeground, 1.55)
readonly property var slots: Model.keySlots(deck.status)
readonly property int cols: Number(deck.status.device.cols) || 8
readonly property int rows: Number(deck.status.device.rows) || 4
// ── panel navigation state ─────────────────────────────────────────────────
property int cursorIndex: 0
property bool cursorActive: false
property int editingIndex: -1
readonly property bool editing: editingIndex >= 0
// A device-shape change (XL -> MK.2, or the first real poll shrinking the
// fallback grid) can strand the cursor or the editor beyond the last slot.
onSlotsChanged: {
if (cursorIndex >= slots.length) cursorIndex = Math.max(0, slots.length - 1)
if (editing && editingIndex >= slots.length) closeEditor()
}
function moveCursor(dx, dy) {
cursorActive = true
if (editing) return
var next = cursorIndex + dx + dy * cols
if (next < 0 || next >= slots.length) return
cursorIndex = next
}
function activateCursor() {
if (editing) return
editKey(cursorIndex)
}
function focusKey(index) {
cursorActive = true
cursorIndex = index
}
function editKey(index) {
cursorIndex = index
editingIndex = index
// Focus lands inside the editor via its own onVisibleChanged handler —
// the key catcher is blocked while editing.
}
function closeEditor() {
editingIndex = -1
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
}
readonly property var focusedKey: {
for (var i = 0; i < slots.length; i++) {
if (Number(slots[i].index) === cursorIndex) return slots[i]
}
return null
}
implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight
visible: !(deck.setting("hideWhenDisconnected", false) === true && deck.loaded && !deck.connected)
onOpenedChanged: if (opened) {
cursorActive = false
editingIndex = -1
if (panelFlick) panelFlick.contentY = 0
deck.refresh()
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
}
Service {
id: deck
settings: root.settings
panelOpen: root.opened
}
IpcHandler {
target: root.ipcTarget
function open(): void { root.open() }
function close(): void { root.close() }
function show(): void { root.open() }
function hide(): void { root.close() }
function toggle(): void { root.toggle() }
function refresh(): string { deck.refresh(); return "ok" }
function status(): string { return Model.statusLine(deck.status) }
function brightness(level: string): string {
var value = parseInt(level, 10)
if (!isFinite(value)) return "expected a number 0-100"
deck.setBrightness(value)
return "ok"
}
function press(index: string): string {
var value = parseInt(index, 10)
if (!isFinite(value)) return "expected a key index"
deck.pressKey(value)
return "ok"
}
}
// ── bar icon ───────────────────────────────────────────────────────────────
BarIconButton {
id: button
anchors.fill: parent
bar: root.bar
iconComponent: Component {
Item {
StreamDeckIcon {
anchors.centerIn: parent
iconSize: Style.space(13)
color: root.barIconColor
// The icon fills in proportionally to how much of the deck is mapped,
// so a glance at the bar says whether the deck is live and loaded.
litKeys: {
if (!deck.connected) return 0
var total = Number(deck.status.device.keyCount) || 0
if (total === 0) return 6
var used = Model.countConfigured(deck.status)
return Math.max(1, Math.round((used / total) * 6))
}
opacity: root.live ? 1.0 : 0.6
}
}
}
onPressed: function(buttonCode) {
if (buttonCode === Qt.RightButton) deck.refresh()
else if (buttonCode === Qt.MiddleButton) deck.toggleDaemon()
else root.toggle()
}
}
// ── panel ──────────────────────────────────────────────────────────────────
KeyboardPanel {
id: panel
anchorItem: button
owner: root
bar: root.bar
open: root.opened
focusTarget: keyCatcher
// Wide enough that an 8-column grid still gives each key a legible face.
contentWidth: panel.fittedContentWidth(Style.space(520))
contentHeight: panel.fittedContentHeight(column.implicitHeight, Style.space(640))
PanelKeyCatcher {
id: keyCatcher
anchors.fill: parent
// While the editor is up its fields own the keyboard — the shipped-panel
// idiom for inline editors. Escape then bubbles to the editor's own
// handler instead of tearing down the whole panel mid-edit.
blocked: root.editing
onMoveRequested: function(dx, dy) {
if (!root.cursorActive) { root.cursorActive = true; return }
root.moveCursor(dx, dy)
}
onActivateRequested: if (root.cursorActive) root.activateCursor()
onCloseRequested: root.close()
onTabRequested: function(direction) { root.switchPanel(direction) }
onTextKey: function(t) {
if (t === "r" || t === "R") deck.refresh()
else if (t === "p" || t === "P") deck.toggleDaemon()
else if (t === "e" || t === "E") root.editKey(root.cursorIndex)
else if (t === " ") deck.pressKey(root.cursorIndex)
}
Flickable {
id: panelFlick
anchors.fill: parent
contentWidth: width
contentHeight: column.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
flickableDirection: Flickable.VerticalFlick
interactive: contentHeight > height
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
ColumnLayout {
id: column
width: panelFlick.width
spacing: Style.space(12)
// ── hero: title, status line, daemon switch ────────────────────────
Item {
id: header
Layout.fillWidth: true
implicitHeight: hero.implicitHeight
// Exposed for the hero's trailingControl, whose `root` resolves to
// PanelHero rather than this Panel.
readonly property bool ringVisible: false
PanelHero {
id: hero
width: parent.width
title: "Stream Deck"
meta: Model.statusLine(deck.status)
foreground: root.foreground
fontFamily: root.fontFamily
iconOpacity: root.live ? 1.0 : 0.5
iconComponent: Component {
StreamDeckIcon {
iconSize: Style.font.display
color: root.live ? root.foreground : root.dim
litKeys: deck.connected ? 6 : 0
}
}
trailingControl: Component {
ToggleSwitch {
id: powerSwitch
visible: !deck.ctlMissing && deck.status.daemon.state !== "unknown"
checked: deck.daemonActive
busy: deck.busy
foreground: hero.foreground
onToggled: deck.toggleDaemon()
PanelToolTip {
visible: powerSwitch.containsMouse
text: deck.daemonActive ? "Stop the daemon" : "Start the daemon"
fontFamily: hero.fontFamily
}
}
}
}
}
// ── messages ───────────────────────────────────────────────────────
Text {
Layout.fillWidth: true
visible: deck.actionStatus !== "" || deck.lastError !== ""
text: deck.actionStatus !== "" ? deck.actionStatus : deck.lastError
color: deck.lastError !== "" && deck.actionStatus === "" ? root.urgent : root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
wrapMode: Text.WordWrap
}
// streamdeck-ctl is what makes this widget work at all; if it isn't
// installed, say so plainly instead of showing dead controls.
ColumnLayout {
Layout.fillWidth: true
visible: deck.ctlMissing
spacing: Style.spacing.labelGap
Text {
Layout.fillWidth: true
text: "streamdeck-ctl was not found"
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.body
}
Text {
Layout.fillWidth: true
text: "Build and install it from the streamdeck-go repo:\nmake build-ctl && make install-ctl"
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
wrapMode: Text.WordWrap
}
}
// ── brightness ─────────────────────────────────────────────────────
ColumnLayout {
Layout.fillWidth: true
visible: !deck.ctlMissing && !root.editing
spacing: Style.spacing.labelGap
RowLayout {
Layout.fillWidth: true
spacing: Style.space(8)
PanelSectionHeader {
Layout.fillWidth: true
text: "BRIGHTNESS"
foreground: root.foreground
fontFamily: root.fontFamily
}
Text {
text: deck.brightness + "%"
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
}
}
PanelSlider {
Layout.fillWidth: true
bar: root.bar
minimum: 0
maximum: 100
step: 5
integer: true
value: deck.brightness
enabled: deck.connected
opacity: deck.connected ? 1.0 : 0.45
onMoved: function(v) { deck.setBrightness(v) }
}
}
// ── device / daemon facts ──────────────────────────────────────────
ColumnLayout {
Layout.fillWidth: true
visible: !deck.ctlMissing && !root.editing
spacing: Style.spacing.labelGap
InfoPair { label: "Device"; value: Model.deviceLine(deck.status) }
InfoPair {
label: "Connection"
value: deck.connected ? "Connected" : "Disconnected"
emphasised: !deck.connected
}
InfoPair { label: "Daemon"; value: Model.daemonLine(deck.status) }
}
PanelSeparator {
Layout.fillWidth: true
visible: !deck.ctlMissing
foreground: root.foreground
}
// ── key grid ───────────────────────────────────────────────────────
ColumnLayout {
Layout.fillWidth: true
visible: !deck.ctlMissing && !root.editing
spacing: Style.space(8)
PanelSectionHeader {
Layout.fillWidth: true
text: "KEYS"
foreground: root.foreground
fontFamily: root.fontFamily
}
KeyGrid {
Layout.fillWidth: true
slots: root.slots
cols: root.cols
rows: root.rows
cursorIndex: root.cursorIndex
cursorActive: root.cursorActive
foreground: root.foreground
accent: root.accent
fontFamily: root.fontFamily
onKeyFocused: function(index) { root.focusKey(index) }
// Left-click mirrors a physical press; editing is the secondary action.
onKeyActivated: function(index) { deck.pressKey(index) }
// Right-click fires the key, mirroring a physical press.
onKeySecondary: function(index) { root.editKey(index) }
}
Text {
Layout.fillWidth: true
text: root.focusedKey
? "Key " + root.cursorIndex + " — " + Model.describeKey(root.focusedKey)
: ""
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
wrapMode: Text.WordWrap
maximumLineCount: 2
elide: Text.ElideRight
}
Text {
Layout.fillWidth: true
text: "Click a key to run it · right-click to edit"
color: root.dim
opacity: 0.7
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
}
// ── editor ─────────────────────────────────────────────────────────
KeyEditor {
id: editorPane
Layout.fillWidth: true
visible: root.editing
service: deck
status: deck.status
keyIndex: root.editingIndex
foreground: root.foreground
accent: root.accent
fontFamily: root.fontFamily
onClosed: root.closeEditor()
onSaved: root.closeEditor()
}
PanelSeparator {
Layout.fillWidth: true
visible: !deck.ctlMissing && !root.editing
foreground: root.foreground
}
// ── actions ────────────────────────────────────────────────────────
RowLayout {
Layout.fillWidth: true
visible: !deck.ctlMissing && !root.editing
spacing: Style.space(6)
Button {
text: "Restart"
iconText: "󰑓"
foreground: root.foreground
accent: root.accent
fontFamily: root.fontFamily
onClicked: deck.restartDaemon()
}
Button {
text: "Config"
iconText: "󰈔"
foreground: root.foreground
accent: root.accent
fontFamily: root.fontFamily
onClicked: deck.openConfig()
}
Button {
text: "Icons"
iconText: "󰉋"
foreground: root.foreground
accent: root.accent
fontFamily: root.fontFamily
onClicked: deck.openIcons()
}
Item { Layout.fillWidth: true }
PanelActionButton {
iconText: "󰌱"
tooltipText: "Follow the daemon log"
foreground: root.foreground
fontFamily: root.fontFamily
onClicked: deck.openLogs()
}
}
}
}
}
}
// InfoPair is a label on the left, value on the right, with the gap between
// them absorbing the leftover width.
component InfoPair: RowLayout {
id: pair
property string label: ""
property string value: ""
property bool emphasised: false
Layout.fillWidth: true
spacing: Style.space(8)
Text {
text: pair.label
color: root.foreground
opacity: 0.6
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
}
Item { Layout.fillWidth: true }
Text {
text: pair.value
color: pair.emphasised ? root.urgent : root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
elide: Text.ElideRight
}
}
}

169
omarchy-plugin/README.md Normal file
View File

@@ -0,0 +1,169 @@
# Stream Deck — Omarchy shell plugin
A bar widget for [Omarchy](https://omarchy.org) that controls the
[streamdeck-go](https://git.i0t.app/lwoodard/streamdeck-go) daemon: device and
daemon status, brightness, and a live 8×4 key grid you can edit in place.
Every colour and metric resolves through Omarchy's `qs.Commons` theme tokens
(`Color.*`, `Style.*`), so the widget follows the active theme rather than
carrying a palette of its own.
---
## What it does
**Bar icon** — a miniature deck that fills in proportionally to how much of the
deck is mapped, and dims when the deck is unplugged or the daemon is stopped.
**Panel**
| Section | Behaviour |
|---|---|
| Header | Title, status line, and an on/off switch for the `streamdeck-go` user service |
| Brightness | Slider writing `brightness:` into `config.yaml`; the daemon hot-reloads, so the deck dims as you drag |
| Facts | Device model, connection state, daemon state and uptime |
| Key grid | One cell per physical key, with icon previews, slot indices, and labels. Empty slots are shown so you can fill them |
| Editor | Right-click a key to edit its module/function, parameters, shell command, icon, label, and label colour |
| Actions | Restart the daemon, open `config.yaml`, open the icons folder, follow the log |
A red dot on a cell marks a `priv:` key. Those run through the privileged helper
and are deliberately **not** runnable from the panel — press them on the deck.
### Mouse and keyboard
| Input | Action |
|---|---|
| Left-click bar icon | Open/close the panel |
| Right-click bar icon | Refresh |
| Middle-click bar icon | Start/stop the daemon |
| Left-click a key | Run that key's command (fire-and-forget, like a physical press) |
| Right-click a key | Open the editor for that key |
| Arrow keys | Move the grid cursor |
| `Enter` | Edit the focused key |
| `Space` | Run the focused key |
| `e` | Edit the focused key |
| `r` | Refresh |
| `p` | Start/stop the daemon |
| `Esc` | Leave the field → close the editor → close the panel |
---
## Requirements
- Omarchy with `omarchy-shell` running
- `streamdeck-go` installed and its user service present
- **`streamdeck-ctl`** — the helper CLI the plugin drives. The panel refuses to
show controls without it and tells you how to install it.
The plugin never edits YAML or probes USB itself; it shells out to
`streamdeck-ctl`, which reuses the daemon's own config and device code. That
keeps one implementation of the config format rather than two.
---
## Install
From a checkout of the streamdeck-go repo:
```bash
make install-plugin # builds + installs streamdeck-ctl, links the plugin
omarchy plugin enable dev.woodard.streamdeck right
```
`make install-plugin` symlinks this directory into
`~/.config/omarchy/plugins/dev.woodard.streamdeck`, so edits to the QML are live
after a rescan:
```bash
omarchy-shell shell rescanPlugins
```
> Qt caches compiled QML in `~/.cache/quickshell/qmlcache`, and a running shell
> can keep serving a stale compiled unit across a rescan. If an edit doesn't seem
> to take effect, `omarchy restart shell`.
To validate before committing:
```bash
make validate-plugin
```
`omarchy plugin validate` refuses a symlinked plugin folder by design, so this
target validates the source directory rather than the installed link.
### Remove
```bash
make uninstall-plugin
```
---
## Settings
Configurable from the Omarchy bar widget settings:
| Setting | Default | Meaning |
|---|---|---|
| `refreshIntervalSec` | 10 | Poll cadence while the panel is open; closed, it relaxes to at most once a minute |
| `ctlPath` | *(empty)* | Explicit path to `streamdeck-ctl`; empty searches `~/.local/bin`, `~/go/bin`, `/usr/local/bin`, then `$PATH` |
| `configPath` | *(empty)* | Explicit `config.yaml`; empty uses `~/.config/streamdeck-go/config.yaml` |
| `hideWhenDisconnected` | false | Hide the bar icon entirely when no deck is connected |
---
## IPC
```bash
omarchy-shell dev.woodard.streamdeck open
omarchy-shell dev.woodard.streamdeck toggle
omarchy-shell dev.woodard.streamdeck status # "20 of 32 keys in use"
omarchy-shell dev.woodard.streamdeck refresh
omarchy-shell dev.woodard.streamdeck brightness 60
omarchy-shell dev.woodard.streamdeck press 3
```
Useful for Hyprland binds — e.g. a key that dims the deck without opening the bar.
---
## Security note
Module keys can reference secrets via `{{env "TOKEN"}}` in their exec templates.
`streamdeck-ctl status` never emits the rendered command — the panel shows only
`module · function` for module keys — so tokens can't leak into the UI or into
anything scraping the CLI's JSON. On Linux, `key press` merges the systemd user
manager's environment over the caller's before running, so a token imported with
`systemctl --user import-environment` reaches panel presses exactly as it reaches
hardware presses. (macOS keeps the caller's environment — launchd has no
equivalent bulk query.)
## A note on config formatting
Saving from the editor rewrites `config.yaml` through a YAML node-level editor.
Comments and blank lines are preserved, but two normalisations happen on any
save:
- trailing whitespace on values is trimmed
- a comment block dangling at the end of a key entry may be re-indented to match
the block it attaches to
Both are cosmetic. Values, ordering, and comment text are preserved.
---
## Files
```
manifest.json plugin metadata + settings schema
Panel.qml bar icon and popout panel (entry point)
Service.qml owns every streamdeck-ctl invocation
KeyGrid.qml the 8x4 (or 5x3) key grid
KeyEditor.qml per-key editor
StreamDeckIcon.qml drawn deck icon, takes its colour from the caller
Model.js parsing and formatting helpers
```
## License
MIT — see [LICENSE](LICENSE).

311
omarchy-plugin/Service.qml Normal file
View File

@@ -0,0 +1,311 @@
import QtQuick
import Quickshell
import Quickshell.Io
import "Model.js" as Model
// Service owns every conversation with streamdeck-ctl. The panel binds to the
// properties here and calls the functions; it never spawns a process itself.
Item {
id: root
property var settings: ({})
// Set by the panel. While the popout is closed only the bar icon consumes
// status, so polling relaxes to at most once a minute.
property bool panelOpen: false
// Full snapshot from `streamdeck-ctl status`, normalised by Model.parseStatus.
property var status: Model.EMPTY_STATUS
property bool loaded: false
property bool refreshing: false
property string lastError: ""
property string actionStatus: ""
// True once we have looked for the binary and failed — the panel shows install
// guidance instead of controls in that state.
property bool ctlMissing: false
property string ctlResolved: ""
readonly property bool busy: statusProcess.running || actionProcess.running
readonly property bool connected: status.device.connected === true
readonly property bool daemonActive: status.daemon.active === true
// Brightness is echoed optimistically so the slider tracks the drag instead of
// snapping back on the next poll, which lands up to refreshIntervalSec later.
property int pendingBrightness: -1
readonly property int brightness: pendingBrightness >= 0 ? pendingBrightness : (Number(status.brightness) || 0)
readonly property int refreshIntervalSec: intSetting("refreshIntervalSec", 10, 2, 600)
readonly property string configPath: String(setting("configPath", "") || "")
function setting(name, fallback) {
var value = settings ? settings[name] : undefined
return value === undefined || value === null || value === "" ? fallback : value
}
function intSetting(name, fallback, min, max) {
var n = parseInt(String(setting(name, fallback)), 10)
if (!isFinite(n)) n = fallback
return Math.max(min, Math.min(max, n))
}
// ── locating streamdeck-ctl ────────────────────────────────────────────────
//
// The shell inherits a minimal PATH under systemd, so ~/.local/bin (Linux) and
// ~/go/bin (macOS, and Go's default install target) are checked explicitly
// before falling back to a PATH lookup.
readonly property string home: Quickshell.env("HOME")
readonly property var candidatePaths: [
String(setting("ctlPath", "") || ""),
home + "/.local/bin/streamdeck-ctl",
home + "/go/bin/streamdeck-ctl",
"/usr/local/bin/streamdeck-ctl"
]
function resolveCtl() {
if (locateProcess.running) return
locateProcess.command = ["sh", "-c", locateScript()]
locateProcess.running = true
}
// A changed ctlPath setting (or HOME, in theory) must invalidate the cached
// location — without this, ctlResolved is sticky for the life of the shell.
onCandidatePathsChanged: {
ctlResolved = ""
ctlMissing = false
resolveCtl()
}
function locateScript() {
var checks = []
for (var i = 0; i < candidatePaths.length; i++) {
var path = candidatePaths[i]
if (!path) continue
checks.push('[ -x ' + shellQuote(path) + ' ] && { printf %s ' + shellQuote(path) + '; exit 0; }')
}
checks.push('command -v streamdeck-ctl 2>/dev/null || true')
return checks.join("\n")
}
function shellQuote(value) {
return "'" + String(value).replace(/'/g, "'\\''") + "'"
}
// baseArgs prefixes every invocation, carrying the -config override when the
// widget was pointed at a non-default config.
function baseArgs() {
var args = []
if (configPath !== "") args.push("-config", configPath)
return args
}
// ── reading ────────────────────────────────────────────────────────────────
function refresh() {
if (ctlResolved === "") {
if (!locateProcess.running) resolveCtl()
return
}
if (statusProcess.running) return
refreshing = true
statusProcess.command = [ctlResolved].concat(baseArgs()).concat(["status"])
statusProcess.running = true
}
// ── writing ────────────────────────────────────────────────────────────────
function setBrightness(value) {
var clamped = Math.max(0, Math.min(100, Math.round(value)))
pendingBrightness = clamped
brightnessDebounce.restart()
}
function toggleDaemon() {
runAction(["daemon", "toggle"], daemonActive ? "Stopping daemon…" : "Starting daemon…")
}
function restartDaemon() {
runAction(["daemon", "restart"], "Restarting daemon…")
}
function pressKey(index) {
runAction(["key", "press", String(index)], "")
}
function clearKey(index) {
runAction(["key", "clear", String(index)], "Cleared key " + index)
}
// saveKey takes the flag list Model.editArguments built. Returning early on an
// empty list keeps a no-op "Save" from rewriting the config file.
function saveKey(index, flags) {
if (!flags || flags.length === 0) {
actionStatus = "No changes"
actionStatusTimer.restart()
return
}
runAction(["key", "set", String(index)].concat(flags), "Saved key " + index)
}
// Writes are queued, not dropped: a save clicked while another action is in
// flight must still land. Brightness entries coalesce to the newest value so
// a slider drag queues one write, not a backlog of intermediate positions.
property var actionQueue: []
function runAction(args, message) {
if (ctlResolved === "") { lastError = "streamdeck-ctl not found"; return }
if (args[0] === "brightness") {
actionQueue = actionQueue.filter(function(entry) { return entry.args[0] !== "brightness" })
}
actionQueue.push({ args: args, message: message })
pumpActions()
}
function pumpActions() {
if (actionProcess.running || actionQueue.length === 0) return
var next = actionQueue.shift()
actionProcess.pendingMessage = next.message
actionProcess.command = [ctlResolved].concat(baseArgs()).concat(next.args)
actionProcess.running = true
}
function openConfig() {
var path = status.configPath || (home + "/.config/streamdeck-go/config.yaml")
Quickshell.execDetached(["uwsm-app", "--", "xdg-open", path])
}
function openLogs() {
Quickshell.execDetached(["uwsm-app", "--", "omarchy-launch-floating-terminal-with-presentation",
"journalctl", "--user", "-u", "streamdeck-go.service", "-f", "-n", "200"])
}
function openIcons() {
var dir = status.iconsDir || (home + "/.config/streamdeck-go/icons")
Quickshell.execDetached(["uwsm-app", "--", "xdg-open", dir])
}
// ── processes ──────────────────────────────────────────────────────────────
Process {
id: locateProcess
running: false
command: []
stdout: StdioCollector { id: locateStdout; waitForEnd: true }
onExited: function(exitCode) {
var found = String(locateStdout.text || "").trim().split("\n")[0].trim()
if (found !== "") {
root.ctlResolved = found
root.ctlMissing = false
root.refresh()
} else {
root.ctlMissing = true
root.loaded = true
}
}
}
Process {
id: statusProcess
running: false
command: []
stdout: StdioCollector { id: statusStdout; waitForEnd: true }
stderr: StdioCollector { id: statusStderr; waitForEnd: true }
onExited: function(exitCode) {
root.refreshing = false
root.loaded = true
// A failure with empty stdout is spawn-shaped (the CLI always emits JSON,
// even on error) — the binary may have been moved or removed. Drop the
// cached location so the next tick re-probes.
if (exitCode !== 0 && String(statusStdout.text || "").trim() === "") {
root.ctlResolved = ""
root.lastError = "streamdeck-ctl failed to run"
return
}
var parsed = Model.parseStatus(statusStdout.text)
if (parsed.ok === false && parsed.error) {
root.lastError = parsed.error
return
}
root.status = parsed
root.lastError = parsed.configError ? parsed.configError
: (exitCode !== 0 ? String(statusStderr.text || "").trim() : "")
// The poll caught up with the optimistic value — stop overriding it.
if (root.pendingBrightness >= 0 && Number(parsed.brightness) === root.pendingBrightness) {
root.pendingBrightness = -1
}
}
}
Process {
id: actionProcess
property string pendingMessage: ""
running: false
command: []
stdout: StdioCollector { id: actionStdout; waitForEnd: true }
stderr: StdioCollector { id: actionStderr; waitForEnd: true }
onExited: function(exitCode) {
if (exitCode === 0) {
root.lastError = ""
if (actionProcess.pendingMessage !== "") {
root.actionStatus = actionProcess.pendingMessage
actionStatusTimer.restart()
}
} else {
var message = String(actionStderr.text || actionStdout.text || "").trim()
root.lastError = Model.truncate(message || "streamdeck-ctl failed", 160)
root.actionStatus = ""
// A failed brightness write must release the optimistic value, or the
// slider shows a level the deck never reached — forever, since no poll
// will ever confirm it.
root.pendingBrightness = -1
}
root.pumpActions()
// Daemon transitions take a moment to settle; re-poll shortly after rather
// than waiting for the next scheduled refresh.
settleTimer.restart()
root.refresh()
}
}
// ── timers ─────────────────────────────────────────────────────────────────
Timer {
id: refreshTimer
// The bar icon doesn't need panel-grade freshness; the panel refreshes
// explicitly on open, so a slow background cadence costs nothing visible.
interval: (root.panelOpen ? root.refreshIntervalSec
: Math.max(root.refreshIntervalSec, 60)) * 1000
repeat: true
running: true
triggeredOnStart: true
onTriggered: root.refresh()
}
Timer {
// Dragging the slider would otherwise rewrite config.yaml on every pixel,
// and each write triggers a daemon reload. Coalesce to the last value.
id: brightnessDebounce
interval: 180
repeat: false
onTriggered: {
if (root.pendingBrightness < 0) return
root.runAction(["brightness", String(root.pendingBrightness)], "")
}
}
Timer {
id: settleTimer
interval: 1200
repeat: false
onTriggered: root.refresh()
}
Timer {
id: actionStatusTimer
interval: 2400
repeat: false
onTriggered: root.actionStatus = ""
}
Component.onCompleted: resolveCtl()
}

View File

@@ -0,0 +1,52 @@
import QtQuick
import qs.Commons
// A 3x2 grid of keys, drawn rather than glyphed so it scales cleanly and takes
// its colour straight from the caller (and therefore from the active theme).
Item {
id: root
property real iconSize: Style.space(14)
property color color: Color.foreground
// Lit keys are drawn filled; the rest are outlined. The panel uses this to
// show the deck's state at a glance without a second icon.
property int litKeys: 6
implicitWidth: iconSize
implicitHeight: iconSize
readonly property int cols: 3
readonly property int rows: 2
readonly property real gap: Math.max(1, iconSize * 0.1)
readonly property real cell: (iconSize - gap * (cols - 1)) / cols
readonly property real cellHeight: (iconSize * 0.72 - gap * (rows - 1)) / rows
Item {
anchors.centerIn: parent
width: root.iconSize
height: root.cellHeight * root.rows + root.gap * (root.rows - 1)
Repeater {
model: root.cols * root.rows
Rectangle {
required property int index
readonly property int column: index % root.cols
readonly property int row: Math.floor(index / root.cols)
readonly property bool lit: index < root.litKeys
x: column * (root.cell + root.gap)
y: row * (root.cellHeight + root.gap)
width: root.cell
height: root.cellHeight
radius: Style.cornerRadius > 0 ? Math.max(1, root.cell * 0.22) : 0
color: lit ? root.color : "transparent"
border.width: lit ? 0 : Math.max(1, Math.round(root.iconSize * 0.07))
border.color: root.color
opacity: lit ? 1.0 : 0.45
}
}
}
}

View File

@@ -0,0 +1,59 @@
{
"schemaVersion": 1,
"id": "dev.woodard.streamdeck",
"name": "Stream Deck",
"version": "1.0.0",
"author": "Levi Woodard",
"license": "MIT",
"description": "Control the streamdeck-go daemon from the Omarchy bar: device status, brightness, and a live key grid you can edit in place.",
"kinds": [
"bar-widget"
],
"entryPoints": {
"barWidget": "Panel.qml"
},
"barWidget": {
"displayName": "Stream Deck",
"description": "Deck and daemon status, brightness, and inline key editing for streamdeck-go.",
"category": "Hardware",
"allowMultiple": false,
"defaultSection": "right",
"defaults": {
"refreshIntervalSec": 10,
"ctlPath": "",
"configPath": "",
"hideWhenDisconnected": false
},
"schema": [
{
"key": "refreshIntervalSec",
"type": "integer",
"label": "Refresh interval (seconds)",
"min": 2,
"max": 600,
"step": 1,
"defaultValue": 10
},
{
"key": "ctlPath",
"type": "string",
"label": "Path to streamdeck-ctl",
"description": "Leave empty to search ~/.local/bin, ~/go/bin, then $PATH.",
"defaultValue": ""
},
{
"key": "configPath",
"type": "string",
"label": "Path to config.yaml",
"description": "Leave empty to use ~/.config/streamdeck-go/config.yaml.",
"defaultValue": ""
},
{
"key": "hideWhenDisconnected",
"type": "boolean",
"label": "Hide the bar icon when no deck is connected",
"defaultValue": false
}
]
}
}

View File

@@ -51,20 +51,23 @@ current_addr() {
'
;;
Darwin)
# system_profiler entry per device:
# Stream Deck XL:
# Product ID: 0x00ba
# Vendor ID: 0x0fd9 (Elgato ...)
# ...
# Location ID: 0x14140000 / 5
# The trailing "/ N" is the bus address — it changes on replug.
system_profiler SPUSBDataType 2>/dev/null | awk -v pids="$PIDS_RE" '
/^[[:space:]]*Product ID:/ { pid = $3 }
/^[[:space:]]*Vendor ID:/ { vid = $3 }
/^[[:space:]]*Location ID:/ {
sub(/^[[:space:]]*Location ID:[[:space:]]*/, "")
if (vid == "0x0fd9" && pid ~ ("^0x(" pids ")$")) {
print
# ioreg is the reliable source on macOS — system_profiler SPUSBDataType
# silently omits the deck on some machines, and its "Location ID: X / N"
# trailing bus number flaps spuriously without an actual replug.
#
# sessionID is unique per USB enumeration session: stable while the
# device stays plugged in, changes on every replug. Exactly what we want.
#
# Elgato vendor in decimal: 4057 (0x0fd9).
# Stream Deck product IDs in decimal: 186 (0x00ba), 108 (0x006c), 109 (0x006d).
ioreg -p IOUSB -l -w 0 2>/dev/null | awk '
/<class IOUSBHostDevice/ { vid=""; pid=""; sid="" }
/"idVendor"/ { vid=$NF }
/"idProduct"/ { pid=$NF }
/"sessionID"/ { sid=$NF }
/}/ {
if (vid == "4057" && (pid == "186" || pid == "108" || pid == "109") && sid != "") {
print sid
exit
}
}
@@ -106,14 +109,52 @@ prev=""
curr="$(current_addr)"
# Always update the state file so the next run sees a fresh baseline.
printf '%s' "$curr" > "$STATE_FILE"
# Only update the state file when the device is present. If we overwrote with
# an empty string while the device was absent (e.g. mid-KVM-swap), the very
# next run would see prev="" and miss the address change on return.
if [[ -n "$curr" ]]; then
printf '%s' "$curr" > "$STATE_FILE"
fi
# No device present — nothing to do. Don't touch the service.
if [[ -z "$curr" ]]; then
exit 0
fi
# Linux-only: detect a stale hidraw fd held by the daemon. When the device
# unplugs, hidraw's open fd survives but its /dev node is removed; procfs
# marks the symlink "(deleted)". hid_read_timeout on this fd silently returns
# zero bytes, so the daemon's 3-error reconnect path never trips.
stale_fd_detected() {
[[ "$OS" != "Linux" ]] && return 1
local pid
pid="$(systemctl --user show -p MainPID --value streamdeck-go.service 2>/dev/null || true)"
[[ -z "$pid" || "$pid" == "0" ]] && return 1
[[ ! -d "/proc/$pid/fd" ]] && return 1
ls -la "/proc/$pid/fd/" 2>/dev/null | grep -qE 'hidraw[0-9]+ \(deleted\)'
}
# Linux-only: detect that the system resumed from suspend after the daemon
# started. On resume, the xhci controller may reset the deck's USB device
# in place (same bus address, same hidraw node, fd not deleted). The kernel
# reset leaves the existing fd's input queue dead — buttons no longer reach
# userspace — but no externally visible signal flags the failure. Restarting
# the daemon is cheap and reliably fixes it.
#
# Idempotent by construction: once we restart, the daemon's ActiveEnterTimestamp
# moves past the resume event, so this check stops firing until the next sleep.
resumed_since_start() {
[[ "$OS" != "Linux" ]] && return 1
local started
started="$(systemctl --user show -p ActiveEnterTimestamp --value streamdeck-go.service 2>/dev/null || true)"
[[ -z "$started" || "$started" == "n/a" ]] && return 1
local started_epoch
started_epoch="$(date -d "$started" +%s 2>/dev/null || true)"
[[ -z "$started_epoch" ]] && return 1
journalctl -k --since "@$started_epoch" --no-pager 2>/dev/null \
| grep -qE 'PM: suspend exit|PM: Finishing wakeup'
}
reason=""
if [[ -z "$prev" ]]; then
@@ -127,6 +168,10 @@ elif [[ "$curr" != "$prev" ]]; then
reason="device address changed: $prev → $curr (likely unplug/replug)"
elif ! service_active; then
reason="device present at $curr but service is not active"
elif stale_fd_detected; then
reason="daemon holds a deleted hidraw fd (post-unplug stale handle)"
elif resumed_since_start; then
reason="system resumed from suspend since daemon started (USB reset may have invalidated input queue)"
fi
if [[ -n "$reason" ]]; then