Compare commits
3 Commits
2aa4c65e42
...
Omarchy-Pl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d0748e4c5 | ||
|
|
4158165410 | ||
|
|
c143db6b68 |
117
README.md
117
README.md
@@ -352,6 +352,45 @@ keys:
|
|||||||
command: ""
|
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
|
### 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.
|
A key can poll any shell command on an interval and show one of two icons based on the result. Pressing the button runs `command` as usual, and the icon re-checks ~400 ms later so it reflects the new state immediately.
|
||||||
@@ -823,6 +862,84 @@ keys:
|
|||||||
|
|
||||||
**Note — absolute paths in modules:** The example templates call `/usr/local/bin/obs-cmd` rather than just `obs-cmd`. This is because launchd (macOS) and systemd (Linux) give the service a minimal `PATH` that doesn't include `/usr/local/bin` or Homebrew. Use the absolute path returned by `which obs-cmd` in your own module templates, or set `PATH` in the launchd plist / systemd unit.
|
**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
|
#### Using modules in config.yaml
|
||||||
|
|
||||||
Reference a module function instead of writing inline commands:
|
Reference a module function instead of writing inline commands:
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ key set flags (only the flags you pass are written):
|
|||||||
-icon NAME -icon-true NAME -icon-false NAME
|
-icon NAME -icon-true NAME -icon-false NAME
|
||||||
-text STR -text-color STR -text-command STR -refresh DUR
|
-text STR -text-color STR -text-command STR -refresh DUR
|
||||||
-command STR -module NAME -function NAME
|
-command STR -module NAME -function NAME
|
||||||
|
-folder NAME (pressing the key opens that folder)
|
||||||
-param K=V (repeatable)
|
-param K=V (repeatable)
|
||||||
-poll-command STR -poll-match STR -poll-interval DUR
|
-poll-command STR -poll-match STR -poll-interval DUR
|
||||||
-unset FIELD (repeatable — removes a field)
|
-unset FIELD (repeatable — removes a field)
|
||||||
@@ -161,6 +162,7 @@ func keySet(cfgPath string, index int, args []string) error {
|
|||||||
"text-command": fs.String("text-command", "", "command whose stdout becomes the overlay text"),
|
"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"),
|
"refresh": fs.String("refresh", "", "how often to re-run text-command, e.g. 1s"),
|
||||||
"command": fs.String("command", "", "shell command to run on press"),
|
"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"),
|
"module": fs.String("module", "", "module name from modules.yaml"),
|
||||||
"function": fs.String("function", "", "function name within the module"),
|
"function": fs.String("function", "", "function name within the module"),
|
||||||
"poll-command": fs.String("poll-command", "", "command used to poll toggle state"),
|
"poll-command": fs.String("poll-command", "", "command used to poll toggle state"),
|
||||||
@@ -171,6 +173,7 @@ func keySet(cfgPath string, index int, args []string) error {
|
|||||||
"icon": "icon", "icon-true": "icon_true", "icon-false": "icon_false",
|
"icon": "icon", "icon-true": "icon_true", "icon-false": "icon_false",
|
||||||
"text": "text", "text-color": "text_color", "text-command": "text_command",
|
"text": "text", "text-color": "text_color", "text-command": "text_command",
|
||||||
"refresh": "refresh", "command": "command", "module": "module", "function": "function",
|
"refresh": "refresh", "command": "command", "module": "module", "function": "function",
|
||||||
|
"folder": "folder",
|
||||||
}
|
}
|
||||||
|
|
||||||
var params stringList
|
var params stringList
|
||||||
@@ -300,6 +303,10 @@ func keyPress(cfgPath string, index int) error {
|
|||||||
return fmt.Errorf("key %d is not configured", index)
|
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
|
command := key.Command
|
||||||
if key.Module != "" && key.Function != "" {
|
if key.Module != "" && key.Function != "" {
|
||||||
reg, err := modules.LoadRegistry(config.ModulesPath(cfgPath))
|
reg, err := modules.LoadRegistry(config.ModulesPath(cfgPath))
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ type Status struct {
|
|||||||
Daemon DaemonStatus `json:"daemon"`
|
Daemon DaemonStatus `json:"daemon"`
|
||||||
Device DeviceStatus `json:"device"`
|
Device DeviceStatus `json:"device"`
|
||||||
Keys []KeyStatus `json:"keys"`
|
Keys []KeyStatus `json:"keys"`
|
||||||
|
Folders []FolderStatus `json:"folders"`
|
||||||
Icons []string `json:"icons"`
|
Icons []string `json:"icons"`
|
||||||
Modules []ModuleStatus `json:"modules"`
|
Modules []ModuleStatus `json:"modules"`
|
||||||
Warnings []string `json:"warnings,omitempty"`
|
Warnings []string `json:"warnings,omitempty"`
|
||||||
@@ -62,7 +63,7 @@ type DeviceStatus struct {
|
|||||||
// shape comes from Device.Cols/Rows so a front-end can lay out empty slots.
|
// shape comes from Device.Cols/Rows so a front-end can lay out empty slots.
|
||||||
type KeyStatus struct {
|
type KeyStatus struct {
|
||||||
Index int `json:"index"`
|
Index int `json:"index"`
|
||||||
Kind string `json:"kind"` // empty|static|text|toggle|module
|
Kind string `json:"kind"` // empty|static|text|toggle|module|folder
|
||||||
Label string `json:"label"` // short human label for a grid cell
|
Label string `json:"label"` // short human label for a grid cell
|
||||||
Icon string `json:"icon,omitempty"`
|
Icon string `json:"icon,omitempty"`
|
||||||
IconPath string `json:"iconPath,omitempty"` // absolute, for previews
|
IconPath string `json:"iconPath,omitempty"` // absolute, for previews
|
||||||
@@ -78,6 +79,17 @@ type KeyStatus struct {
|
|||||||
Params map[string]string `json:"params,omitempty"`
|
Params map[string]string `json:"params,omitempty"`
|
||||||
Privileged bool `json:"privileged"`
|
Privileged bool `json:"privileged"`
|
||||||
Poll *PollStatus `json:"poll,omitempty"`
|
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 {
|
type PollStatus struct {
|
||||||
@@ -104,6 +116,7 @@ func cmdStatus(cfgPath string) error {
|
|||||||
ConfigPath: cfgPath,
|
ConfigPath: cfgPath,
|
||||||
ModulesPath: config.ModulesPath(cfgPath),
|
ModulesPath: config.ModulesPath(cfgPath),
|
||||||
Keys: []KeyStatus{},
|
Keys: []KeyStatus{},
|
||||||
|
Folders: []FolderStatus{},
|
||||||
Icons: []string{},
|
Icons: []string{},
|
||||||
Modules: []ModuleStatus{},
|
Modules: []ModuleStatus{},
|
||||||
}
|
}
|
||||||
@@ -131,6 +144,7 @@ func cmdStatus(cfgPath string) error {
|
|||||||
}
|
}
|
||||||
status.Modules = moduleList(reg)
|
status.Modules = moduleList(reg)
|
||||||
status.Keys = keyList(cfg, reg)
|
status.Keys = keyList(cfg, reg)
|
||||||
|
status.Folders = folderList(cfg, reg)
|
||||||
status.Icons = iconList(cfg.IconsDir)
|
status.Icons = iconList(cfg.IconsDir)
|
||||||
|
|
||||||
emit(status)
|
emit(status)
|
||||||
@@ -138,15 +152,41 @@ func cmdStatus(cfgPath string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func keyList(cfg *config.Config, reg *modules.Registry) []KeyStatus {
|
func keyList(cfg *config.Config, reg *modules.Registry) []KeyStatus {
|
||||||
indices := make([]int, 0, len(cfg.Keys))
|
return keyEntries(cfg.Keys, cfg.IconsDir, reg)
|
||||||
for index := range cfg.Keys {
|
}
|
||||||
|
|
||||||
|
// 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)
|
indices = append(indices, index)
|
||||||
}
|
}
|
||||||
sort.Ints(indices)
|
sort.Ints(indices)
|
||||||
|
|
||||||
out := make([]KeyStatus, 0, len(indices))
|
out := make([]KeyStatus, 0, len(indices))
|
||||||
for _, index := range indices {
|
for _, index := range indices {
|
||||||
key := cfg.Keys[index]
|
key := keys[index]
|
||||||
entry := KeyStatus{
|
entry := KeyStatus{
|
||||||
Index: index,
|
Index: index,
|
||||||
Icon: key.Icon,
|
Icon: key.Icon,
|
||||||
@@ -160,10 +200,11 @@ func keyList(cfg *config.Config, reg *modules.Registry) []KeyStatus {
|
|||||||
Module: key.Module,
|
Module: key.Module,
|
||||||
Function: key.Function,
|
Function: key.Function,
|
||||||
Params: key.Params,
|
Params: key.Params,
|
||||||
|
Folder: key.Folder,
|
||||||
}
|
}
|
||||||
|
|
||||||
if icon := firstNonEmpty(key.Icon, key.IconTrue, key.IconFalse); icon != "" {
|
if icon := firstNonEmpty(key.Icon, key.IconTrue, key.IconFalse); icon != "" {
|
||||||
entry.IconPath = resolveIcon(cfg.IconsDir, icon)
|
entry.IconPath = resolveIcon(iconsDir, icon)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The rendered template can inline secrets ({{env "TOKEN"}}), so it never
|
// The rendered template can inline secrets ({{env "TOKEN"}}), so it never
|
||||||
@@ -194,6 +235,8 @@ func keyList(cfg *config.Config, reg *modules.Registry) []KeyStatus {
|
|||||||
|
|
||||||
func keyKind(key config.KeyConfig) string {
|
func keyKind(key config.KeyConfig) string {
|
||||||
switch {
|
switch {
|
||||||
|
case key.Folder != "":
|
||||||
|
return "folder"
|
||||||
case key.Poll != nil:
|
case key.Poll != nil:
|
||||||
return "toggle"
|
return "toggle"
|
||||||
case key.Module != "":
|
case key.Module != "":
|
||||||
@@ -215,6 +258,9 @@ func keyLabel(key config.KeyConfig) string {
|
|||||||
return strings.TrimSpace(line)
|
return strings.TrimSpace(line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if key.Folder != "" {
|
||||||
|
return key.Folder
|
||||||
|
}
|
||||||
if key.Function != "" {
|
if key.Function != "" {
|
||||||
return strings.ReplaceAll(key.Function, "_", " ")
|
return strings.ReplaceAll(key.Function, "_", " ")
|
||||||
}
|
}
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
_ "image/jpeg"
|
_ "image/jpeg"
|
||||||
_ "image/png"
|
_ "image/png"
|
||||||
"log"
|
"log"
|
||||||
|
"math"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -158,6 +159,12 @@ func main() {
|
|||||||
|
|
||||||
// run initialises the deck and handles button presses until ctx is cancelled
|
// 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.
|
// 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) {
|
func run(ctx context.Context, sd *device.StreamDeck, cfg *config.Config, reg *modules.Registry) (deviceDied bool) {
|
||||||
// Recover from any panic inside this goroutine tree.
|
// Recover from any panic inside this goroutine tree.
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -173,141 +180,15 @@ func run(ctx context.Context, sd *device.StreamDeck, cfg *config.Config, reg *mo
|
|||||||
if err := sd.SetBrightness(cfg.Brightness); err != nil {
|
if err := sd.SetBrightness(cfg.Brightness); err != nil {
|
||||||
log.Printf("warn: brightness: %v", err)
|
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.
|
// Navigation stack of folder names; empty means the root page is showing.
|
||||||
var wg sync.WaitGroup
|
var stack []string
|
||||||
|
current := openPage(ctx, sd, cfg, reg, "")
|
||||||
|
defer func() { current.stop() }()
|
||||||
|
|
||||||
// triggers lets button presses immediately re-poll toggle keys.
|
showPage := func(name string) {
|
||||||
triggers := make(map[int]chan struct{})
|
current.stop()
|
||||||
|
current = openPage(ctx, sd, cfg, reg, name)
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optional $(...) sugar → text_command.
|
|
||||||
if keyCfg.TextCommand == "" {
|
|
||||||
if inner, ok := unwrapCmdSubst(keyCfg.Text); ok {
|
|
||||||
keyCfg.TextCommand = inner
|
|
||||||
keyCfg.Text = ""
|
|
||||||
cfg.Keys[keyIdx] = keyCfg
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
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 refreshTextKey %d: %v", idx, r)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
refreshTextKey(ctx, sd, idx, kCfg, cfg.IconsDir, trig)
|
|
||||||
}(keyIdx, keyCfg, trigger)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toggle/status key: managed by a polling goroutine.
|
|
||||||
if keyCfg.Poll != nil {
|
|
||||||
if keyCfg.IconTrue == "" || keyCfg.IconFalse == "" {
|
|
||||||
log.Printf("key %d: toggle key requires icon_on and icon_off", keyIdx)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
trigger := make(chan struct{}, 1)
|
|
||||||
triggers[keyIdx] = trigger
|
|
||||||
wg.Add(1)
|
|
||||||
go func(idx int, kCfg config.KeyConfig, trig chan struct{}) {
|
|
||||||
defer wg.Done()
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
log.Printf("panic in pollKey %d: %v", idx, r)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
pollKey(ctx, sd, idx, kCfg, cfg.IconsDir, trig)
|
|
||||||
}(keyIdx, keyCfg, trigger)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regular key: load icon once.
|
|
||||||
if keyCfg.Icon == "" {
|
|
||||||
// No icon — render text-only key on a black background.
|
|
||||||
if keyCfg.Text != "" {
|
|
||||||
bg := image.NewRGBA(image.Rect(0, 0, sd.ImageWidth(), sd.ImageHeight()))
|
|
||||||
img := overlayText(bg, keyCfg.Text, keyCfg.TextColor, sd.ImageWidth())
|
|
||||||
if err := sd.SetKeyImage(keyIdx, img); err != nil {
|
|
||||||
log.Printf("key %d: set image: %v", keyIdx, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
iconPath := filepath.Join(cfg.IconsDir, keyCfg.Icon)
|
|
||||||
|
|
||||||
if strings.ToLower(filepath.Ext(keyCfg.Icon)) == ".gif" {
|
|
||||||
frames, delays, err := loadGIF(sd, iconPath)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("key %d: load gif %q: %v", keyIdx, keyCfg.Icon, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
wg.Add(1)
|
|
||||||
go func(idx int, f [][]byte, d []time.Duration) {
|
|
||||||
defer wg.Done()
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
log.Printf("panic in animateKey %d: %v", idx, r)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
animateKey(ctx, sd, idx, f, d)
|
|
||||||
}(keyIdx, frames, delays)
|
|
||||||
} else {
|
|
||||||
img, err := loadImage(iconPath)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("key %d: load icon %q: %v", keyIdx, keyCfg.Icon, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if keyCfg.Text != "" {
|
|
||||||
img = overlayText(img, keyCfg.Text, keyCfg.TextColor, sd.ImageWidth())
|
|
||||||
}
|
|
||||||
if err := sd.SetKeyImage(keyIdx, img); err != nil {
|
|
||||||
log.Printf("key %d: set image: %v", keyIdx, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("stream deck ready (%d keys)", sd.KeyCount())
|
log.Printf("stream deck ready (%d keys)", sd.KeyCount())
|
||||||
@@ -318,7 +199,6 @@ func run(ctx context.Context, sd *device.StreamDeck, cfg *config.Config, reg *mo
|
|||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
wg.Wait()
|
|
||||||
return false
|
return false
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
@@ -328,8 +208,6 @@ func run(ctx context.Context, sd *device.StreamDeck, cfg *config.Config, reg *mo
|
|||||||
consecutiveErrors++
|
consecutiveErrors++
|
||||||
if consecutiveErrors >= 3 {
|
if consecutiveErrors >= 3 {
|
||||||
log.Printf("device error (gave up after %d attempts): %v", consecutiveErrors, err)
|
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
|
return true
|
||||||
}
|
}
|
||||||
log.Printf("read error (%d/3): %v", consecutiveErrors, err)
|
log.Printf("read error (%d/3): %v", consecutiveErrors, err)
|
||||||
@@ -343,11 +221,26 @@ func run(ctx context.Context, sd *device.StreamDeck, cfg *config.Config, reg *mo
|
|||||||
}
|
}
|
||||||
|
|
||||||
for i, pressed := range buttons {
|
for i, pressed := range buttons {
|
||||||
if pressed && !prev[i] {
|
if !pressed || prev[i] {
|
||||||
keyCfg, ok := cfg.Keys[i]
|
|
||||||
if !ok || keyCfg.Command == "" {
|
|
||||||
continue
|
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)
|
log.Printf("key %d pressed → %s", i, keyCfg.Command)
|
||||||
go func(cmd string) {
|
go func(cmd string) {
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -357,21 +250,289 @@ func run(ctx context.Context, sd *device.StreamDeck, cfg *config.Config, reg *mo
|
|||||||
}()
|
}()
|
||||||
runCommand(cmd)
|
runCommand(cmd)
|
||||||
}(keyCfg.Command)
|
}(keyCfg.Command)
|
||||||
|
}
|
||||||
|
|
||||||
// For toggle keys, signal the poll goroutine to re-check state
|
// For toggle keys, signal the poll goroutine to re-check state
|
||||||
// shortly after the command runs (gives the system time to update).
|
// shortly after the command runs (gives the system time to update).
|
||||||
if ch, ok := triggers[i]; ok {
|
if ch, ok := current.triggers[i]; ok {
|
||||||
select {
|
select {
|
||||||
case ch <- struct{}{}:
|
case ch <- struct{}{}:
|
||||||
default:
|
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
|
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.
|
// 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).
|
// 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{}) {
|
func pollKey(ctx context.Context, sd *device.StreamDeck, keyIdx int, keyCfg config.KeyConfig, iconsDir string, trigger <-chan struct{}) {
|
||||||
|
|||||||
@@ -106,3 +106,21 @@ keys:
|
|||||||
# 13:
|
# 13:
|
||||||
# text_command: "date +%H:%M:%S"
|
# text_command: "date +%H:%M:%S"
|
||||||
# refresh: 1s
|
# 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
|
||||||
|
|||||||
@@ -42,6 +42,34 @@ type KeyConfig struct {
|
|||||||
Module string `yaml:"module"`
|
Module string `yaml:"module"`
|
||||||
Function string `yaml:"function"`
|
Function string `yaml:"function"`
|
||||||
Params map[string]string `yaml:"params"`
|
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.
|
// DefaultConfigPath returns the default config file path, respecting XDG_CONFIG_HOME.
|
||||||
@@ -65,6 +93,9 @@ type Config struct {
|
|||||||
Brightness int `yaml:"brightness"`
|
Brightness int `yaml:"brightness"`
|
||||||
Device DeviceConfig `yaml:"device"`
|
Device DeviceConfig `yaml:"device"`
|
||||||
Keys map[int]KeyConfig `yaml:"keys"`
|
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).
|
// DeviceConfig allows overriding USB IDs (defaults work for Stream Deck XL v2).
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ var fieldOrder = []string{
|
|||||||
"icon", "icon_true", "icon_false",
|
"icon", "icon_true", "icon_false",
|
||||||
"text", "text_color", "text_command", "refresh",
|
"text", "text_color", "text_command", "refresh",
|
||||||
"module", "function", "params",
|
"module", "function", "params",
|
||||||
"command", "poll",
|
"command", "folder", "poll",
|
||||||
}
|
}
|
||||||
|
|
||||||
func sortedFieldNames(m map[string]any) []string {
|
func sortedFieldNames(m map[string]any) []string {
|
||||||
|
|||||||
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])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,6 +57,30 @@ modules:
|
|||||||
curl -s -X POST https://slack.com/api/dnd.endSnooze \
|
curl -s -X POST https://slack.com/api/dnd.endSnooze \
|
||||||
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}"
|
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}"
|
||||||
|
|
||||||
|
# Pomodoro / focus timer — the `flow` CLI.
|
||||||
|
#
|
||||||
|
# Absolute path required (minimal service PATH). Apple Silicon Homebrew installs
|
||||||
|
# live at /opt/homebrew/bin/flow — set that in ~/.config/streamdeck-go/.env:
|
||||||
|
# FLOW_CMD=/opt/homebrew/bin/flow
|
||||||
|
#
|
||||||
|
# Control verbs (start/pause/resume/toggle/skip/stop/reset/gui) run on key press.
|
||||||
|
# `status` prints one line like `state=running phase=work remaining=24:12` for
|
||||||
|
# icon-swap poll blocks (match: "state=running").
|
||||||
|
#
|
||||||
|
# The LIVE countdown label uses a key's `text_command` field directly (not a
|
||||||
|
# module), e.g. text_command: "/usr/local/bin/flow status --short" which prints
|
||||||
|
# a two-line label like WORK\n24:12 (an actual newline between the lines).
|
||||||
|
flow:
|
||||||
|
start: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} start' }
|
||||||
|
pause: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} pause' }
|
||||||
|
resume: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} resume' }
|
||||||
|
toggle: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} toggle' }
|
||||||
|
skip: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} skip' }
|
||||||
|
stop: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} stop' }
|
||||||
|
reset: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} reset' }
|
||||||
|
gui: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} gui' }
|
||||||
|
status: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} status' }
|
||||||
|
|
||||||
# OBS Studio — media player, streaming, and scene/transition control via obs-cmd
|
# OBS Studio — media player, streaming, and scene/transition control via obs-cmd
|
||||||
#
|
#
|
||||||
# Requires: obs-cmd (https://github.com/grigio/obs-cmd)
|
# Requires: obs-cmd (https://github.com/grigio/obs-cmd)
|
||||||
@@ -182,3 +206,67 @@ modules:
|
|||||||
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene transition-set "{{.transition}}" && \
|
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene transition-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 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}}"
|
{{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 (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
@@ -10,6 +11,7 @@ import (
|
|||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/sstallion/go-hid"
|
"github.com/sstallion/go-hid"
|
||||||
"golang.org/x/image/draw"
|
"golang.org/x/image/draw"
|
||||||
@@ -194,8 +196,13 @@ func (sd *StreamDeck) ClearKey(keyIndex int) error {
|
|||||||
// Returns (nil, nil) on timeout — callers should check context and retry.
|
// Returns (nil, nil) on timeout — callers should check context and retry.
|
||||||
func (sd *StreamDeck) ReadButtons() ([]bool, error) {
|
func (sd *StreamDeck) ReadButtons() ([]bool, error) {
|
||||||
data := make([]byte, readReportSize)
|
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 err != nil {
|
||||||
|
if errors.Is(err, hid.ErrTimeout) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
// Linux hidraw returns errors (not (0,nil)) for non-fatal conditions:
|
// Linux hidraw returns errors (not (0,nil)) for non-fatal conditions:
|
||||||
// timeout waiting for data, or EINTR (signal interrupted).
|
// timeout waiting for data, or EINTR (signal interrupted).
|
||||||
// macOS IOHIDManager usually returns (0, nil) on timeout, but may also
|
// macOS IOHIDManager usually returns (0, nil) on timeout, but may also
|
||||||
|
|||||||
@@ -96,3 +96,4 @@ The biggest architectural difference is privileged commands:
|
|||||||
| Log location | `journalctl --user -u streamdeck-go` | `~/Library/Logs/streamdeck-go.log` |
|
| 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) |
|
| 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) |
|
| 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` |
|
||||||
|
|||||||
@@ -206,3 +206,67 @@ modules:
|
|||||||
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene transition-set "{{.transition}}" && \
|
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene transition-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 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}}"
|
{{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:]]'
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ function formatDuration(seconds) {
|
|||||||
// from streamdeck-ctl, so this list tracks KeyStatus.Kind in status.go.
|
// from streamdeck-ctl, so this list tracks KeyStatus.Kind in status.go.
|
||||||
function kindGlyph(kind) {
|
function kindGlyph(kind) {
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
|
case "folder": return "" // folder
|
||||||
case "toggle": return "" // toggle-switch
|
case "toggle": return "" // toggle-switch
|
||||||
case "module": return "" // puzzle piece
|
case "module": return "" // puzzle piece
|
||||||
case "text": return "" // text
|
case "text": return "" // text
|
||||||
|
|||||||
Reference in New Issue
Block a user