Files
brad f95853dc25 Add GPL-3.0 licensing and prepare for a public repository
metadata.json claimed MIT while no LICENSE file existed, so the repository
had no effective licence at all. It is now GPL-3.0-or-later: the full FSF
text in LICENSE, the identifier in metadata.json, and REUSE-style
SPDX-FileCopyrightText and SPDX-License-Identifier headers on all 33 source
files, which is the convention KDE itself uses.

Worth recording that GPL-3.0 is a preference here rather than an obligation.
Nothing in the dependency graph forces it: linkctl is MIT and is invoked as a
subprocess rather than linked, and the KDE QML modules the applet imports are
LGPL.

Removed two things that should not ship in a public repository. tests's QML
linter had an absolute sandbox path, /tmp/pyside/PySide6/qmllint, baked into
its search list; it now looks for Qt's qmllint and PySide6's pyside6-qmllint
on PATH and honours a QMLLINT override, and says how to install one when it
finds nothing. The systemd unit carried a Documentation= URL pointing at a
host that does not exist, and its ExecStart silently assumed a particular
checkout directory; the URL is gone and the path is now an obvious
PATH/TO/CHECKOUT placeholder next to the comment telling you to edit it.

Added a Credits section naming the work this is built on, since the project
is mostly glue: linkctl does the camera control, schlarpc's firmware teardown
documents the extension-unit protocol, and vrwallace's controller established
that the Link 2 needs both pan and tilt axes in a single VIDIOC_S_EXT_CTRLS
call, which is the detail the whole thing depends on.

Also made the two Python tests executable, matching the other scripts.

Audited for operator-specific detail: no credentials, tokens, private
hostnames, IP addresses or absolute home paths in any tracked file or
anywhere in the history's diffs. Three merge commits do carry Reviewed-on and
Reviewed-by trailers naming a private forge, added by the forge at merge
time; those are commit metadata rather than file content and are handled
separately.

Tested: 169 checks across seven suites plus the QML linter, all passing after
the header insertion, which confirms it did not disturb the order-sensitive
`.pragma library` line in logic.js or the qmldir module definitions.
shellcheck clean.
2026-10-13 20:54:23 -07:00

350 lines
12 KiB
Lua

