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:
443
cmd/streamdeck-ctl/main.go
Normal file
443
cmd/streamdeck-ctl/main.go
Normal 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(¶ms, "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
|
||||
}
|
||||
397
cmd/streamdeck-ctl/status.go
Normal file
397
cmd/streamdeck-ctl/status.go
Normal 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 ""
|
||||
}
|
||||
Reference in New Issue
Block a user