From 6d0748e4c570581a12121e0df57d5dc023a60836 Mon Sep 17 00:00:00 2001 From: Levi Woodard Date: Thu, 8 Oct 2026 14:02:29 -0600 Subject: [PATCH] Add camera module: drive UVC webcam controls via cameractrls New `camera` module in modules.example.yaml (and the embedded copy) with four functions: `set` (any control=value list, incl. preset/colour buttons), `toggle` (flip between two values), `adjust` (step a numeric control by a delta, clamped to the min/max parsed from `cameractrls -l`, with optional `extra` controls applied first), and `is` (poll helper, exit 0 on match). Device comes from CAMERA_DEVICE in .env (default /dev/video0) or a per-key `device` param. cameractrls exits 0 for unknown controls, so polls must use `is`; stderr is discarded to drop Python deprecation warnings. README: Camera module section with setup, function table, a folder example, and the oksvg viewBox-offset centring gotcha for SVG icons. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017iodiNMYL9iuT7f6WCe5iv --- README.md | 78 +++++++++++++++++++++++ internal/defaults/modules.example.yaml | 88 ++++++++++++++++++++++++++ modules.example.yaml | 64 +++++++++++++++++++ 3 files changed, 230 insertions(+) diff --git a/README.md b/README.md index 4d745b3..fb1e1ec 100644 --- a/README.md +++ b/README.md @@ -862,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. +#### 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 ``. + #### Using modules in config.yaml Reference a module function instead of writing inline commands: diff --git a/internal/defaults/modules.example.yaml b/internal/defaults/modules.example.yaml index f8749de..0cfe8ff 100644 --- a/internal/defaults/modules.example.yaml +++ b/internal/defaults/modules.example.yaml @@ -57,6 +57,30 @@ modules: curl -s -X POST https://slack.com/api/dnd.endSnooze \ -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 # # 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-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:]]' diff --git a/modules.example.yaml b/modules.example.yaml index 14859d2..0cfe8ff 100644 --- a/modules.example.yaml +++ b/modules.example.yaml @@ -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-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:]]'