-- SPDX-FileCopyrightText: 2026 branderson
-- SPDX-License-Identifier: GPL-3.0-or-later
--[[
obs-linkctl.lua - Insta360 Link control inside OBS.
Adds to OBS:
* Hotkeys for tracking on/off/toggle, a named preset, and recenter.
Bind them under Settings -> Hotkeys (search for "Insta360").
* Scene automation: switching to a scene can set the camera's state, so a
"Talking Head" scene enables tracking and an "Overhead" scene loads a
preset without anyone pressing anything.
* A startup action, which matters on Linux because the Link parks its
gimbal facing down when idle. Pointing it home when OBS finishes
loading undoes that automatically.
Install: Tools -> Scripts -> + -> pick this file.
OBS's own hotkeys only fire while OBS has focus under Wayland. For hotkeys
that work from anywhere, use the KDE shortcuts in ../kde/ instead; the two
are complementary and can both be installed.
--]]
local obs = obslua
-- Actions are plain linkctl argument lists, resolved when the action runs so
-- that edits to the preset name in the properties UI take effect immediately.
local cfg = {
linkctl = "linkctl",
device = "",
force = true,
preset = "home",
scene_map_text = "",
startup_action = "preset load home",
log_output = false,
enable_scenes = true,
}
local scene_map = {}
local hotkey_ids = {}
local last_run = { cmd = nil, at = 0 }
-- OBS owns this handle and keeps it alive for the lifetime of the script. We
-- hold it because a button callback is handed only (props, property) and so
-- has no other route to write a setting back.
local script_settings = nil
local HOTKEYS = {
{ name = "linkctl_tracking_toggle", label = "Insta360: toggle AI tracking",
args = function() return { "tracking", "toggle" } end },
{ name = "linkctl_tracking_on", label = "Insta360: AI tracking on",
args = function() return { "tracking", "on" } end },
{ name = "linkctl_tracking_off", label = "Insta360: AI tracking off",
args = function() return { "tracking", "off" } end },
{ name = "linkctl_preset_load", label = "Insta360: load named preset",
args = function() return { "preset", "load", cfg.preset } end },
{ name = "linkctl_center", label = "Insta360: recenter gimbal",
args = function() return { "center" } end },
}
-- Tolerate obs being absent so the pure helpers below stay testable offline.
local function log(fmt, ...)
local msg = string.format(fmt, ...)
if obs then obs.script_log(obs.LOG_INFO, msg) end
end
local function warn(fmt, ...)
local msg = string.format(fmt, ...)
if obs then obs.script_log(obs.LOG_WARNING, msg) end
end
-- Single-quote for /bin/sh. Preset names come from user input and reach a
-- shell, so this is the boundary that has to hold.
local function shquote(s)
return "'" .. (tostring(s):gsub("'", "'\\''")) .. "'"
end
-- Split an action string into arguments, honouring double quotes so that a
-- preset name containing a space still works.
local function tokenize(s)
local out, i, n = {}, 1, #s
while i <= n do
local c = s:sub(i, i)
if c:match("%s") then
i = i + 1
elseif c == '"' then
local j = s:find('"', i + 1, true)
if not j then
table.insert(out, s:sub(i + 1))
break
end
table.insert(out, s:sub(i + 1, j - 1))
i = j + 1
else
local j = s:find("%s", i) or (n + 1)
table.insert(out, s:sub(i, j - 1))
i = j
end
end
return out
end
local function build_cmd(args)
local parts = { shquote(cfg.linkctl) }
if cfg.force then
table.insert(parts, "--force")
end
if cfg.device ~= "" then
table.insert(parts, "--device")
table.insert(parts, shquote(cfg.device))
end
for _, a in ipairs(args) do
table.insert(parts, shquote(a))
end
return table.concat(parts, " ")
end
local function run(args)
if not args or #args == 0 then
return
end
local cmd = build_cmd(args)
-- Studio mode and transitions can emit several scene events in a row.
-- Collapse identical commands fired within 250ms of each other.
local now = obs.os_gettime_ns()
if last_run.cmd == cmd and (now - last_run.at) < 250000000 then
return
end
last_run.cmd, last_run.at = cmd, now
if cfg.log_output then
-- Synchronous, so the camera's reply reaches the script log. Slightly
-- blocking; intended for troubleshooting rather than normal use.
local f = io.popen(cmd .. " 2>&1")
if f then
local out = f:read("*a")
f:close()
log("%s -> %s", cmd, (out:gsub("%s+$", "")))
else
warn("could not run: %s", cmd)
end
else
-- Fire and forget: the shell backgrounds it, so the OBS UI thread
-- never waits on the camera.
os.execute(cmd .. " >/dev/null 2>&1 &")
end
end
local function parse_scene_map(text)
local map = {}
for line in tostring(text):gmatch("[^\r\n]+") do
local trimmed = line:match("^%s*(.-)%s*$")
if trimmed ~= "" and not trimmed:match("^#") then
local scene, action = trimmed:match("^(.-)%s*=%s*(.*)$")
if scene and scene ~= "" and action and action ~= "" then
map[scene] = action
else
warn("ignoring unparsable scene mapping: %s", trimmed)
end
end
end
return map
end
local function current_scene_name()
local src = obs.obs_frontend_get_current_scene()
if not src then
return nil
end
local name = obs.obs_source_get_name(src)
obs.obs_source_release(src)
return name
end
local function on_scene_changed()
if not cfg.enable_scenes then
return
end
local name = current_scene_name()
if not name then
return
end
local action = scene_map[name]
if action then
run(tokenize(action))
end
end
local function on_frontend_event(event)
if event == obs.OBS_FRONTEND_EVENT_SCENE_CHANGED then
on_scene_changed()
elseif event == obs.OBS_FRONTEND_EVENT_FINISHED_LOADING then
if cfg.startup_action ~= "" then
run(tokenize(cfg.startup_action))
end
end
end
----------------------------------------------------------------------------
-- Properties UI
----------------------------------------------------------------------------
function script_description()
return [[<b>Insta360 Link control</b><br/>
Drives <code>linkctl</code> for AI tracking, presets and the gimbal.<br/><br/>
Bind the hotkeys under <b>Settings &rarr; Hotkeys</b> ("Insta360").<br/>
For hotkeys that work outside OBS, install the KDE shortcuts shipped
alongside this script.]]
end
local function populate_scenes()
-- Prefill the mapping box with every scene currently in the collection,
-- so the user edits a list rather than guessing exact scene names.
if not script_settings then
return false
end
local scenes = obs.obs_frontend_get_scenes()
if not scenes then
return false
end
local lines = {}
for _, src in ipairs(scenes) do
local name = obs.obs_source_get_name(src)
-- Keep any action already mapped to this scene.
table.insert(lines, string.format("%s = %s", name, scene_map[name] or ""))
end
obs.source_list_release(scenes)
local text = table.concat(lines, "\n")
obs.obs_data_set_string(script_settings, "scene_map_text", text)
cfg.scene_map_text = text
scene_map = parse_scene_map(text)
-- true asks OBS to redraw the properties pane so the new text appears.
return true
end
function script_properties()
local props = obs.obs_properties_create()
obs.obs_properties_add_path(props, "linkctl", "linkctl binary",
obs.OBS_PATH_FILE, "", "/usr/local/bin/linkctl")
obs.obs_properties_add_text(props, "device",
"Video device (blank = auto-detect)", obs.OBS_TEXT_DEFAULT)
obs.obs_properties_add_bool(props, "force",
"Act even when no application is using the camera (--force)")
obs.obs_properties_add_text(props, "preset",
"Preset name for the 'load named preset' hotkey", obs.OBS_TEXT_DEFAULT)
obs.obs_properties_add_text(props, "startup_action",
"Run when OBS finishes loading (blank = nothing)", obs.OBS_TEXT_DEFAULT)
obs.obs_properties_add_bool(props, "enable_scenes",
"React to scene changes")
local map = obs.obs_properties_add_text(props, "scene_map_text",
"Scene actions, one per line: Scene Name = linkctl args",
obs.OBS_TEXT_MULTILINE)
obs.obs_property_set_long_description(map,
"Example:\n" ..
"Talking Head = tracking on\n" ..
"Overhead = preset load overhead\n" ..
"Slides = tracking off\n\n" ..
"Blank actions and lines starting with # are ignored.")
obs.obs_properties_add_button(props, "fill_scenes",
"Insert current scene names", populate_scenes)
obs.obs_properties_add_bool(props, "log_output",
"Log linkctl output to the script log (for troubleshooting)")
obs.obs_properties_add_button(props, "test_tracking_on", "Test: tracking on",
function() run({ "tracking", "on" }) return false end)
obs.obs_properties_add_button(props, "test_tracking_off", "Test: tracking off",
function() run({ "tracking", "off" }) return false end)
obs.obs_properties_add_button(props, "test_preset", "Test: load preset",
function() run({ "preset", "load", cfg.preset }) return false end)
obs.obs_properties_add_button(props, "test_center", "Test: recenter",
function() run({ "center" }) return false end)
return props
end
function script_defaults(settings)
obs.obs_data_set_default_string(settings, "linkctl", "linkctl")
obs.obs_data_set_default_string(settings, "device", "")
obs.obs_data_set_default_bool(settings, "force", true)
obs.obs_data_set_default_string(settings, "preset", "home")
obs.obs_data_set_default_string(settings, "startup_action", "preset load home")
obs.obs_data_set_default_bool(settings, "enable_scenes", true)
obs.obs_data_set_default_string(settings, "scene_map_text", "")
obs.obs_data_set_default_bool(settings, "log_output", false)
end
function script_update(settings)
script_settings = settings
cfg.linkctl = obs.obs_data_get_string(settings, "linkctl")
cfg.device = obs.obs_data_get_string(settings, "device")
cfg.force = obs.obs_data_get_bool(settings, "force")
cfg.preset = obs.obs_data_get_string(settings, "preset")
cfg.startup_action = obs.obs_data_get_string(settings, "startup_action")
cfg.enable_scenes = obs.obs_data_get_bool(settings, "enable_scenes")
cfg.scene_map_text = obs.obs_data_get_string(settings, "scene_map_text")
cfg.log_output = obs.obs_data_get_bool(settings, "log_output")
if cfg.linkctl == "" then
cfg.linkctl = "linkctl"
end
scene_map = parse_scene_map(cfg.scene_map_text)
end
function script_load(settings)
script_settings = settings
for _, def in ipairs(HOTKEYS) do
local id = obs.obs_hotkey_register_frontend(def.name, def.label,
function(pressed)
if pressed then
run(def.args())
end
end)
hotkey_ids[def.name] = id
local saved = obs.obs_data_get_array(settings, def.name)
obs.obs_hotkey_load(id, saved)
obs.obs_data_array_release(saved)
end
obs.obs_frontend_add_event_callback(on_frontend_event)
end
function script_save(settings)
for name, id in pairs(hotkey_ids) do
local a = obs.obs_hotkey_save(id)
obs.obs_data_set_array(settings, name, a)
obs.obs_data_array_release(a)
end
end
function script_unload()
obs.obs_frontend_remove_event_callback(on_frontend_event)
end
-- Exposed only when loaded outside OBS, by the test harness in ../tests.
-- OBS discards a chunk's return value, but staying silent there keeps the
-- script's contract with OBS unambiguous.
if not rawget(_G, "obslua") then
return {
shquote = shquote,
tokenize = tokenize,
parse_scene_map = parse_scene_map,
build_cmd = build_cmd,
cfg = cfg,
}
end