Wraps linkctl so the Insta360 Link 2's AI tracking and gimbal presets are reachable from a keypress and from OBS, instead of a terminal. bin/linkctl-macro resolves the linkctl binary itself, because a desktop shortcut is launched with a far sparser PATH than an interactive shell and would otherwise just fail silently. It defaults to --force on the grounds that a deliberate keypress means "act now" even when nothing holds the camera open, which is precisely the state the Link parks itself in. Results go to a desktop notification, since tracking state and gimbal position are invisible otherwise, and linkctl's documented exit codes are mapped to actionable text. kde/install-shortcuts.sh registers Plasma global shortcuts via .desktop entries plus kglobalshortcutsrc. These work under Wayland, where OBS's own hotkeys only fire while OBS has focus. It writes with the shortcut daemon stopped, because the daemon rewrites that file on exit and would discard the entries. Supports --dry-run, --list and --uninstall. obs/obs-linkctl.lua adds OBS hotkeys, test buttons, and scene-to-action mapping, so switching scenes can set the camera state unattended. Its startup action defaults to loading the home preset, which undoes the gimbal's idle parking when OBS launches. Commands are backgrounded so the OBS UI thread never waits on the camera, and identical commands within 250ms are collapsed to absorb the repeated events studio mode emits. Tested: 22 offline Lua checks over shell quoting, action tokenizing, scene map parsing and command construction; the wrapper exercised against a stub linkctl for every documented exit code; the KDE installer dry-run, install, list and uninstall paths run against stubbed Plasma tools in a throwaway HOME. shellcheck clean. Not yet run against a live camera or a real Plasma session.
85 lines
3.1 KiB
Lua
85 lines
3.1 KiB
Lua
-- Offline tests for the pure helpers in obs/obs-linkctl.lua.
|
|
-- Run: lua tests/test_obs_linkctl.lua
|
|
--
|
|
-- These cover the parts that do not need OBS or a camera: shell quoting,
|
|
-- action tokenizing, the scene-mapping parser and command construction.
|
|
|
|
local m = dofile("obs/obs-linkctl.lua")
|
|
|
|
local failures, checks = 0, 0
|
|
|
|
local function eq(got, want, label)
|
|
checks = checks + 1
|
|
if got ~= want then
|
|
failures = failures + 1
|
|
io.write(string.format("FAIL %s\n got: %s\n want: %s\n",
|
|
label, tostring(got), tostring(want)))
|
|
end
|
|
end
|
|
|
|
local function eqlist(got, want, label)
|
|
eq(table.concat(got, "|"), table.concat(want, "|"), label)
|
|
end
|
|
|
|
-- shquote: the boundary between user input and /bin/sh
|
|
eq(m.shquote("home"), "'home'", "shquote plain")
|
|
eq(m.shquote("my desk"), "'my desk'", "shquote space")
|
|
eq(m.shquote("it's"), [['it'\''s']], "shquote embedded single quote")
|
|
eq(m.shquote("a;rm -rf /"), "'a;rm -rf /'", "shquote neutralises metacharacters")
|
|
eq(m.shquote("$(whoami)"), "'$(whoami)'", "shquote neutralises substitution")
|
|
|
|
-- tokenize: splitting an action string into linkctl arguments
|
|
eqlist(m.tokenize("tracking on"), { "tracking", "on" }, "tokenize simple")
|
|
eqlist(m.tokenize(" center "), { "center" }, "tokenize trims")
|
|
eqlist(m.tokenize("preset load home"), { "preset", "load", "home" }, "tokenize three")
|
|
eqlist(m.tokenize('preset load "my desk"'),
|
|
{ "preset", "load", "my desk" }, "tokenize quoted")
|
|
eqlist(m.tokenize('preset load "unterminated'),
|
|
{ "preset", "load", "unterminated" }, "tokenize unterminated quote")
|
|
eqlist(m.tokenize(""), {}, "tokenize empty")
|
|
|
|
-- parse_scene_map
|
|
local map = m.parse_scene_map([[
|
|
Talking Head = tracking on
|
|
Overhead = preset load overhead
|
|
|
|
# a comment
|
|
Slides = tracking off
|
|
Blank Action =
|
|
]])
|
|
eq(map["Talking Head"], "tracking on", "map simple")
|
|
eq(map["Overhead"], "preset load overhead", "map multiword action")
|
|
eq(map["Slides"], "tracking off", "map after comment and blank")
|
|
eq(map["# a comment"], nil, "map ignores comments")
|
|
eq(map["Blank Action"], nil, "map ignores empty action")
|
|
|
|
local spaced = m.parse_scene_map(" Scene With Spaces = tracking toggle ")
|
|
eq(spaced["Scene With Spaces"], "tracking toggle", "map trims around separator")
|
|
|
|
-- An action containing '=' must keep it; only the first separator splits.
|
|
local eqaction = m.parse_scene_map("S = brightness 1=2")
|
|
eq(eqaction["S"], "brightness 1=2", "map splits on first separator only")
|
|
|
|
-- build_cmd honours configuration
|
|
m.cfg.linkctl = "linkctl"
|
|
m.cfg.device = ""
|
|
m.cfg.force = true
|
|
eq(m.build_cmd({ "tracking", "on" }),
|
|
"'linkctl' --force 'tracking' 'on'", "build_cmd with force")
|
|
|
|
m.cfg.force = false
|
|
eq(m.build_cmd({ "center" }), "'linkctl' 'center'", "build_cmd without force")
|
|
|
|
m.cfg.device = "/dev/video0"
|
|
eq(m.build_cmd({ "center" }),
|
|
"'linkctl' --device '/dev/video0' 'center'", "build_cmd with device")
|
|
|
|
m.cfg.linkctl = "/usr/local/bin/linkctl"
|
|
m.cfg.device = ""
|
|
eq(m.build_cmd({ "preset", "load", "my desk" }),
|
|
"'/usr/local/bin/linkctl' 'preset' 'load' 'my desk'",
|
|
"build_cmd quotes preset with space")
|
|
|
|
io.write(string.format("\n%d checks, %d failures\n", checks, failures))
|
|
os.exit(failures == 0 and 0 or 1)
|