diff --git a/README.md b/README.md index b4270b5..e0198d4 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ No Elgato software required — communicates directly with the device over USB H - PNG and JPEG icons, automatically scaled to key size - Animated GIF support — frames pre-encoded at startup, cycled at the GIF's native rate - Runs any shell command on key press +- **Status/toggle keys** — poll any shell command on an interval, swap icons based on output; icon updates on press - **Live config reload** — save your config and the deck updates instantly, no restart needed - Privileged command helper — run whitelisted root commands via a Unix socket; supports polkit auth dialogs - Automatic reconnect — survives USB unplug, KVM switches, and suspend/resume @@ -60,6 +61,10 @@ streamdeck-go/ │ └──▶ goroutine/key: loop frames, sleep per-frame delay │ (cancelled via ctx on reload) │ + ├── poll goroutine/key ──▶ exec poll command every interval + │ └──▶ match in stdout? swap icon_true / icon_false + │ (also triggered immediately on button press) + │ └── event loop ──▶ device.ReadButtons() (250 ms timeout, checks ctx) └──▶ key-down: exec.Command("sh", "-c", command) ``` @@ -272,6 +277,63 @@ keys: command: "" ``` +### 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. + +```yaml +keys: + 3: + command: pactl set-source-mute @DEFAULT_SOURCE@ toggle + icon_true: mic-muted.png # shown when poll output contains match + icon_false: mic-active.png # shown when poll output does not contain match + poll: + command: pactl get-source-mute @DEFAULT_SOURCE@ + interval: 2s # how often to check (default: 2s) + match: "yes" # substring to find in stdout → true +``` + +**How matching works:** + +| `match` set? | True condition | False condition | +|---|---|---| +| Yes | stdout contains the string | stdout does not contain it | +| No (omitted) | command exits 0 | command exits non-zero | + +**More examples:** + +```yaml +# VPN status (exit-code match — no match string needed) +4: + command: nmcli connection up my-vpn + icon_true: vpn-on.png + icon_false: vpn-off.png + poll: + command: nmcli connection show --active my-vpn + interval: 5s + +# Systemd service toggle +5: + command: systemctl --user toggle my-service + icon_true: service-running.png + icon_false: service-stopped.png + poll: + command: systemctl --user is-active my-service + interval: 3s + +# Speaker mute +6: + command: pactl set-sink-mute @DEFAULT_SINK@ toggle + icon_true: speaker-muted.png + icon_false: speaker-on.png + poll: + command: pactl get-sink-mute @DEFAULT_SINK@ + interval: 2s + match: "yes" +``` + +--- + ### Terminal & SSH commands Any shell command works — including launching terminals and SSH sessions. @@ -358,6 +420,7 @@ Works as long as SSH key auth is set up and the key has no passphrase (or the ag | PNG | Recommended for static icons | | JPEG | Good for photos / complex images | | GIF | Animated — all frames pre-encoded at startup, cycled in a background goroutine | +| SVG | Rasterised to 96×96 at startup via `oksvg` | Icons are scaled to 96×96 px using bi-linear filtering. The XL renders images mirrored, so they are pre-flipped before sending — your icons will appear the right diff --git a/config.example.yaml b/config.example.yaml index 3e24a7b..09cb7e5 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -19,6 +19,7 @@ device: # command: any shell command keys: + # Regular key: show an icon and run a command on press. 0: icon: ghostty.png command: ghostty @@ -28,3 +29,35 @@ keys: 2: icon: files.png command: nautilus + + # Toggle/status key: polls a command to determine state and shows one of two + # icons. Pressing the button runs `command` and the icon updates automatically. + # + # `match` is a substring to find in the poll command's stdout. + # If match is found → icon_on is shown; otherwise → icon_off. + # Omit `match` entirely to use exit code instead (0 = on, non-zero = off). + # + # Examples: + # Microphone mute: + # command: pactl set-source-mute @DEFAULT_SOURCE@ toggle + # poll.command: pactl get-source-mute @DEFAULT_SOURCE@ + # poll.match: "yes" # "Mute: yes" → muted → show icon_on (mic-muted.png) + # + # VPN toggle: + # command: nmcli connection up my-vpn / nmcli connection down my-vpn + # poll.command: nmcli connection show --active my-vpn + # (no match — uses exit code: 0 = connected) + # + # Systemd service: + # command: systemctl --user toggle my-service + # poll.command: systemctl --user is-active my-service + # (no match — uses exit code) + # + # 3: + # command: pactl set-source-mute @DEFAULT_SOURCE@ toggle + # icon_true: mic-muted.png + # icon_false: mic-active.png + # poll: + # command: pactl get-source-mute @DEFAULT_SOURCE@ + # interval: 2s + # match: "yes" diff --git a/go.mod b/go.mod index a2887c2..f93c6f1 100644 --- a/go.mod +++ b/go.mod @@ -9,4 +9,10 @@ require ( gopkg.in/yaml.v3 v3.0.1 ) -require golang.org/x/sys v0.13.0 // indirect +require ( + github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect + github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect + golang.org/x/net v0.0.0-20211118161319-6a13c67c3ce4 // indirect + golang.org/x/sys v0.13.0 // indirect + golang.org/x/text v0.35.0 // indirect +) diff --git a/go.sum b/go.sum index 13b4dac..4c26afb 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,19 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= +github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q= +github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= +github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= github.com/sstallion/go-hid v0.15.0 h1:WERW/VW3Us6N73V2qa7HjdqWQvwHd0CoRDOP/N707/w= github.com/sstallion/go-hid v0.15.0/go.mod h1:fPKp4rqx0xuoTV94gwKojsPG++KNKhxuU88goGuGM7I= golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA= golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= +golang.org/x/net v0.0.0-20211118161319-6a13c67c3ce4 h1:DZshvxDdVoeKIbudAdFEKi+f70l51luSy/7b76ibTY0= +golang.org/x/net v0.0.0-20211118161319-6a13c67c3ce4/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/config/config.go b/internal/config/config.go index 70be297..3e76d21 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,10 +8,22 @@ import ( "gopkg.in/yaml.v3" ) +// PollConfig defines how to poll for the current state of a toggle key. +type PollConfig struct { + Command string `yaml:"command"` // shell command whose output is checked + Interval string `yaml:"interval"` // how often to poll, e.g. "2s" (default: "2s") + Match string `yaml:"match"` // substring to find in output → "on" state; omit to use exit code 0 +} + // KeyConfig defines what a single Stream Deck key does. type KeyConfig struct { - Icon string `yaml:"icon"` // filename relative to icons_dir + Icon string `yaml:"icon"` // filename relative to icons_dir (regular keys) Command string `yaml:"command"` // shell command to run on press + + // 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 + Poll *PollConfig `yaml:"poll"` } // Config is the top-level structure of the YAML config file.