Compare commits
16 Commits
5e85dda038
...
Omarchy-Pl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d0748e4c5 | ||
|
|
4158165410 | ||
|
|
c143db6b68 | ||
|
|
2aa4c65e42 | ||
|
|
8cff4f8418 | ||
|
|
cef6f72712 | ||
|
|
d1d5d88508 | ||
|
|
a542ad60cd | ||
|
|
a51fd2beff | ||
|
|
8b6b4d582d | ||
|
|
212e957f49 | ||
|
|
38893cbb84 | ||
|
|
9b375cfba2 | ||
|
|
3a112dfd84 | ||
|
|
962ee747fd | ||
|
|
44dc22d8ee |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -2,6 +2,8 @@
|
||||
/streamdeck
|
||||
/streamdeck-go
|
||||
/streamdeck-helper
|
||||
/streamdeck-init
|
||||
/streamdeck-ctl
|
||||
/bin/
|
||||
*.exe
|
||||
|
||||
|
||||
187
Makefile
187
Makefile
@@ -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 uninstall uninstall-helper 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.
|
||||
@@ -88,6 +99,37 @@ install-helper: build-helper
|
||||
@echo " or run: newgrp $(GROUP)"
|
||||
endif
|
||||
|
||||
# Install the watchdog timer that detects USB unplug/replug and restarts the
|
||||
# service when the daemon's in-process reconnect misses an event. Linux only.
|
||||
ifeq ($(OS),Darwin)
|
||||
WATCHDOG_PLIST := $(LAUNCHAGENTS)/com.woodarddigital.streamdeck-go-watchdog.plist
|
||||
WATCHDOG_LOG := $(HOME)/Library/Logs/streamdeck-go-watchdog.log
|
||||
install-watchdog:
|
||||
mkdir -p $(BIN_DIR) $(LAUNCHAGENTS) $(HOME)/Library/Logs
|
||||
install -m 755 systemd/streamdeck-go-watchdog.sh $(BIN_DIR)/streamdeck-go-watchdog
|
||||
# Substitute the binary and log paths into the plist.
|
||||
sed -e 's|STREAMDECK_WATCHDOG_PATH|$(BIN_DIR)/streamdeck-go-watchdog|' \
|
||||
-e 's|STREAMDECK_WATCHDOG_LOG_PATH|$(WATCHDOG_LOG)|g' \
|
||||
launchd/com.woodarddigital.streamdeck-go-watchdog.plist > $(WATCHDOG_PLIST)
|
||||
# Reload: bootout (ignore if not loaded) then bootstrap.
|
||||
launchctl bootout gui/$$(id -u)/com.woodarddigital.streamdeck-go-watchdog 2>/dev/null || true
|
||||
launchctl bootstrap gui/$$(id -u) $(WATCHDOG_PLIST)
|
||||
@echo ""
|
||||
@echo "Watchdog installed. Fires every 30s."
|
||||
@echo " Logs: $(WATCHDOG_LOG)"
|
||||
else
|
||||
install-watchdog:
|
||||
install -Dm755 systemd/streamdeck-go-watchdog.sh $(BIN_DIR)/streamdeck-go-watchdog
|
||||
install -Dm644 systemd/streamdeck-go-watchdog.service $(SYSTEMD_USER)/streamdeck-go-watchdog.service
|
||||
install -Dm644 systemd/streamdeck-go-watchdog.timer $(SYSTEMD_USER)/streamdeck-go-watchdog.timer
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now streamdeck-go-watchdog.timer
|
||||
@echo ""
|
||||
@echo "Watchdog timer installed and started."
|
||||
@echo " Status: systemctl --user status streamdeck-go-watchdog.timer"
|
||||
@echo " Logs: journalctl --user -u streamdeck-go-watchdog.service"
|
||||
endif
|
||||
|
||||
# udev: Linux-only device permission rule.
|
||||
udev:
|
||||
ifeq ($(OS),Darwin)
|
||||
@@ -104,6 +146,135 @@ else
|
||||
fi
|
||||
endif
|
||||
|
||||
# ── Reinstall ─────────────────────────────────────────────────────────────────
|
||||
# Refresh only the pieces that are already installed: binary, service unit,
|
||||
# helper, watchdog, and modules.yaml. Skips dependency/dotfile/symlink setup.
|
||||
# Use this after a code change to redeploy without going through install.sh.
|
||||
|
||||
ifeq ($(OS),Darwin)
|
||||
reinstall: build
|
||||
@echo " ━━━ streamdeck-go reinstall ━━━"
|
||||
@if [ -f $(BIN_DIR)/$(BINARY) ]; then \
|
||||
install -m 755 $(BINARY) $(BIN_DIR)/$(BINARY); \
|
||||
echo " ✓ binary → $(BIN_DIR)/$(BINARY)"; \
|
||||
else \
|
||||
echo " · binary not installed (skipping — run 'make install' first)"; \
|
||||
fi
|
||||
@if [ -f $(AGENT_PLIST) ]; then \
|
||||
LOG_PATH="$$HOME/Library/Logs/streamdeck-go.log"; \
|
||||
sed -e "s|STREAMDECK_BINARY_PATH|$(BIN_DIR)/$(BINARY)|g" \
|
||||
-e "s|STREAMDECK_LOG_PATH|$$LOG_PATH|g" \
|
||||
launchd/com.woodarddigital.streamdeck-go.plist > $(AGENT_PLIST); \
|
||||
launchctl unload $(AGENT_PLIST) 2>/dev/null || true; \
|
||||
launchctl load $(AGENT_PLIST); \
|
||||
echo " ✓ launchd agent reloaded"; \
|
||||
else \
|
||||
echo " · launchd agent not installed (skipping)"; \
|
||||
fi
|
||||
@if [ -f $(WATCHDOG_PLIST) ]; then \
|
||||
install -m 755 systemd/streamdeck-go-watchdog.sh $(BIN_DIR)/streamdeck-go-watchdog; \
|
||||
sed -e 's|STREAMDECK_WATCHDOG_PATH|$(BIN_DIR)/streamdeck-go-watchdog|' \
|
||||
-e 's|STREAMDECK_WATCHDOG_LOG_PATH|$(WATCHDOG_LOG)|g' \
|
||||
launchd/com.woodarddigital.streamdeck-go-watchdog.plist > $(WATCHDOG_PLIST); \
|
||||
launchctl bootout gui/$$(id -u)/com.woodarddigital.streamdeck-go-watchdog 2>/dev/null || true; \
|
||||
launchctl bootstrap gui/$$(id -u) $(WATCHDOG_PLIST); \
|
||||
echo " ✓ watchdog refreshed"; \
|
||||
else \
|
||||
echo " · watchdog not installed (skipping)"; \
|
||||
fi
|
||||
@if [ -f $(CONFIG_DIR)/modules.yaml ]; then \
|
||||
install -m 644 modules.example.yaml $(CONFIG_DIR)/modules.yaml; \
|
||||
echo " ✓ modules.yaml updated"; \
|
||||
else \
|
||||
echo " · config dir not set up (skipping modules.yaml)"; \
|
||||
fi
|
||||
else
|
||||
reinstall: build
|
||||
@echo " ━━━ streamdeck-go reinstall ━━━"
|
||||
@if [ -f $(BIN_DIR)/$(BINARY) ]; then \
|
||||
install -m 755 $(BINARY) $(BIN_DIR)/$(BINARY); \
|
||||
echo " ✓ binary → $(BIN_DIR)/$(BINARY)"; \
|
||||
else \
|
||||
echo " · binary not installed (skipping — run 'make install' first)"; \
|
||||
fi
|
||||
@if [ -f $(SYSTEMD_USER)/streamdeck-go.service ]; then \
|
||||
install -m 644 systemd/streamdeck-go.service $(SYSTEMD_USER)/streamdeck-go.service; \
|
||||
systemctl --user daemon-reload; \
|
||||
systemctl --user restart streamdeck-go.service; \
|
||||
echo " ✓ systemd unit refreshed and service restarted"; \
|
||||
else \
|
||||
echo " · systemd user service not installed (skipping)"; \
|
||||
fi
|
||||
@if [ -f $(SYS_BIN)/$(HELPER) ]; then \
|
||||
$(MAKE) -s build-helper; \
|
||||
sudo install -m 750 $(HELPER) $(SYS_BIN)/$(HELPER); \
|
||||
sudo chown root:$(GROUP) $(SYS_BIN)/$(HELPER); \
|
||||
sudo install -m 644 systemd/streamdeck-go-helper.service $(SYSTEMD_SYS)/streamdeck-go-helper.service; \
|
||||
sudo systemctl daemon-reload; \
|
||||
sudo systemctl restart streamdeck-go-helper.service; \
|
||||
echo " ✓ helper refreshed and restarted"; \
|
||||
else \
|
||||
echo " · helper not installed (skipping)"; \
|
||||
fi
|
||||
@if [ -f $(SYSTEMD_USER)/streamdeck-go-watchdog.timer ]; then \
|
||||
install -m 755 systemd/streamdeck-go-watchdog.sh $(BIN_DIR)/streamdeck-go-watchdog; \
|
||||
install -m 644 systemd/streamdeck-go-watchdog.service $(SYSTEMD_USER)/streamdeck-go-watchdog.service; \
|
||||
install -m 644 systemd/streamdeck-go-watchdog.timer $(SYSTEMD_USER)/streamdeck-go-watchdog.timer; \
|
||||
systemctl --user daemon-reload; \
|
||||
systemctl --user restart streamdeck-go-watchdog.timer; \
|
||||
echo " ✓ watchdog refreshed"; \
|
||||
else \
|
||||
echo " · watchdog not installed (skipping)"; \
|
||||
fi
|
||||
@if [ -f $(CONFIG_DIR)/modules.yaml ]; then \
|
||||
install -m 644 modules.example.yaml $(CONFIG_DIR)/modules.yaml; \
|
||||
echo " ✓ modules.yaml updated"; \
|
||||
else \
|
||||
echo " · config dir not set up (skipping modules.yaml)"; \
|
||||
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)
|
||||
@@ -116,6 +287,12 @@ uninstall:
|
||||
uninstall-helper:
|
||||
@echo "No helper daemon on macOS — nothing to uninstall."
|
||||
@echo "Whitelist at $(CONFIG_DIR)/privileged.yaml preserved."
|
||||
|
||||
uninstall-watchdog:
|
||||
launchctl bootout gui/$$(id -u)/com.woodarddigital.streamdeck-go-watchdog 2>/dev/null || true
|
||||
rm -f $(WATCHDOG_PLIST)
|
||||
rm -f $(BIN_DIR)/streamdeck-go-watchdog
|
||||
@echo "Watchdog uninstalled."
|
||||
else
|
||||
uninstall:
|
||||
systemctl --user disable --now streamdeck-go.service || true
|
||||
@@ -124,6 +301,14 @@ uninstall:
|
||||
systemctl --user daemon-reload
|
||||
@echo "Uninstalled. Config at $(CONFIG_DIR) preserved."
|
||||
|
||||
uninstall-watchdog:
|
||||
systemctl --user disable --now streamdeck-go-watchdog.timer || true
|
||||
rm -f $(BIN_DIR)/streamdeck-go-watchdog
|
||||
rm -f $(SYSTEMD_USER)/streamdeck-go-watchdog.service
|
||||
rm -f $(SYSTEMD_USER)/streamdeck-go-watchdog.timer
|
||||
systemctl --user daemon-reload
|
||||
@echo "Watchdog uninstalled."
|
||||
|
||||
uninstall-helper:
|
||||
sudo systemctl disable --now streamdeck-go-helper.service || true
|
||||
sudo rm -f $(SYS_BIN)/$(HELPER)
|
||||
|
||||
14
OMARCHY.md
14
OMARCHY.md
@@ -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
|
||||
|
||||
324
README.md
324
README.md
@@ -14,17 +14,18 @@ No Elgato software required — communicates directly with the device over USB H
|
||||
- PNG, JPEG, and SVG 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
|
||||
- **Status/toggle keys** — poll any shell command on an interval, swap icons based on output; icon updates on press. Either icon can be an animated GIF (e.g. a flashing record indicator when recording is active).
|
||||
- **Live config reload** — save your config and the deck updates instantly, no restart needed
|
||||
- **Privileged commands** — run whitelisted root/admin commands; Linux uses a root helper daemon, macOS uses the native admin auth dialog
|
||||
- Automatic reconnect — survives USB unplug, KVM switches, and suspend/resume
|
||||
- Runs as a systemd user service (Linux) or launchd agent (macOS), starts automatically at login
|
||||
- No Stream Deck app, no Node.js, no Electron
|
||||
|
||||
- **Modules** — define reusable, parameterised commands in `modules.yaml` with Go templates; secrets stay in env vars, config stays in dotfiles. First built-in example: Slack (status, presence, snooze). See [Modules](#modules).
|
||||
- **Text overlays** — add a `text` field to any key for auto-sized, word-wrapped labels; white by default, or set `text_color` to black, red, blue, or any `#RRGGBB` hex. Works with icons (overlaid), without icons (white text on black), and on toggle keys. See [Text overlays](#text-overlays).
|
||||
- **Modules** — define reusable, parameterised commands in `modules.yaml` with Go templates; secrets stay in env vars, config stays in dotfiles. Built-in examples: Slack (status, presence, snooze) and OBS Studio (recording, streaming, scene switching, media control). See [Modules](#modules).
|
||||
- **Interactive config builder** — TUI tool (`streamdeck-init`) that walks you through key setup: pick a slot, pick a module/function, customize params, choose an icon. No YAML editing required. See [Config builder](#config-builder).
|
||||
|
||||
**Planned:** text/label overlays on keys, multi-page layouts, AUR package — see [Roadmap](#roadmap)
|
||||
**Planned:** multi-page layouts, AUR package — see [Roadmap](#roadmap)
|
||||
|
||||
---
|
||||
|
||||
@@ -69,6 +70,8 @@ streamdeck-go/
|
||||
│ └──▶ registry.Resolve() → rendered shell command
|
||||
│ (templates expanded, env vars resolved)
|
||||
│
|
||||
├── text overlay? ──▶ overlayText() (auto-size font, word wrap, outline)
|
||||
│
|
||||
├── static icon ──▶ device.SetKeyImage() (scale → flip → JPEG → HID)
|
||||
│
|
||||
├── animated GIF ──▶ device.EncodeFrame() (pre-encode all frames once)
|
||||
@@ -96,7 +99,7 @@ interleave partial image data across keys.
|
||||
|---|---|
|
||||
| [`github.com/sstallion/go-hid`](https://github.com/sstallion/go-hid) | Bindings for `libhidapi` — USB HID read/write |
|
||||
| [`github.com/fsnotify/fsnotify`](https://github.com/fsnotify/fsnotify) | Config file watching for live reload |
|
||||
| [`golang.org/x/image`](https://pkg.go.dev/golang.org/x/image) | Bi-linear image scaling |
|
||||
| [`golang.org/x/image`](https://pkg.go.dev/golang.org/x/image) | Bi-linear image scaling, font rendering (text overlays use embedded Go Bold font) |
|
||||
| [`gopkg.in/yaml.v3`](https://pkg.go.dev/gopkg.in/yaml.v3) | YAML config parsing |
|
||||
| [`github.com/srwiley/oksvg`](https://github.com/srwiley/oksvg) | SVG rasterisation |
|
||||
| Go stdlib `image/gif`, `image/jpeg`, `image/png` | Image decoding and JPEG encoding |
|
||||
@@ -123,7 +126,7 @@ interleave partial image data across keys.
|
||||
# Prerequisites
|
||||
brew install go hidapi
|
||||
|
||||
git clone https://github.com/WoodardDigital/streamdeck-go
|
||||
git clone https://git.i0t.app/WoodardDigital/streamdeck-go
|
||||
cd streamdeck-go
|
||||
make install
|
||||
```
|
||||
@@ -151,7 +154,7 @@ directory integration.
|
||||
# Prerequisites — Arch example; adjust for your distro (see table above)
|
||||
sudo pacman -S go hidapi
|
||||
|
||||
git clone https://github.com/WoodardDigital/streamdeck-go
|
||||
git clone https://git.i0t.app/WoodardDigital/streamdeck-go
|
||||
cd streamdeck-go
|
||||
make install
|
||||
```
|
||||
@@ -215,7 +218,7 @@ sudo udevadm trigger
|
||||
**2. Build and run:**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/WoodardDigital/streamdeck-go
|
||||
git clone https://git.i0t.app/WoodardDigital/streamdeck-go
|
||||
cd streamdeck-go
|
||||
|
||||
cp config.example.yaml config.yaml
|
||||
@@ -230,6 +233,33 @@ first (respecting `$XDG_CONFIG_HOME`). The repo's `config.yaml` is gitignored.
|
||||
|
||||
---
|
||||
|
||||
### Updating — `make reinstall`
|
||||
|
||||
After pulling new code or editing a Go source file, refresh whatever's already
|
||||
deployed without going through the full installer:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
make reinstall
|
||||
```
|
||||
|
||||
`reinstall` rebuilds the binary and refreshes only the pieces that are already
|
||||
installed:
|
||||
|
||||
| Component | Action when present |
|
||||
|-------------------|--------------------------------------------------------------------|
|
||||
| Main binary | rebuilt and copied into `~/.local/bin` (Linux) or `~/go/bin` (macOS) |
|
||||
| Service unit | systemd unit / launchd plist re-installed; service restarted |
|
||||
| Helper (Linux) | rebuilt, re-installed under `/usr/local/bin`, helper service restarted (sudo) |
|
||||
| Watchdog | script + unit/plist refreshed and timer restarted |
|
||||
| `modules.yaml` | re-copied from `modules.example.yaml` into the active config dir |
|
||||
|
||||
Anything not currently installed prints `· skipped` instead of failing.
|
||||
No dependency installs, no dotfile prompts, no symlink logic — that's still
|
||||
`make install`'s job.
|
||||
|
||||
---
|
||||
|
||||
### AUR (Arch Linux)
|
||||
|
||||
> AUR package coming soon. Until then, use `make install` above.
|
||||
@@ -322,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.
|
||||
@@ -403,6 +472,68 @@ keys:
|
||||
|
||||
---
|
||||
|
||||
### Text overlays
|
||||
|
||||
Add a `text` field to any key to render a small label at the bottom of the key. The text is rendered at a fixed label size (~15pt at 96×96 key resolution), bottom-aligned so the icon stays visible above it. A contrasting outline is drawn automatically for readability on any background.
|
||||
|
||||
Text stays on a single line unless you add explicit line breaks with `\n` or YAML's `|` literal block syntax. If the text is too wide for the key, the font shrinks to fit. Multi-line text (up to 4-5 lines) is supported via explicit newlines.
|
||||
|
||||
The source image is scaled to key resolution (96×96 for XL, 72×72 for MK.2) before text is rendered, so label size is consistent regardless of source icon dimensions.
|
||||
|
||||
```yaml
|
||||
keys:
|
||||
# Label on an icon — single line, bottom-aligned
|
||||
9:
|
||||
icon: lock.png
|
||||
text: "Lock Machine"
|
||||
command: hyprctl dispatch exec omarchy-lock-screen
|
||||
|
||||
# Multi-line (explicit newlines only — no automatic word wrap)
|
||||
16:
|
||||
icon: server.png
|
||||
text: |
|
||||
Restart
|
||||
Web Server
|
||||
command: systemctl restart nginx
|
||||
|
||||
# Text-only key (no icon — white text on black background)
|
||||
17:
|
||||
text: "Build\nDeploy"
|
||||
command: make deploy
|
||||
|
||||
# Custom text color
|
||||
18:
|
||||
icon: alert.png
|
||||
text: "DANGER"
|
||||
text_color: red
|
||||
command: nuke-from-orbit
|
||||
|
||||
# Hex color
|
||||
19:
|
||||
icon: status.png
|
||||
text: "Online"
|
||||
text_color: "#00FF00"
|
||||
command: ""
|
||||
```
|
||||
|
||||
**`text_color`** is optional and defaults to white. Supported values:
|
||||
|
||||
| Value | Color |
|
||||
|---|---|
|
||||
| `white` (default) | White |
|
||||
| `black` | Black |
|
||||
| `red` | Red |
|
||||
| `blue` | Blue |
|
||||
| `#RRGGBB` | Any hex color |
|
||||
|
||||
Text overlays work on:
|
||||
- **Static keys** — label at the bottom of the icon
|
||||
- **Text-only keys** — no icon needed, renders on a black background
|
||||
- **Toggle keys** — text appears on both `icon_true` and `icon_false`
|
||||
- GIF keys do not currently support text overlays
|
||||
|
||||
---
|
||||
|
||||
### Launching applications
|
||||
|
||||
On Linux, GUI apps are typically launched by their binary name (`firefox`, `ghostty`, `nautilus`). On macOS, apps live in `/Applications/` as `.app` bundles and need to be opened differently.
|
||||
@@ -669,6 +800,146 @@ modules:
|
||||
|
||||
A full example is included at [`modules.example.yaml`](modules.example.yaml) in the repo root.
|
||||
|
||||
#### OBS Studio module
|
||||
|
||||
The example `modules.yaml` also includes an `obs` module that drives OBS over WebSocket using [`obs-cmd`](https://github.com/grigio/obs-cmd).
|
||||
|
||||
**Setup:**
|
||||
|
||||
1. Install `obs-cmd`:
|
||||
- macOS: `brew install grigio/obs-cmd/obs-cmd`
|
||||
- Linux: `cargo install obs-cmd` or download a release binary
|
||||
2. Requires **OBS 30.2 or newer** — earlier versions fail `obs-cmd`'s version check.
|
||||
3. In OBS, enable **Tools → WebSocket Server Settings** and note the port + password.
|
||||
4. Export env vars (see [Secrets and tokens](#secrets-and-tokens) for launchd/systemd setup):
|
||||
```bash
|
||||
export OBS_WEBSOCKET_PASSWORD="your-password"
|
||||
export OBS_HOST="localhost" # or remote IP, e.g. 192.168.1.28
|
||||
export OBS_PORT="4455"
|
||||
```
|
||||
|
||||
**Functions:**
|
||||
|
||||
| Function | Purpose | Params |
|
||||
|---|---|---|
|
||||
| `play` / `pause` / `stop` / `restart` | Media input controls | `source` (default `"Media Source"`) |
|
||||
| `toggle_record` | Start/stop recording | — |
|
||||
| `toggle_record_pause` | Pause/resume recording | — |
|
||||
| `toggle_stream` | Start/stop streaming | — |
|
||||
| `scene_switch` | Switch active scene | `scene` (default `"Scene 1"`) |
|
||||
| `toggle_mute` | Toggle input mute | `source` (default `"Mic/Aux"`) |
|
||||
| `is_recording` | Status check for poll blocks | — |
|
||||
| `is_recording_paused` | Status check for poll blocks | — |
|
||||
| `is_streaming` | Status check for poll blocks | — |
|
||||
|
||||
**Example — record button with flashing GIF when active:**
|
||||
|
||||
```yaml
|
||||
keys:
|
||||
7:
|
||||
icon_true: recording.gif # animated — flashes while recording
|
||||
icon_false: camera.png # static — shown when stopped
|
||||
module: obs
|
||||
function: toggle_record
|
||||
poll:
|
||||
module: obs
|
||||
function: is_recording
|
||||
match: "Active: true" # obs-cmd output contains this when recording
|
||||
interval: 2s
|
||||
|
||||
15:
|
||||
icon_true: pause.svg
|
||||
icon_false: play.svg
|
||||
module: obs
|
||||
function: toggle_record_pause
|
||||
poll:
|
||||
module: obs
|
||||
function: is_recording_paused
|
||||
match: "Paused: true"
|
||||
interval: 2s
|
||||
|
||||
```
|
||||
|
||||
**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:
|
||||
@@ -741,6 +1012,36 @@ keys:
|
||||
match: "snooze_enabled.*true"
|
||||
```
|
||||
|
||||
#### Adding your own modules
|
||||
|
||||
Modules are purely YAML — no Go code changes required.
|
||||
|
||||
1. Open `~/.config/streamdeck-go/modules.yaml`.
|
||||
2. Add a new top-level key under `modules:` (e.g. `home_assistant`, `spotify`).
|
||||
3. Define functions, each with an `exec` template and optional default `params`. Use `{{env "VAR"}}` for secrets and `{{.paramName}}` for parameters.
|
||||
4. Save — the daemon reloads automatically.
|
||||
5. Reference the new module from `config.yaml` with `module:` / `function:` / `params:`.
|
||||
|
||||
**Skeleton:**
|
||||
|
||||
```yaml
|
||||
modules:
|
||||
my_api:
|
||||
do_thing:
|
||||
params:
|
||||
target: "default-value"
|
||||
exec: |
|
||||
curl -s -X POST https://example.com/api/thing \
|
||||
-H "Authorization: Bearer {{env "MY_API_TOKEN"}}" \
|
||||
-d '{"target":"{{.target}}"}'
|
||||
```
|
||||
|
||||
**Tips:**
|
||||
|
||||
- If the template calls an external binary (like `curl`, `obs-cmd`, `osascript`), use the absolute path — service `PATH` is minimal.
|
||||
- Chain multiple API calls with `&&` inside a single `exec` (see `go_offline` in the example).
|
||||
- Status functions for poll blocks should output text matching a substring you set in the key's `poll.match`.
|
||||
|
||||
#### Template helpers
|
||||
|
||||
Two helpers are available in `exec` templates:
|
||||
@@ -913,10 +1214,10 @@ To add a model, edit the `models` map in [internal/device/streamdeck.go](interna
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Text / label overlay on icons
|
||||
### Dynamic text from command output
|
||||
|
||||
Render dynamic text directly onto a key image at runtime — useful for showing
|
||||
live state like volume level, a clock, a counter, or the current git branch.
|
||||
Extend the text overlay feature to render live output from a shell command —
|
||||
useful for showing volume level, a clock, a counter, or the current git branch.
|
||||
|
||||
Example config (proposed):
|
||||
|
||||
@@ -924,8 +1225,7 @@ Example config (proposed):
|
||||
keys:
|
||||
4:
|
||||
icon: volume.png
|
||||
label: "$(pactl get-sink-volume @DEFAULT_SINK@ | awk '{print $5}')"
|
||||
label_position: bottom # top | center | bottom
|
||||
text: "$(pactl get-sink-volume @DEFAULT_SINK@ | awk '{print $5}')"
|
||||
refresh: 5s
|
||||
```
|
||||
|
||||
|
||||
450
cmd/streamdeck-ctl/main.go
Normal file
450
cmd/streamdeck-ctl/main.go
Normal 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(¶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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
443
cmd/streamdeck-ctl/status.go
Normal file
443
cmd/streamdeck-ctl/status.go
Normal 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 ""
|
||||
}
|
||||
@@ -29,7 +29,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/WoodardDigital/streamdeck-go/internal/config"
|
||||
"git.i0t.app/lwoodard/streamdeck-go/internal/config"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
// 3. Renders an 8x4 key grid showing which slots are free vs occupied.
|
||||
// 4. Walks the user through a series of huh forms:
|
||||
// - Pick a key slot (0-31)
|
||||
// - Choose action type: module function or raw shell command
|
||||
// - Choose action type: module function, shell command, or toggle key (with poll)
|
||||
// - If module: pick module → function → customize params (with defaults)
|
||||
// - If command: type a shell command
|
||||
// - Pick an icon from icons_dir (or type a custom filename)
|
||||
@@ -22,10 +22,10 @@
|
||||
//
|
||||
// ## Expanding this tool
|
||||
//
|
||||
// To add new TUI steps (e.g. toggle key setup with poll config, or a
|
||||
// "delete key" flow), add a new function following the pattern of
|
||||
// configureModuleKey/configureCommandKey: build huh forms, collect values
|
||||
// into a keyEntry, return it for the confirm/append step.
|
||||
// To add new TUI steps (e.g. a "delete key" flow), add a new function
|
||||
// following the pattern of configureModuleKey/configureCommandKey/
|
||||
// configureToggleKey: build huh forms, collect values into a keyEntry,
|
||||
// return it for the confirm/append step.
|
||||
//
|
||||
// To support new key types in the YAML output, update keyEntry and
|
||||
// renderKeySnippet() in yaml.go.
|
||||
@@ -51,9 +51,9 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/WoodardDigital/streamdeck-go/internal/config"
|
||||
"github.com/WoodardDigital/streamdeck-go/internal/defaults"
|
||||
"github.com/WoodardDigital/streamdeck-go/internal/modules"
|
||||
"git.i0t.app/lwoodard/streamdeck-go/internal/config"
|
||||
"git.i0t.app/lwoodard/streamdeck-go/internal/defaults"
|
||||
"git.i0t.app/lwoodard/streamdeck-go/internal/modules"
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
@@ -185,29 +185,51 @@ func runOnce(cfgPath string) error {
|
||||
var entry keyEntry
|
||||
entry.Index = keyIdx
|
||||
|
||||
if actionType == "module" {
|
||||
// Module path: module → function → params (with defaults pre-filled).
|
||||
switch actionType {
|
||||
case "module":
|
||||
entry, err = configureModuleKey(keyIdx, reg)
|
||||
} else {
|
||||
// Command path: just a shell command string.
|
||||
case "toggle":
|
||||
entry, err = configureToggleKey(keyIdx, reg)
|
||||
default:
|
||||
entry, err = configureCommandKey(keyIdx)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 4: Pick an icon file.
|
||||
icon, err := pickIcon(cfg.IconsDir)
|
||||
if err != nil {
|
||||
return err
|
||||
// Step 4: Pick icon(s).
|
||||
if entry.IsToggle {
|
||||
fmt.Println(lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")).Render(" Icon for ON state:"))
|
||||
iconTrue, err := pickIcon(cfg.IconsDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entry.IconTrue = iconTrue
|
||||
|
||||
fmt.Println(lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")).Render(" Icon for OFF state:"))
|
||||
iconFalse, err := pickIcon(cfg.IconsDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entry.IconFalse = iconFalse
|
||||
} else {
|
||||
icon, err := pickIcon(cfg.IconsDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entry.Icon = icon
|
||||
}
|
||||
entry.Icon = icon
|
||||
|
||||
// Step 5: Show summary and confirm before writing.
|
||||
fmt.Println()
|
||||
fmt.Println(lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")).Render(" Summary:"))
|
||||
fmt.Printf(" Key: %d\n", entry.Index)
|
||||
fmt.Printf(" Icon: %s\n", entry.Icon)
|
||||
if entry.IsToggle {
|
||||
fmt.Printf(" Icon ON: %s\n", entry.IconTrue)
|
||||
fmt.Printf(" Icon OFF: %s\n", entry.IconFalse)
|
||||
} else {
|
||||
fmt.Printf(" Icon: %s\n", entry.Icon)
|
||||
}
|
||||
if entry.Module != "" {
|
||||
fmt.Printf(" Module: %s\n", entry.Module)
|
||||
fmt.Printf(" Function: %s\n", entry.Function)
|
||||
@@ -220,6 +242,27 @@ func runOnce(cfgPath string) error {
|
||||
} else {
|
||||
fmt.Printf(" Command: %s\n", entry.Command)
|
||||
}
|
||||
if entry.IsToggle {
|
||||
fmt.Println(" Poll:")
|
||||
if entry.PollModule != "" {
|
||||
fmt.Printf(" Module: %s\n", entry.PollModule)
|
||||
fmt.Printf(" Function: %s\n", entry.PollFunction)
|
||||
if len(entry.PollParams) > 0 {
|
||||
fmt.Println(" Params:")
|
||||
for k, v := range entry.PollParams {
|
||||
fmt.Printf(" %s: %s\n", k, v)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Printf(" Command: %s\n", entry.PollCommand)
|
||||
}
|
||||
if entry.PollMatch != "" {
|
||||
fmt.Printf(" Match: %s\n", entry.PollMatch)
|
||||
}
|
||||
if entry.PollInterval != "" {
|
||||
fmt.Printf(" Interval: %s\n", entry.PollInterval)
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
var confirm bool
|
||||
@@ -281,10 +324,8 @@ func pickKeySlot(keys map[int]config.KeyConfig) (int, error) {
|
||||
return keyIdx, nil
|
||||
}
|
||||
|
||||
// pickActionType asks whether to configure a module function or a raw shell command.
|
||||
//
|
||||
// FUTURE: add "Toggle key (with poll)" as a third option, which would walk
|
||||
// through icon_true/icon_false and poll config setup.
|
||||
// pickActionType asks whether to configure a module function, a raw shell command,
|
||||
// or a toggle key with poll-based state checking.
|
||||
func pickActionType() (string, error) {
|
||||
var actionType string
|
||||
form := huh.NewForm(
|
||||
@@ -292,8 +333,9 @@ func pickActionType() (string, error) {
|
||||
huh.NewSelect[string]().
|
||||
Title("What should this key do?").
|
||||
Options(
|
||||
huh.NewOption("Module function (Slack, etc.)", "module"),
|
||||
huh.NewOption("Module function (Slack, OBS, etc.)", "module"),
|
||||
huh.NewOption("Shell command", "command"),
|
||||
huh.NewOption("Toggle key (with poll)", "toggle"),
|
||||
).
|
||||
Value(&actionType),
|
||||
),
|
||||
@@ -434,6 +476,123 @@ func configureCommandKey(keyIdx int) (keyEntry, error) {
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// configureToggleKey walks the user through setting up a toggle key:
|
||||
// press action (module or command) + poll configuration (module or command,
|
||||
// match string, interval). Icons are handled separately in runOnce().
|
||||
func configureToggleKey(keyIdx int, reg *modules.Registry) (keyEntry, error) {
|
||||
entry := keyEntry{Index: keyIdx, IsToggle: true}
|
||||
|
||||
// Ask what happens when the key is pressed.
|
||||
var pressType string
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("What should pressing this key do?").
|
||||
Options(
|
||||
huh.NewOption("Module function", "module"),
|
||||
huh.NewOption("Shell command", "command"),
|
||||
).
|
||||
Value(&pressType),
|
||||
),
|
||||
)
|
||||
if err := form.Run(); err != nil {
|
||||
return entry, err
|
||||
}
|
||||
|
||||
if pressType == "module" {
|
||||
modEntry, err := configureModuleKey(keyIdx, reg)
|
||||
if err != nil {
|
||||
return entry, err
|
||||
}
|
||||
entry.Module = modEntry.Module
|
||||
entry.Function = modEntry.Function
|
||||
entry.Params = modEntry.Params
|
||||
} else {
|
||||
cmdEntry, err := configureCommandKey(keyIdx)
|
||||
if err != nil {
|
||||
return entry, err
|
||||
}
|
||||
entry.Command = cmdEntry.Command
|
||||
}
|
||||
|
||||
// Configure the poll block.
|
||||
if err := configurePoll(&entry, reg); err != nil {
|
||||
return entry, err
|
||||
}
|
||||
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// configurePoll walks through poll configuration for a toggle key: how to check
|
||||
// state (module function or shell command), optional match string, and interval.
|
||||
func configurePoll(entry *keyEntry, reg *modules.Registry) error {
|
||||
var pollType string
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("How should the key check its state?").
|
||||
Options(
|
||||
huh.NewOption("Module function", "module"),
|
||||
huh.NewOption("Shell command", "command"),
|
||||
).
|
||||
Value(&pollType),
|
||||
),
|
||||
)
|
||||
if err := form.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if pollType == "module" {
|
||||
modEntry, err := configureModuleKey(entry.Index, reg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entry.PollModule = modEntry.Module
|
||||
entry.PollFunction = modEntry.Function
|
||||
entry.PollParams = modEntry.Params
|
||||
} else {
|
||||
var cmd string
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("Poll command").
|
||||
Description("Shell command to check key state").
|
||||
Value(&cmd).
|
||||
Placeholder("e.g. pgrep -x myapp"),
|
||||
),
|
||||
)
|
||||
if err := form.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
entry.PollCommand = cmd
|
||||
}
|
||||
|
||||
// Match string and interval.
|
||||
var match, interval string
|
||||
interval = "2s"
|
||||
form = huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("Match string").
|
||||
Description("Substring in output that means ON (leave empty to use exit code)").
|
||||
Value(&match).
|
||||
Placeholder("optional"),
|
||||
huh.NewInput().
|
||||
Title("Poll interval").
|
||||
Description("How often to check state").
|
||||
Value(&interval).
|
||||
Placeholder("2s"),
|
||||
),
|
||||
)
|
||||
if err := form.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
entry.PollMatch = match
|
||||
entry.PollInterval = interval
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// pickIcon lets the user select an icon file.
|
||||
//
|
||||
// If icons_dir has image files, shows a select list of them plus a "type custom"
|
||||
|
||||
@@ -31,8 +31,8 @@
|
||||
//
|
||||
// ## Expanding
|
||||
//
|
||||
// To support toggle keys (icon_true/icon_false + poll block), add those fields
|
||||
// to keyEntry and extend renderKeySnippet() to emit the extra YAML lines.
|
||||
// Toggle keys (icon_true/icon_false + poll block) are supported — see IsToggle
|
||||
// and the Poll* fields on keyEntry, and the toggle branch in renderKeySnippet().
|
||||
//
|
||||
// To support deleting or editing existing keys, you'd need a different strategy
|
||||
// (e.g. yaml.v3 node-level manipulation). That's not in scope for the current
|
||||
@@ -51,9 +51,11 @@ import (
|
||||
// keyEntry holds the data collected from the TUI forms, ready to be rendered
|
||||
// as a YAML snippet and appended to config.yaml.
|
||||
//
|
||||
// Two mutually exclusive modes:
|
||||
// Three mutually exclusive modes:
|
||||
// - Module key: Module + Function + Params are set, Command is empty
|
||||
// - Command key: Command is set, Module/Function/Params are empty
|
||||
// - Toggle key: IsToggle is true, IconTrue/IconFalse replace Icon,
|
||||
// and Poll* fields define state checking. Press action uses Module or Command.
|
||||
type keyEntry struct {
|
||||
Index int // key slot (0-31)
|
||||
Icon string // icon filename relative to icons_dir
|
||||
@@ -61,6 +63,17 @@ type keyEntry struct {
|
||||
Function string // function name within the module (e.g. "set_status")
|
||||
Params map[string]string // param overrides (merged with module defaults at runtime)
|
||||
Command string // raw shell command (non-module keys only)
|
||||
|
||||
// Toggle key fields
|
||||
IsToggle bool
|
||||
IconTrue string // icon when poll state is true/on
|
||||
IconFalse string // icon when poll state is false/off
|
||||
PollCommand string // shell command to check state
|
||||
PollModule string // module for poll (alternative to PollCommand)
|
||||
PollFunction string // function for poll
|
||||
PollParams map[string]string // poll param overrides
|
||||
PollMatch string // substring match in output -> true state
|
||||
PollInterval string // poll frequency (e.g. "2s")
|
||||
}
|
||||
|
||||
// appendKeyToConfig reads config.yaml, appends a key entry as raw YAML text,
|
||||
@@ -123,7 +136,13 @@ func renderKeySnippet(e keyEntry) string {
|
||||
var b strings.Builder
|
||||
|
||||
fmt.Fprintf(&b, " %d:\n", e.Index)
|
||||
fmt.Fprintf(&b, " icon: %s\n", e.Icon)
|
||||
|
||||
if e.IsToggle {
|
||||
fmt.Fprintf(&b, " icon_true: %s\n", e.IconTrue)
|
||||
fmt.Fprintf(&b, " icon_false: %s\n", e.IconFalse)
|
||||
} else {
|
||||
fmt.Fprintf(&b, " icon: %s\n", e.Icon)
|
||||
}
|
||||
|
||||
if e.Module != "" {
|
||||
fmt.Fprintf(&b, " module: %s\n", e.Module)
|
||||
@@ -144,5 +163,32 @@ func renderKeySnippet(e keyEntry) string {
|
||||
fmt.Fprintf(&b, " command: \"%s\"\n", e.Command)
|
||||
}
|
||||
|
||||
if e.IsToggle {
|
||||
b.WriteString(" poll:\n")
|
||||
if e.PollModule != "" {
|
||||
fmt.Fprintf(&b, " module: %s\n", e.PollModule)
|
||||
fmt.Fprintf(&b, " function: %s\n", e.PollFunction)
|
||||
if len(e.PollParams) > 0 {
|
||||
b.WriteString(" params:\n")
|
||||
pkeys := make([]string, 0, len(e.PollParams))
|
||||
for k := range e.PollParams {
|
||||
pkeys = append(pkeys, k)
|
||||
}
|
||||
sort.Strings(pkeys)
|
||||
for _, k := range pkeys {
|
||||
fmt.Fprintf(&b, " %s: \"%s\"\n", k, e.PollParams[k])
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(&b, " command: \"%s\"\n", e.PollCommand)
|
||||
}
|
||||
if e.PollMatch != "" {
|
||||
fmt.Fprintf(&b, " match: \"%s\"\n", e.PollMatch)
|
||||
}
|
||||
if e.PollInterval != "" {
|
||||
fmt.Fprintf(&b, " interval: \"%s\"\n", e.PollInterval)
|
||||
}
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
30
cmd/streamdeck/folder_test.go
Normal file
30
cmd/streamdeck/folder_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,18 @@ package main
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"log"
|
||||
"math"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -21,12 +24,17 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/WoodardDigital/streamdeck-go/internal/config"
|
||||
"github.com/WoodardDigital/streamdeck-go/internal/device"
|
||||
"github.com/WoodardDigital/streamdeck-go/internal/modules"
|
||||
"git.i0t.app/lwoodard/streamdeck-go/internal/config"
|
||||
"git.i0t.app/lwoodard/streamdeck-go/internal/device"
|
||||
"git.i0t.app/lwoodard/streamdeck-go/internal/modules"
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"github.com/srwiley/oksvg"
|
||||
"github.com/srwiley/rasterx"
|
||||
xdraw "golang.org/x/image/draw"
|
||||
"golang.org/x/image/font"
|
||||
"golang.org/x/image/font/gofont/gobold"
|
||||
"golang.org/x/image/font/opentype"
|
||||
"golang.org/x/image/math/fixed"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
@@ -151,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() {
|
||||
@@ -166,93 +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 == "" {
|
||||
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 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())
|
||||
@@ -263,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:
|
||||
}
|
||||
@@ -273,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)
|
||||
@@ -288,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() {
|
||||
@@ -302,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{}) {
|
||||
@@ -371,6 +587,10 @@ func pollKey(ctx context.Context, sd *device.StreamDeck, keyIdx int, keyCfg conf
|
||||
return
|
||||
}
|
||||
}
|
||||
if keyCfg.Text != "" {
|
||||
imgTrue = overlayText(imgTrue, keyCfg.Text, keyCfg.TextColor, sd.ImageWidth())
|
||||
imgFalse = overlayText(imgFalse, keyCfg.Text, keyCfg.TextColor, sd.ImageWidth())
|
||||
}
|
||||
|
||||
lastState := -1 // unknown, forces initial icon set
|
||||
var animCancel context.CancelFunc
|
||||
@@ -466,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
|
||||
@@ -555,6 +888,174 @@ func loadGIF(sd *device.StreamDeck, path string) (frames [][]byte, delays []time
|
||||
return frames, delays, nil
|
||||
}
|
||||
|
||||
// --- Text overlay ---
|
||||
|
||||
var (
|
||||
parsedFont *opentype.Font
|
||||
fontOnce sync.Once
|
||||
)
|
||||
|
||||
func getFont() *opentype.Font {
|
||||
fontOnce.Do(func() {
|
||||
f, err := opentype.Parse(gobold.TTF)
|
||||
if err != nil {
|
||||
log.Fatalf("parse embedded font: %v", err)
|
||||
}
|
||||
parsedFont = f
|
||||
})
|
||||
return parsedFont
|
||||
}
|
||||
|
||||
// parseTextColor converts a color name or hex string to a color.Color.
|
||||
// Supported names: white (default), black, red, blue. Hex: "#RRGGBB".
|
||||
func parseTextColor(s string) color.Color {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "", "white":
|
||||
return color.White
|
||||
case "black":
|
||||
return color.Black
|
||||
case "red":
|
||||
return color.RGBA{R: 255, A: 255}
|
||||
case "blue":
|
||||
return color.RGBA{B: 255, A: 255}
|
||||
default:
|
||||
s = strings.TrimPrefix(s, "#")
|
||||
if len(s) == 6 {
|
||||
b, err := hex.DecodeString(s)
|
||||
if err == nil && len(b) == 3 {
|
||||
return color.RGBA{R: b[0], G: b[1], B: b[2], A: 255}
|
||||
}
|
||||
}
|
||||
log.Printf("unknown text_color %q, using white", s)
|
||||
return color.White
|
||||
}
|
||||
}
|
||||
|
||||
// contrastColor returns black or white depending on which contrasts better
|
||||
// with the given color. Used for the text outline/shadow.
|
||||
func contrastColor(c color.Color) color.Color {
|
||||
r, g, b, _ := c.RGBA()
|
||||
// Perceived luminance (values are 16-bit, so divide by 257 to get 8-bit).
|
||||
lum := 0.299*float64(r/257) + 0.587*float64(g/257) + 0.114*float64(b/257)
|
||||
if lum > 128 {
|
||||
return color.Black
|
||||
}
|
||||
return color.White
|
||||
}
|
||||
|
||||
// splitLines splits text on explicit newlines only. No automatic word wrapping.
|
||||
func splitLines(text string) []string {
|
||||
return strings.Split(text, "\n")
|
||||
}
|
||||
|
||||
// overlayText renders text onto img at keySize resolution with auto-sizing.
|
||||
// The image is first scaled to keySize×keySize so font sizes are consistent
|
||||
// regardless of source image dimensions.
|
||||
// textColor is parsed from the config; shadow/outline uses the contrasting color.
|
||||
func overlayText(img image.Image, text, textColorStr string, keySize int) image.Image {
|
||||
if text == "" {
|
||||
return img
|
||||
}
|
||||
|
||||
// Scale to key resolution so font sizing is consistent.
|
||||
dst := image.NewRGBA(image.Rect(0, 0, keySize, keySize))
|
||||
xdraw.BiLinear.Scale(dst, dst.Bounds(), img, img.Bounds(), xdraw.Over, nil)
|
||||
w, h := keySize, keySize
|
||||
|
||||
f := getFont()
|
||||
marginX := int(float64(w) * 0.06)
|
||||
marginY := int(float64(h) * 0.06)
|
||||
availW := w - 2*marginX
|
||||
availH := h - 2*marginY
|
||||
|
||||
// Split on explicit newlines only — no automatic word wrapping.
|
||||
lines := splitLines(text)
|
||||
|
||||
// Find the largest font size where all lines fit.
|
||||
// Cap at h*0.16 (~15pt on 96px key) so text stays label-sized.
|
||||
var bestFace font.Face
|
||||
maxSize := float64(h) * 0.16
|
||||
const minSize = 6.0
|
||||
|
||||
for size := maxSize; size >= minSize; size -= 0.5 {
|
||||
face, err := opentype.NewFace(f, &opentype.FaceOptions{
|
||||
Size: size,
|
||||
DPI: 72,
|
||||
Hinting: font.HintingFull,
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
lineH := face.Metrics().Height.Ceil()
|
||||
fits := lineH*len(lines) <= availH
|
||||
if fits {
|
||||
for _, line := range lines {
|
||||
if font.MeasureString(face, line).Ceil() > availW {
|
||||
fits = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if fits {
|
||||
bestFace = face
|
||||
break
|
||||
}
|
||||
}
|
||||
if bestFace == nil {
|
||||
bestFace, _ = opentype.NewFace(f, &opentype.FaceOptions{
|
||||
Size: minSize, DPI: 72, Hinting: font.HintingFull,
|
||||
})
|
||||
}
|
||||
|
||||
metrics := bestFace.Metrics()
|
||||
lineH := metrics.Height.Ceil()
|
||||
totalH := lineH * len(lines)
|
||||
// Bottom-align so the icon stays visible above.
|
||||
startY := h - marginY - totalH + metrics.Ascent.Ceil()
|
||||
|
||||
txtColor := parseTextColor(textColorStr)
|
||||
shadow := contrastColor(txtColor)
|
||||
|
||||
// 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},
|
||||
}
|
||||
|
||||
for i, line := range lines {
|
||||
adv := font.MeasureString(bestFace, line)
|
||||
x := (w - adv.Ceil()) / 2
|
||||
y := startY + i*lineH
|
||||
|
||||
// Draw outline.
|
||||
for _, off := range offsets {
|
||||
d := &font.Drawer{
|
||||
Dst: dst,
|
||||
Src: image.NewUniform(shadow),
|
||||
Face: bestFace,
|
||||
Dot: fixed.P(x+off.X, y+off.Y),
|
||||
}
|
||||
d.DrawString(line)
|
||||
}
|
||||
// Draw text.
|
||||
d := &font.Drawer{
|
||||
Dst: dst,
|
||||
Src: image.NewUniform(txtColor),
|
||||
Face: bestFace,
|
||||
Dot: fixed.P(x, y),
|
||||
}
|
||||
d.DrawString(line)
|
||||
}
|
||||
|
||||
return dst
|
||||
}
|
||||
|
||||
func loadImage(path string) (image.Image, error) {
|
||||
if strings.ToLower(filepath.Ext(path)) == ".svg" {
|
||||
return loadSVG(path)
|
||||
@@ -569,7 +1070,7 @@ func loadImage(path string) (image.Image, error) {
|
||||
}
|
||||
|
||||
func loadSVG(path string) (image.Image, error) {
|
||||
icon, err := oksvg.ReadIcon(path, oksvg.StrictErrorMode)
|
||||
icon, err := oksvg.ReadIcon(path, oksvg.IgnoreErrorMode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -725,18 +1226,18 @@ 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
|
||||
}
|
||||
|
||||
func defaultConfig(iconsDir string) string {
|
||||
return `# streamdeck-go configuration
|
||||
# https://github.com/WoodardDigital/streamdeck-go
|
||||
# https://git.i0t.app/lwoodard/streamdeck-go
|
||||
|
||||
icons_dir: ` + iconsDir + `
|
||||
brightness: 70
|
||||
@@ -761,4 +1262,3 @@ device:
|
||||
keys: {}
|
||||
`
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
2
go.mod
2
go.mod
@@ -1,4 +1,4 @@
|
||||
module github.com/WoodardDigital/streamdeck-go
|
||||
module git.i0t.app/lwoodard/streamdeck-go
|
||||
|
||||
go 1.25.0
|
||||
|
||||
|
||||
31
install.sh
31
install.sh
@@ -243,9 +243,9 @@ else
|
||||
install -m 755 /tmp/obs-cmd /usr/local/bin/obs-cmd
|
||||
rm -f /tmp/obs-cmd
|
||||
else
|
||||
# Linux — x86_64 musl static binary
|
||||
# Linux — x86_64 static binary
|
||||
step "Downloading obs-cmd v${OBS_CMD_VERSION}..."
|
||||
curl -sL "https://github.com/grigio/obs-cmd/releases/download/v${OBS_CMD_VERSION}/obs-cmd-v${OBS_CMD_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
|
||||
curl -sL "https://github.com/grigio/obs-cmd/releases/download/v${OBS_CMD_VERSION}/obs-cmd-x64-linux.tar.gz" \
|
||||
| tar xz -C /tmp
|
||||
sudo install -m 755 /tmp/obs-cmd /usr/local/bin/obs-cmd
|
||||
rm -f /tmp/obs-cmd
|
||||
@@ -449,6 +449,33 @@ else
|
||||
ok "Service enabled and started"
|
||||
fi
|
||||
|
||||
# ── 9. Watchdog ────────────────────────────────────────────────────────────────
|
||||
nl
|
||||
step "Installing watchdog (USB unplug/replug recovery)..."
|
||||
if $IS_MAC; then
|
||||
WATCHDOG_BIN="${BIN_DIR}/streamdeck-go-watchdog"
|
||||
WATCHDOG_PLIST_LABEL="com.woodarddigital.streamdeck-go-watchdog"
|
||||
WATCHDOG_PLIST="${LAUNCHAGENTS_DIR}/${WATCHDOG_PLIST_LABEL}.plist"
|
||||
WATCHDOG_LOG="${HOME}/Library/Logs/streamdeck-go-watchdog.log"
|
||||
|
||||
install -m 755 systemd/streamdeck-go-watchdog.sh "${WATCHDOG_BIN}"
|
||||
sed \
|
||||
-e "s|STREAMDECK_WATCHDOG_PATH|${WATCHDOG_BIN}|g" \
|
||||
-e "s|STREAMDECK_WATCHDOG_LOG_PATH|${WATCHDOG_LOG}|g" \
|
||||
launchd/com.woodarddigital.streamdeck-go-watchdog.plist \
|
||||
> "${WATCHDOG_PLIST}"
|
||||
launchctl bootout "gui/$(id -u)/${WATCHDOG_PLIST_LABEL}" 2>/dev/null || true
|
||||
launchctl bootstrap "gui/$(id -u)" "${WATCHDOG_PLIST}"
|
||||
ok "Watchdog loaded — fires every 30s"
|
||||
else
|
||||
install_file 755 systemd/streamdeck-go-watchdog.sh "${BIN_DIR}/streamdeck-go-watchdog"
|
||||
install_file 644 systemd/streamdeck-go-watchdog.service "${SYSTEMD_USER}/streamdeck-go-watchdog.service"
|
||||
install_file 644 systemd/streamdeck-go-watchdog.timer "${SYSTEMD_USER}/streamdeck-go-watchdog.timer"
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now streamdeck-go-watchdog.timer
|
||||
ok "Watchdog timer enabled — fires every 30s"
|
||||
fi
|
||||
|
||||
# ── Done ───────────────────────────────────────────────────────────────────────
|
||||
nl
|
||||
echo -e " ${DIM}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
|
||||
@@ -22,8 +22,16 @@ type PollConfig struct {
|
||||
|
||||
// KeyConfig defines what a single Stream Deck key does.
|
||||
type KeyConfig struct {
|
||||
Icon string `yaml:"icon"` // filename relative to icons_dir (regular keys)
|
||||
Command string `yaml:"command"` // shell command to run on press
|
||||
Icon string `yaml:"icon"` // filename relative to icons_dir (regular keys)
|
||||
Text string `yaml:"text"` // text overlay on the key (auto-sized, supports newlines)
|
||||
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
|
||||
@@ -34,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.
|
||||
@@ -57,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
402
internal/config/edit.go
Normal 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'
|
||||
}
|
||||
126
internal/config/edit_test.go
Normal file
126
internal/config/edit_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
82
internal/config/folders_test.go
Normal file
82
internal/config/folders_test.go
Normal 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])
|
||||
}
|
||||
}
|
||||
@@ -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,75 +57,216 @@ modules:
|
||||
curl -s -X POST https://slack.com/api/dnd.endSnooze \
|
||||
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}"
|
||||
|
||||
# OBS Studio — media player and streaming control via obs-cmd
|
||||
# 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)
|
||||
# macOS: brew install grigio/obs-cmd/obs-cmd
|
||||
# Linux: cargo install obs-cmd (or download binary from GitHub releases)
|
||||
# macOS (Intel): brew install grigio/obs-cmd/obs-cmd → /usr/local/bin/obs-cmd
|
||||
# macOS (Apple Silicon): brew install grigio/obs-cmd/obs-cmd → /opt/homebrew/bin/obs-cmd
|
||||
# Linux: cargo install obs-cmd → ~/.cargo/bin/obs-cmd
|
||||
# Linux (binary): download from GitHub releases, e.g. /usr/local/bin/obs-cmd
|
||||
#
|
||||
# OBS WebSocket must be enabled: Tools → WebSocket Server Settings (on by default in OBS 28+)
|
||||
#
|
||||
# Add to ~/.config/streamdeck-go/.env:
|
||||
# OBS_WEBSOCKET_PASSWORD=your-password
|
||||
# OBS_HOST=localhost (optional, default: localhost)
|
||||
# OBS_PORT=4455 (optional, default: 4455)
|
||||
# OBS_HOST=localhost (optional, default: localhost)
|
||||
# OBS_PORT=4455 (optional, default: 4455)
|
||||
# OBS_CMD=/opt/homebrew/bin/obs-cmd (optional, default: /usr/local/bin/obs-cmd)
|
||||
# set this on Apple Silicon or Linux cargo installs
|
||||
obs:
|
||||
play:
|
||||
params:
|
||||
source: "Media Source"
|
||||
exec: |
|
||||
/usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} media-input play "{{.source}}"
|
||||
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} media-input play "{{.source}}"
|
||||
|
||||
pause:
|
||||
params:
|
||||
source: "Media Source"
|
||||
exec: |
|
||||
/usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} media-input pause "{{.source}}"
|
||||
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} media-input pause "{{.source}}"
|
||||
|
||||
stop:
|
||||
params:
|
||||
source: "Media Source"
|
||||
exec: |
|
||||
/usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} media-input stop "{{.source}}"
|
||||
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} media-input stop "{{.source}}"
|
||||
|
||||
restart:
|
||||
params:
|
||||
source: "Media Source"
|
||||
exec: |
|
||||
/usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} media-input restart "{{.source}}"
|
||||
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} media-input restart "{{.source}}"
|
||||
|
||||
toggle_record:
|
||||
exec: |
|
||||
/usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} recording toggle
|
||||
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} recording toggle
|
||||
|
||||
toggle_record_pause:
|
||||
exec: |
|
||||
/usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} recording toggle-pause
|
||||
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} recording toggle-pause
|
||||
|
||||
is_recording_paused:
|
||||
exec: |
|
||||
/usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} recording status
|
||||
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} recording status
|
||||
|
||||
toggle_stream:
|
||||
exec: |
|
||||
/usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} streaming toggle
|
||||
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} streaming toggle
|
||||
|
||||
scene_switch:
|
||||
params:
|
||||
scene: "Scene 1"
|
||||
exec: |
|
||||
/usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene switch "{{.scene}}"
|
||||
{{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}}"
|
||||
|
||||
toggle_mute:
|
||||
params:
|
||||
source: "Mic/Aux"
|
||||
exec: |
|
||||
/usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} input toggle-mute "{{.source}}"
|
||||
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} input toggle-mute "{{.source}}"
|
||||
|
||||
is_recording:
|
||||
exec: |
|
||||
/usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} recording status
|
||||
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} recording status
|
||||
|
||||
is_streaming:
|
||||
exec: |
|
||||
/usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} streaming status
|
||||
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} streaming status
|
||||
|
||||
# Change the active transition type. Common values: Fade, Cut, Slide, Swipe, Stinger.
|
||||
# Must match a transition that exists in your OBS profile (see Scene Transitions panel).
|
||||
set_transition:
|
||||
params:
|
||||
transition: "Fade"
|
||||
exec: |
|
||||
{{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}}"
|
||||
|
||||
# Set the active transition duration in milliseconds.
|
||||
set_transition_duration:
|
||||
params:
|
||||
duration: "300"
|
||||
exec: |
|
||||
{{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}}
|
||||
|
||||
# Toggle Studio Mode (preview + program with a Transition button).
|
||||
studio_mode_toggle:
|
||||
exec: |
|
||||
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene studio-mode-toggle
|
||||
|
||||
# In Studio Mode, set the scene shown on the preview side.
|
||||
preview_scene:
|
||||
params:
|
||||
scene: "Scene 1"
|
||||
exec: |
|
||||
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene preview-set "{{.scene}}"
|
||||
|
||||
# The Studio Mode "Transition" button — push preview to program using the current transition.
|
||||
take:
|
||||
exec: |
|
||||
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene studio-mode-transition
|
||||
|
||||
# Fire the current transition without changing scenes (e.g. to re-trigger a stinger).
|
||||
transition_trigger:
|
||||
exec: |
|
||||
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene transition-trigger
|
||||
|
||||
# Switch scene using a specific transition, overriding OBS's current transition for this cut.
|
||||
# Chains set_transition + transition-duration + scene switch in one key press.
|
||||
scene_switch_with_transition:
|
||||
params:
|
||||
scene: "Scene 1"
|
||||
transition: "Fade"
|
||||
duration: "300"
|
||||
exec: |
|
||||
{{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:]]'
|
||||
|
||||
@@ -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 (
|
||||
@@ -87,6 +116,12 @@ func (sd *StreamDeck) Close() error {
|
||||
// KeyCount returns the number of keys on this device.
|
||||
func (sd *StreamDeck) KeyCount() int { return sd.model.KeyCount }
|
||||
|
||||
// ImageWidth returns the pixel width of key images for this device.
|
||||
func (sd *StreamDeck) ImageWidth() int { return sd.model.ImageWidth }
|
||||
|
||||
// ImageHeight returns the pixel height of key images for this device.
|
||||
func (sd *StreamDeck) ImageHeight() int { return sd.model.ImageHeight }
|
||||
|
||||
// Reset clears all key images and returns the device to its default state.
|
||||
func (sd *StreamDeck) Reset() error {
|
||||
report := make([]byte, 32)
|
||||
@@ -161,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
|
||||
|
||||
36
launchd/com.woodarddigital.streamdeck-go-watchdog.plist
Normal file
36
launchd/com.woodarddigital.streamdeck-go-watchdog.plist
Normal file
@@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<!--
|
||||
LaunchAgent for streamdeck-go-watchdog.
|
||||
Installed to: ~/Library/LaunchAgents/com.woodarddigital.streamdeck-go-watchdog.plist
|
||||
|
||||
Fires every 30 seconds. Detects USB unplug/replug events that the daemon's
|
||||
in-process reconnect missed and restarts the streamdeck-go agent.
|
||||
|
||||
The watchdog binary path below is set by the install target at install time.
|
||||
-->
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.woodarddigital.streamdeck-go-watchdog</string>
|
||||
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>STREAMDECK_WATCHDOG_PATH</string>
|
||||
</array>
|
||||
|
||||
<!-- Fire every 30 seconds. -->
|
||||
<key>StartInterval</key>
|
||||
<integer>30</integer>
|
||||
|
||||
<!-- Don't run at load — let the first interval fire naturally so the
|
||||
device address has a chance to settle after login. -->
|
||||
<key>RunAtLoad</key>
|
||||
<false/>
|
||||
|
||||
<key>StandardOutPath</key>
|
||||
<string>STREAMDECK_WATCHDOG_LOG_PATH</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>STREAMDECK_WATCHDOG_LOG_PATH</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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` |
|
||||
|
||||
@@ -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,75 +57,216 @@ modules:
|
||||
curl -s -X POST https://slack.com/api/dnd.endSnooze \
|
||||
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}"
|
||||
|
||||
# OBS Studio — media player and streaming control via obs-cmd
|
||||
# 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)
|
||||
# macOS: brew install grigio/obs-cmd/obs-cmd
|
||||
# Linux: cargo install obs-cmd (or download binary from GitHub releases)
|
||||
# macOS (Intel): brew install grigio/obs-cmd/obs-cmd → /usr/local/bin/obs-cmd
|
||||
# macOS (Apple Silicon): brew install grigio/obs-cmd/obs-cmd → /opt/homebrew/bin/obs-cmd
|
||||
# Linux: cargo install obs-cmd → ~/.cargo/bin/obs-cmd
|
||||
# Linux (binary): download from GitHub releases, e.g. /usr/local/bin/obs-cmd
|
||||
#
|
||||
# OBS WebSocket must be enabled: Tools → WebSocket Server Settings (on by default in OBS 28+)
|
||||
#
|
||||
# Add to ~/.config/streamdeck-go/.env:
|
||||
# OBS_WEBSOCKET_PASSWORD=your-password
|
||||
# OBS_HOST=localhost (optional, default: localhost)
|
||||
# OBS_PORT=4455 (optional, default: 4455)
|
||||
# OBS_HOST=localhost (optional, default: localhost)
|
||||
# OBS_PORT=4455 (optional, default: 4455)
|
||||
# OBS_CMD=/opt/homebrew/bin/obs-cmd (optional, default: /usr/local/bin/obs-cmd)
|
||||
# set this on Apple Silicon or Linux cargo installs
|
||||
obs:
|
||||
play:
|
||||
params:
|
||||
source: "Media Source"
|
||||
exec: |
|
||||
/usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} media-input play "{{.source}}"
|
||||
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} media-input play "{{.source}}"
|
||||
|
||||
pause:
|
||||
params:
|
||||
source: "Media Source"
|
||||
exec: |
|
||||
/usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} media-input pause "{{.source}}"
|
||||
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} media-input pause "{{.source}}"
|
||||
|
||||
stop:
|
||||
params:
|
||||
source: "Media Source"
|
||||
exec: |
|
||||
/usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} media-input stop "{{.source}}"
|
||||
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} media-input stop "{{.source}}"
|
||||
|
||||
restart:
|
||||
params:
|
||||
source: "Media Source"
|
||||
exec: |
|
||||
/usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} media-input restart "{{.source}}"
|
||||
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} media-input restart "{{.source}}"
|
||||
|
||||
toggle_record:
|
||||
exec: |
|
||||
/usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} recording toggle
|
||||
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} recording toggle
|
||||
|
||||
toggle_record_pause:
|
||||
exec: |
|
||||
/usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} recording toggle-pause
|
||||
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} recording toggle-pause
|
||||
|
||||
is_recording_paused:
|
||||
exec: |
|
||||
/usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} recording status
|
||||
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} recording status
|
||||
|
||||
toggle_stream:
|
||||
exec: |
|
||||
/usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} streaming toggle
|
||||
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} streaming toggle
|
||||
|
||||
scene_switch:
|
||||
params:
|
||||
scene: "Scene 1"
|
||||
exec: |
|
||||
/usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene switch "{{.scene}}"
|
||||
{{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}}"
|
||||
|
||||
toggle_mute:
|
||||
params:
|
||||
source: "Mic/Aux"
|
||||
exec: |
|
||||
/usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} input toggle-mute "{{.source}}"
|
||||
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} input toggle-mute "{{.source}}"
|
||||
|
||||
is_recording:
|
||||
exec: |
|
||||
/usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} recording status
|
||||
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} recording status
|
||||
|
||||
is_streaming:
|
||||
exec: |
|
||||
/usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} streaming status
|
||||
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} streaming status
|
||||
|
||||
# Change the active transition type. Common values: Fade, Cut, Slide, Swipe, Stinger.
|
||||
# Must match a transition that exists in your OBS profile (see Scene Transitions panel).
|
||||
set_transition:
|
||||
params:
|
||||
transition: "Fade"
|
||||
exec: |
|
||||
{{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}}"
|
||||
|
||||
# Set the active transition duration in milliseconds.
|
||||
set_transition_duration:
|
||||
params:
|
||||
duration: "300"
|
||||
exec: |
|
||||
{{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}}
|
||||
|
||||
# Toggle Studio Mode (preview + program with a Transition button).
|
||||
studio_mode_toggle:
|
||||
exec: |
|
||||
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene studio-mode-toggle
|
||||
|
||||
# In Studio Mode, set the scene shown on the preview side.
|
||||
preview_scene:
|
||||
params:
|
||||
scene: "Scene 1"
|
||||
exec: |
|
||||
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene preview-set "{{.scene}}"
|
||||
|
||||
# The Studio Mode "Transition" button — push preview to program using the current transition.
|
||||
take:
|
||||
exec: |
|
||||
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene studio-mode-transition
|
||||
|
||||
# Fire the current transition without changing scenes (e.g. to re-trigger a stinger).
|
||||
transition_trigger:
|
||||
exec: |
|
||||
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene transition-trigger
|
||||
|
||||
# Switch scene using a specific transition, overriding OBS's current transition for this cut.
|
||||
# Chains set_transition + transition-duration + scene switch in one key press.
|
||||
scene_switch_with_transition:
|
||||
params:
|
||||
scene: "Scene 1"
|
||||
transition: "Fade"
|
||||
duration: "300"
|
||||
exec: |
|
||||
{{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:]]'
|
||||
|
||||
407
omarchy-plugin/KeyEditor.qml
Normal file
407
omarchy-plugin/KeyEditor.qml
Normal 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
200
omarchy-plugin/KeyGrid.qml
Normal 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
21
omarchy-plugin/LICENSE
Normal 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
311
omarchy-plugin/Model.js
Normal 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
501
omarchy-plugin/Panel.qml
Normal 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
169
omarchy-plugin/README.md
Normal 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
311
omarchy-plugin/Service.qml
Normal 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()
|
||||
}
|
||||
52
omarchy-plugin/StreamDeckIcon.qml
Normal file
52
omarchy-plugin/StreamDeckIcon.qml
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
59
omarchy-plugin/manifest.json
Normal file
59
omarchy-plugin/manifest.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
BIN
streamdeck-init
BIN
streamdeck-init
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
[Unit]
|
||||
Description=Stream Deck privileged command helper
|
||||
Documentation=https://github.com/WoodardDigital/streamdeck-go
|
||||
Documentation=https://git.i0t.app/WoodardDigital/streamdeck-go
|
||||
# Start before the user session so the socket is ready when streamdeck-go starts.
|
||||
Before=graphical.target
|
||||
|
||||
|
||||
8
systemd/streamdeck-go-watchdog.service
Normal file
8
systemd/streamdeck-go-watchdog.service
Normal file
@@ -0,0 +1,8 @@
|
||||
[Unit]
|
||||
Description=Stream Deck watchdog (one-shot)
|
||||
Documentation=https://git.i0t.app/WoodardDigital/streamdeck-go
|
||||
After=streamdeck-go.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=%h/.local/bin/streamdeck-go-watchdog
|
||||
180
systemd/streamdeck-go-watchdog.sh
Normal file
180
systemd/streamdeck-go-watchdog.sh
Normal file
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env bash
|
||||
# streamdeck-go watchdog — runs every 30s via systemd timer (Linux) or launchd
|
||||
# StartInterval (macOS).
|
||||
#
|
||||
# Why this exists: when the Stream Deck is unplugged and replugged, the daemon's
|
||||
# in-process reconnect logic does not always notice. On Linux, hidraw can keep
|
||||
# returning read timeouts on the now-stale fd instead of surfacing an error, so
|
||||
# the "3 consecutive errors → reconnect" path never triggers, and the service
|
||||
# manager still reports the service as active even though the device is
|
||||
# unreachable.
|
||||
#
|
||||
# Strategy: track the device's transient USB address (Linux: bus:device,
|
||||
# macOS: Location ID). When it changes (unplug/replug) or the service is
|
||||
# inactive while a device is present, restart the service.
|
||||
set -euo pipefail
|
||||
|
||||
# Stream Deck product IDs we support (see internal/device/streamdeck.go).
|
||||
PIDS_RE="00ba|006c|006d"
|
||||
|
||||
OS="$(uname -s)"
|
||||
|
||||
case "$OS" in
|
||||
Linux)
|
||||
STATE_DIR="${XDG_RUNTIME_DIR:-/tmp}"
|
||||
;;
|
||||
Darwin)
|
||||
# No XDG_RUNTIME_DIR on macOS; use the user-private temp dir.
|
||||
STATE_DIR="${TMPDIR:-/tmp}"
|
||||
;;
|
||||
*)
|
||||
echo "watchdog: unsupported OS: $OS" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
STATE_FILE="$STATE_DIR/streamdeck-go-watchdog.state"
|
||||
|
||||
# Print a transient identifier for the first matching Stream Deck on the USB
|
||||
# bus, or empty if none is present. The identifier must change across
|
||||
# unplug/replug so we can detect it.
|
||||
current_addr() {
|
||||
case "$OS" in
|
||||
Linux)
|
||||
# "Bus 003 Device 052: ID 0fd9:00ba ..." → "003:052"
|
||||
lsusb 2>/dev/null | awk -v pids="$PIDS_RE" '
|
||||
$0 ~ ("ID 0fd9:(" pids ")") {
|
||||
gsub(":", "", $4)
|
||||
print $2 ":" $4
|
||||
exit
|
||||
}
|
||||
'
|
||||
;;
|
||||
Darwin)
|
||||
# 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
|
||||
}
|
||||
}
|
||||
'
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Is the streamdeck-go service currently active?
|
||||
service_active() {
|
||||
case "$OS" in
|
||||
Linux)
|
||||
systemctl --user is-active --quiet streamdeck-go.service
|
||||
;;
|
||||
Darwin)
|
||||
# launchctl list prints "PID Status Label". A PID of "-" means
|
||||
# the agent is loaded but not running.
|
||||
local line
|
||||
line="$(launchctl list 2>/dev/null | awk '$3 == "com.woodarddigital.streamdeck-go" { print $1 }')"
|
||||
[[ -n "$line" && "$line" != "-" ]]
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
restart_service() {
|
||||
case "$OS" in
|
||||
Linux)
|
||||
systemctl --user restart streamdeck-go.service
|
||||
;;
|
||||
Darwin)
|
||||
# kickstart -k stops and restarts; works whether or not it's running.
|
||||
launchctl kickstart -k "gui/$(id -u)/com.woodarddigital.streamdeck-go"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
prev=""
|
||||
[[ -f "$STATE_FILE" ]] && prev="$(cat "$STATE_FILE" 2>/dev/null || true)"
|
||||
|
||||
curr="$(current_addr)"
|
||||
|
||||
# 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
|
||||
# First observation (or state file was wiped). Only restart if the service
|
||||
# is also down — if it's already running, assume it's healthy and just
|
||||
# record the baseline.
|
||||
if ! service_active; then
|
||||
reason="device present at $curr but service is not active"
|
||||
fi
|
||||
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
|
||||
echo "watchdog: $reason — restarting streamdeck-go"
|
||||
restart_service
|
||||
fi
|
||||
12
systemd/streamdeck-go-watchdog.timer
Normal file
12
systemd/streamdeck-go-watchdog.timer
Normal file
@@ -0,0 +1,12 @@
|
||||
[Unit]
|
||||
Description=Stream Deck watchdog timer (every 30s)
|
||||
Documentation=https://git.i0t.app/WoodardDigital/streamdeck-go
|
||||
|
||||
[Timer]
|
||||
OnBootSec=30s
|
||||
OnUnitActiveSec=30s
|
||||
AccuracySec=5s
|
||||
Unit=streamdeck-go-watchdog.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -1,6 +1,6 @@
|
||||
[Unit]
|
||||
Description=Stream Deck controller
|
||||
Documentation=https://github.com/WoodardDigital/streamdeck-go
|
||||
Documentation=https://git.i0t.app/lwoodard/streamdeck-go
|
||||
After=graphical-session.target
|
||||
PartOf=graphical-session.target
|
||||
|
||||
|
||||
Reference in New Issue
Block a user