Files
insta360-link-tools/bin/linkctl-macro
T
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

170 lines
5.8 KiB
Bash
Executable File

#!/usr/bin/env bash
# SPDX-FileCopyrightText: 2026 branderson
# SPDX-License-Identifier: GPL-3.0-or-later
#
# linkctl-macro - run a linkctl command as a desktop macro.
#
# Wraps linkctl so it is safe to bind to a global hotkey:
# - resolves the linkctl binary even in the sparse environment a desktop
# shortcut is launched with
# - passes --force, because a deliberate keypress means "do it now" even
# when no application currently holds the camera open
# - reports the result as a desktop notification, since tracking state and
# gimbal position are otherwise invisible
#
# Usage:
# linkctl-macro tracking on
# linkctl-macro tracking toggle
# linkctl-macro preset load home
# linkctl-macro center
#
# Any linkctl subcommand works; arguments are passed through untouched.
#
# Options (before the linkctl subcommand):
# --no-notify suppress the desktop notification
# --print echo the resolved command instead of running it
#
# Configuration, from the environment or ~/.config/linkctl-macro.conf:
# LINKCTL path to the linkctl binary
# LINKCTL_DEVICE video node to target, e.g. /dev/video0 (for multi-camera)
# LINKCTL_FORCE 1 (default) to pass --force, 0 to respect the idle guard
# LINKCTL_NOTIFY always (default) | error | never
set -uo pipefail
readonly CONF="${XDG_CONFIG_HOME:-$HOME/.config}/linkctl-macro.conf"
# shellcheck source=/dev/null
[[ -r $CONF ]] && source "$CONF"
: "${LINKCTL_DEVICE:=}"
: "${LINKCTL_FORCE:=1}"
: "${LINKCTL_NOTIFY:=always}"
# A desktop shortcut does not inherit an interactive shell's PATH, so look in
# the places linkctl is actually installed to before giving up.
#
# Sets LINKCTL_BIN on success and RESOLVE_ERR on failure. Both are globals
# rather than stdout, because a command substitution would run this in a
# subshell and discard the error message.
resolve_linkctl() {
local c
LINKCTL_BIN=
RESOLVE_ERR=
if [[ -n ${LINKCTL:-} ]]; then
# An explicit setting is authoritative, so a bad one is an error here
# rather than a confusing exec failure later.
if [[ -x $LINKCTL ]]; then
LINKCTL_BIN=$LINKCTL
return 0
fi
RESOLVE_ERR="LINKCTL is set to '$LINKCTL', which is not executable."
return 1
fi
if c=$(command -v linkctl 2>/dev/null); then
LINKCTL_BIN=$c
return 0
fi
for c in /usr/local/bin/linkctl /usr/bin/linkctl \
"$HOME/.local/bin/linkctl" "$HOME/.cargo/bin/linkctl" \
/var/lib/flatpak/exports/bin/linkctl; do
[[ -x $c ]] && { LINKCTL_BIN=$c; return 0; }
done
RESOLVE_ERR="linkctl not found on PATH or in the usual install locations."
return 1
}
# Map linkctl's documented exit codes onto something a person can act on.
hint_for() {
case $1 in
2) echo "Invalid arguments." ;;
3) echo "Camera not found. Is it plugged in?" ;;
4) echo "Several cameras found. Set LINKCTL_DEVICE in $CONF." ;;
5) echo "Camera is idle and --force was disabled." ;;
6) echo "Permission denied. Add yourself to the 'video' group or install the udev rule." ;;
7) echo "This control is not supported on this camera model." ;;
8) echo "Device I/O error. Try replugging the camera." ;;
9) echo "linkctl configuration error." ;;
11) echo "Invalid value for this control." ;;
12) echo "Vendor control failed. AI tracking is experimental." ;;
13) echo "Camera is busy." ;;
*) echo "linkctl exited with status $1." ;;
esac
}
notify() {
local urgency=$1 body=$2
[[ $LINKCTL_NOTIFY == never ]] && return 0
[[ $LINKCTL_NOTIFY == error && $urgency != critical ]] && return 0
command -v notify-send >/dev/null 2>&1 || return 0
# The synchronous hint makes repeated keypresses replace the previous
# popup instead of stacking a queue of them.
notify-send \
--app-name="Insta360 Link" \
--icon=camera-web \
--urgency="$urgency" \
--expire-time=2500 \
--hint=string:x-canonical-private-synchronous:linkctl-macro \
"Insta360 Link" "$body"
}
main() {
local no_notify=0 print_only=0
while [[ $# -gt 0 ]]; do
case $1 in
--no-notify) no_notify=1; shift ;;
--print) print_only=1; shift ;;
--) shift; break ;;
*) break ;;
esac
done
if [[ $# -eq 0 ]]; then
echo "usage: linkctl-macro [--no-notify] [--print] <linkctl args...>" >&2
echo " eg: linkctl-macro tracking toggle" >&2
return 2
fi
(( no_notify )) && LINKCTL_NOTIFY=never
if ! resolve_linkctl; then
local msg="${RESOLVE_ERR:-linkctl not found.} Set LINKCTL in $CONF."
notify critical "$msg"
echo "linkctl-macro: $msg" >&2
return 127
fi
local -a cmd=("$LINKCTL_BIN")
[[ $LINKCTL_FORCE == 1 ]] && cmd+=(--force)
[[ -n $LINKCTL_DEVICE ]] && cmd+=(--device "$LINKCTL_DEVICE")
cmd+=("$@")
if (( print_only )); then
printf '%q ' "${cmd[@]}"; echo
return 0
fi
local out status
out=$("${cmd[@]}" 2>&1)
status=$?
if (( status == 0 )); then
# linkctl already prints the resulting state, eg "Tracking: On", which
# is exactly the feedback worth surfacing. Fall back to the request.
local body=${out%%$'\n'*}
[[ -z $body ]] && body="$*"
notify normal "$body"
[[ -n $out ]] && printf '%s\n' "$out"
else
local body=${out%%$'\n'*}
[[ -n $body ]] && body="$body"$'\n'"$(hint_for "$status")" \
|| body=$(hint_for "$status")
notify critical "$body"
[[ -n $out ]] && printf '%s\n' "$out" >&2
printf 'linkctl-macro: %s\n' "$(hint_for "$status")" >&2
fi
return $status
}
main "$@"