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
This commit is contained in:
Levi Woodard
2026-09-27 18:21:33 -06:00
parent cef6f72712
commit 8cff4f8418
6 changed files with 1399 additions and 3 deletions

1
.gitignore vendored
View File

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

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

@@ -0,0 +1,443 @@
// 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
-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"),
"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",
}
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)
}
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,397 @@
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"`
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
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"`
}
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{},
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.Icons = iconList(cfg.IconsDir)
emit(status)
return nil
}
func keyList(cfg *config.Config, reg *modules.Registry) []KeyStatus {
indices := make([]int, 0, len(cfg.Keys))
for index := range cfg.Keys {
indices = append(indices, index)
}
sort.Ints(indices)
out := make([]KeyStatus, 0, len(indices))
for _, index := range indices {
key := cfg.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,
}
if icon := firstNonEmpty(key.Icon, key.IconTrue, key.IconFalse); icon != "" {
entry.IconPath = resolveIcon(cfg.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.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.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 ""
}

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", "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

@@ -19,6 +19,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 +32,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 (