Fix KDE global shortcuts never binding under Plasma 6, and add a joystick widget #1
@@ -10,6 +10,7 @@ Three pieces, usable independently:
|
|||||||
| `bin/linkctl-macro` | A wrapper that makes `linkctl` safe to bind to a hotkey: finds the binary in a desktop session's sparse `PATH`, passes `--force`, and reports the result as a desktop notification. |
|
| `bin/linkctl-macro` | A wrapper that makes `linkctl` safe to bind to a hotkey: finds the binary in a desktop session's sparse `PATH`, passes `--force`, and reports the result as a desktop notification. |
|
||||||
| `kde/install-shortcuts.sh` | Registers KDE Plasma global shortcuts, so the keys work from anywhere including under Wayland. |
|
| `kde/install-shortcuts.sh` | Registers KDE Plasma global shortcuts, so the keys work from anywhere including under Wayland. |
|
||||||
| `obs/obs-linkctl.lua` | OBS hotkeys, buttons, and scene automation: a scene switch can set tracking or load a preset on its own. |
|
| `obs/obs-linkctl.lua` | OBS hotkeys, buttons, and scene automation: a scene switch can set tracking or load a preset on its own. |
|
||||||
|
| `plasmoid/` | A Plasma 6 widget with a virtual joystick, zoom slider, and Home/Center/Tracking buttons. |
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@@ -37,9 +38,36 @@ Default bindings, editable in the `BINDINGS` table at the top of that script:
|
|||||||
| `Meta+Alt+H` | `preset load home` |
|
| `Meta+Alt+H` | `preset load home` |
|
||||||
| `Meta+Alt+C` | recenter gimbal |
|
| `Meta+Alt+C` | recenter gimbal |
|
||||||
|
|
||||||
Check them afterwards with `./kde/install-shortcuts.sh --list`, or in System
|
Check them afterwards with `./kde/install-shortcuts.sh --list`, which shows the
|
||||||
Settings under Keyboard, Shortcuts, searching for "Insta360". Reverse the whole
|
registered key, the default from the desktop entry, and flags any value Plasma
|
||||||
thing with `--uninstall`.
|
cannot parse. Or look in System Settings under Keyboard, Shortcuts, searching
|
||||||
|
for "Insta360". Reverse the whole thing with `--uninstall`.
|
||||||
|
|
||||||
|
The entries are visible in the launcher by default, because Plasma skips
|
||||||
|
`NoDisplay` services when building the Shortcuts list and they would otherwise
|
||||||
|
not appear there at all. `HIDE_FROM_MENU=1 ./kde/install-shortcuts.sh` hides
|
||||||
|
them at that cost.
|
||||||
|
|
||||||
|
## Joystick widget
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./plasmoid/install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Then right-click a panel or the desktop, Add Widgets, and search for
|
||||||
|
"Insta360". Pass `--restart` when reinstalling over a running copy, since
|
||||||
|
Plasma otherwise keeps the old one loaded.
|
||||||
|
|
||||||
|
The joystick is a velocity control: how far you push sets how fast the camera
|
||||||
|
moves, and it stops when you let go. Degrees per step, step interval, deadzone
|
||||||
|
and axis inversion are all in the widget's settings, along with the `linkctl`
|
||||||
|
path and an optional device.
|
||||||
|
|
||||||
|
It drives the gimbal with *relative* moves rather than absolute ones, because
|
||||||
|
`linkctl status` reports no pan or tilt at all while the camera is idle, so
|
||||||
|
there is no trustworthy position to aim from. Relative steps work either way
|
||||||
|
and cannot drift out of sync, and linkctl clamps each one to the camera's own
|
||||||
|
range, so holding the stick against a limit is harmless.
|
||||||
|
|
||||||
## OBS
|
## OBS
|
||||||
|
|
||||||
@@ -88,9 +116,11 @@ case where the gimbal is parked facing down.
|
|||||||
```sh
|
```sh
|
||||||
lua tests/test_obs_linkctl.lua # pure helpers: quoting, parsing, commands
|
lua tests/test_obs_linkctl.lua # pure helpers: quoting, parsing, commands
|
||||||
lua tests/test_obs_lifecycle.lua # the OBS script against a mock OBS API
|
lua tests/test_obs_lifecycle.lua # the OBS script against a mock OBS API
|
||||||
|
node tests/test_joystick_logic.js # the widget's command building and parsing
|
||||||
bin/linkctl-macro --print tracking on # show the command without running it
|
bin/linkctl-macro --print tracking on # show the command without running it
|
||||||
./kde/install-shortcuts.sh --dry-run # show the KDE changes, apply nothing
|
./kde/install-shortcuts.sh --dry-run # show the KDE changes, apply nothing
|
||||||
shellcheck bin/linkctl-macro kde/install-shortcuts.sh
|
./kde/install-shortcuts.sh --list # diagnose what is actually registered
|
||||||
|
shellcheck bin/linkctl-macro kde/install-shortcuts.sh plasmoid/install.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
The lifecycle suite drives the script the way OBS does, so it catches broken
|
The lifecycle suite drives the script the way OBS does, so it catches broken
|
||||||
@@ -99,10 +129,17 @@ captured too early) without needing OBS or a camera.
|
|||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
**Shortcut does nothing.** Run the action directly first:
|
**Shortcut does nothing, and System Settings says "No default shortcuts".**
|
||||||
|
Run `./kde/install-shortcuts.sh --list`. A `_launch` value containing commas
|
||||||
|
is the Plasma 5 format: Plasma 6 passes that value straight to
|
||||||
|
`QKeySequence::fromString`, so `Meta+Alt+T,none,Some Name` parses to nothing
|
||||||
|
and binds no key. Re-running the installer rewrites it as a bare key sequence.
|
||||||
|
Versions of this repo before that fix wrote the Plasma 5 form.
|
||||||
|
|
||||||
|
**Shortcut still does nothing.** Run the action directly:
|
||||||
`bin/linkctl-macro tracking on`. A failure notification names the cause. If
|
`bin/linkctl-macro tracking on`. A failure notification names the cause. If
|
||||||
that works but the key does not, re-run the installer with `SHOW_IN_MENU=1`;
|
that works but the key does not, check for a conflict in System Settings, and
|
||||||
some Plasma builds only resolve launcher-visible application entries.
|
confirm `--list` shows the entry as `present`.
|
||||||
|
|
||||||
**`tracking` fails with exit 12.** AI tracking is experimental in `linkctl` and
|
**`tracking` fails with exit 12.** AI tracking is experimental in `linkctl` and
|
||||||
goes through the camera's vendor extension unit. Pan, tilt, zoom and presets use
|
goes through the camera's vendor extension unit. Pan, tilt, zoom and presets use
|
||||||
|
|||||||
+42
-12
@@ -31,9 +31,10 @@ BINDINGS=(
|
|||||||
"center|Meta+Alt+C|center|Insta360: Recenter gimbal"
|
"center|Meta+Alt+C|center|Insta360: Recenter gimbal"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Set to 1 if the shortcuts fail to register; some Plasma builds only resolve
|
# The entries are visible by default: KApplicationTrader skips NoDisplay
|
||||||
# application entries that are visible in the launcher.
|
# services, so hiding them also hides them from the Shortcuts page in System
|
||||||
SHOW_IN_MENU=${SHOW_IN_MENU:-0}
|
# Settings. Set HIDE_FROM_MENU=1 to keep the launcher clean at that cost.
|
||||||
|
HIDE_FROM_MENU=${HIDE_FROM_MENU:-0}
|
||||||
|
|
||||||
readonly APPDIR="${XDG_DATA_HOME:-$HOME/.local/share}/applications"
|
readonly APPDIR="${XDG_DATA_HOME:-$HOME/.local/share}/applications"
|
||||||
readonly PREFIX="linkctl-"
|
readonly PREFIX="linkctl-"
|
||||||
@@ -81,15 +82,19 @@ desktop_file() { printf '%s/%s%s.desktop\n' "$APPDIR" "$PREFIX" "$1"; }
|
|||||||
desktop_name() { printf '%s%s.desktop\n' "$PREFIX" "$1"; }
|
desktop_name() { printf '%s%s.desktop\n' "$PREFIX" "$1"; }
|
||||||
|
|
||||||
write_desktop() {
|
write_desktop() {
|
||||||
local id=$1 args=$2 label=$3 path
|
local id=$1 args=$2 label=$3 keys=$4 path
|
||||||
path=$(desktop_file "$id")
|
path=$(desktop_file "$id")
|
||||||
if (( DRY_RUN )); then
|
if (( DRY_RUN )); then
|
||||||
printf ' would write %s\n' "$path"
|
printf ' would write %s\n' "$path"
|
||||||
printf ' Exec=%s %s\n' "$MACRO" "$args"
|
printf ' Exec=%s %s\n' "$MACRO" "$args"
|
||||||
|
printf ' X-KDE-Shortcuts=%s\n' "$keys"
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
local nodisplay=true
|
local nodisplay=false
|
||||||
(( SHOW_IN_MENU )) && nodisplay=false
|
(( HIDE_FROM_MENU )) && nodisplay=true
|
||||||
|
# X-KDE-Shortcuts is where kglobalacceld reads an entry's *default*
|
||||||
|
# shortcut from, so declaring it here is what makes the binding show up in
|
||||||
|
# System Settings instead of "No default shortcuts".
|
||||||
cat > "$path" <<EOF
|
cat > "$path" <<EOF
|
||||||
[Desktop Entry]
|
[Desktop Entry]
|
||||||
Type=Application
|
Type=Application
|
||||||
@@ -100,6 +105,9 @@ Icon=camera-web
|
|||||||
Terminal=false
|
Terminal=false
|
||||||
NoDisplay=$nodisplay
|
NoDisplay=$nodisplay
|
||||||
Categories=AudioVideo;
|
Categories=AudioVideo;
|
||||||
|
X-KDE-Shortcuts=$keys
|
||||||
|
X-KDE-GlobalAccel-CommandShortcut=true
|
||||||
|
StartupNotify=false
|
||||||
EOF
|
EOF
|
||||||
info " wrote $path"
|
info " wrote $path"
|
||||||
}
|
}
|
||||||
@@ -162,7 +170,7 @@ do_install() {
|
|||||||
info "Writing application entries:"
|
info "Writing application entries:"
|
||||||
for entry in "${BINDINGS[@]}"; do
|
for entry in "${BINDINGS[@]}"; do
|
||||||
IFS='|' read -r id keys args label <<< "$entry"
|
IFS='|' read -r id keys args label <<< "$entry"
|
||||||
write_desktop "$id" "$args" "$label"
|
write_desktop "$id" "$args" "$label" "$keys"
|
||||||
done
|
done
|
||||||
|
|
||||||
info "Stopping the global shortcut daemon:"
|
info "Stopping the global shortcut daemon:"
|
||||||
@@ -172,10 +180,14 @@ do_install() {
|
|||||||
info "Registering shortcuts:"
|
info "Registering shortcuts:"
|
||||||
for entry in "${BINDINGS[@]}"; do
|
for entry in "${BINDINGS[@]}"; do
|
||||||
IFS='|' read -r id keys args label <<< "$entry"
|
IFS='|' read -r id keys args label <<< "$entry"
|
||||||
# Value is: active shortcut, default shortcut, display name.
|
# _launch is a bare key sequence. Plasma 5 stored a
|
||||||
|
# "keys,default,name" triplet here and Plasma 6 does not: it passes
|
||||||
|
# the value straight to QKeySequence::fromString, so a triplet parses
|
||||||
|
# to nothing and the shortcut silently never binds. Alternates are
|
||||||
|
# separated by a tab, and "none" means unbound.
|
||||||
run "$KWRITE" --file kglobalshortcutsrc \
|
run "$KWRITE" --file kglobalshortcutsrc \
|
||||||
--group services --group "$(desktop_name "$id")" \
|
--group services --group "$(desktop_name "$id")" \
|
||||||
--key _launch "$keys,none,$label"
|
--key _launch "$keys"
|
||||||
info " $keys -> linkctl $args"
|
info " $keys -> linkctl $args"
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -214,15 +226,33 @@ do_uninstall() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
do_list() {
|
do_list() {
|
||||||
local entry id keys args label value
|
local entry id keys args label value path dflt problems=0
|
||||||
printf '%-22s %-14s %s\n' ACTION KEY REGISTERED
|
printf '%-20s %-12s %-14s %-12s %s\n' ACTION KEY _launch DEFAULT ENTRY
|
||||||
for entry in "${BINDINGS[@]}"; do
|
for entry in "${BINDINGS[@]}"; do
|
||||||
IFS='|' read -r id keys args label <<< "$entry"
|
IFS='|' read -r id keys args label <<< "$entry"
|
||||||
value=$("$KREAD" --file kglobalshortcutsrc \
|
value=$("$KREAD" --file kglobalshortcutsrc \
|
||||||
--group services --group "$(desktop_name "$id")" \
|
--group services --group "$(desktop_name "$id")" \
|
||||||
--key _launch 2>/dev/null || true)
|
--key _launch 2>/dev/null || true)
|
||||||
printf '%-22s %-14s %s\n' "$id" "$keys" "${value:-<not set>}"
|
path=$(desktop_file "$id")
|
||||||
|
if [[ -r $path ]]; then
|
||||||
|
dflt=$(sed -n 's/^X-KDE-Shortcuts=//p' "$path" | head -1)
|
||||||
|
else
|
||||||
|
dflt=""
|
||||||
|
fi
|
||||||
|
# A comma here means a Plasma 5 triplet, which Plasma 6 cannot parse.
|
||||||
|
if [[ $value == *,* ]]; then
|
||||||
|
problems=1
|
||||||
|
value="$value <-- INVALID"
|
||||||
|
fi
|
||||||
|
printf '%-20s %-12s %-14s %-12s %s\n' \
|
||||||
|
"$id" "$keys" "${value:-<unset>}" "${dflt:-<none>}" \
|
||||||
|
"$([[ -r $path ]] && echo present || echo MISSING)"
|
||||||
done
|
done
|
||||||
|
if (( problems )); then
|
||||||
|
echo
|
||||||
|
echo "A _launch value containing commas is the Plasma 5 format and never"
|
||||||
|
echo "binds under Plasma 6. Re-run this script to rewrite them."
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
|
|||||||
Executable
+86
@@ -0,0 +1,86 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# install.sh - install the Insta360 Link Joystick applet into Plasma.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./install.sh install, or upgrade if already present
|
||||||
|
# ./install.sh --uninstall remove it
|
||||||
|
# ./install.sh --restart also restart plasmashell (needed to pick up
|
||||||
|
# changes to an already-running applet)
|
||||||
|
#
|
||||||
|
# After installing: right-click the desktop or panel, Add Widgets, and search
|
||||||
|
# for "Insta360".
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
readonly APPLET_ID="com.github.insta360linktools.joystick"
|
||||||
|
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||||
|
readonly SCRIPT_DIR
|
||||||
|
readonly PKG="$SCRIPT_DIR/package"
|
||||||
|
|
||||||
|
RESTART=0
|
||||||
|
MODE=install
|
||||||
|
|
||||||
|
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case $1 in
|
||||||
|
--uninstall) MODE=uninstall; shift ;;
|
||||||
|
--restart) RESTART=1; shift ;;
|
||||||
|
-h|--help)
|
||||||
|
awk 'NR>1 { if (!/^#/) exit; sub(/^# ?/, ""); print }' \
|
||||||
|
"${BASH_SOURCE[0]}"
|
||||||
|
exit 0 ;;
|
||||||
|
*) die "unknown option: $1" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if command -v kpackagetool6 >/dev/null 2>&1; then
|
||||||
|
KPT=kpackagetool6
|
||||||
|
elif command -v kpackagetool5 >/dev/null 2>&1; then
|
||||||
|
KPT=kpackagetool5
|
||||||
|
else
|
||||||
|
die "neither kpackagetool6 nor kpackagetool5 found; is this a KDE Plasma session?"
|
||||||
|
fi
|
||||||
|
|
||||||
|
restart_shell() {
|
||||||
|
(( RESTART )) || return 0
|
||||||
|
echo "Restarting plasmashell:"
|
||||||
|
if systemctl --user restart plasma-plasmashell.service 2>/dev/null; then
|
||||||
|
echo " restarted via systemd"
|
||||||
|
elif command -v kquitapp6 >/dev/null 2>&1; then
|
||||||
|
kquitapp6 plasmashell 2>/dev/null || true
|
||||||
|
sleep 2
|
||||||
|
(setsid plasmashell >/dev/null 2>&1 &)
|
||||||
|
echo " restarted"
|
||||||
|
else
|
||||||
|
echo " could not restart; log out and back in to pick up changes"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
case $MODE in
|
||||||
|
install)
|
||||||
|
[[ -f $PKG/metadata.json ]] || die "no package found at $PKG"
|
||||||
|
# Fail early on malformed metadata rather than leaving a half-installed
|
||||||
|
# applet that Plasma silently refuses to load.
|
||||||
|
python3 -c "import json,sys; json.load(open('$PKG/metadata.json'))" \
|
||||||
|
2>/dev/null || die "metadata.json is not valid JSON"
|
||||||
|
|
||||||
|
if "$KPT" --type Plasma/Applet --list 2>/dev/null | grep -qx "$APPLET_ID"; then
|
||||||
|
echo "Already installed; upgrading:"
|
||||||
|
"$KPT" --type Plasma/Applet --upgrade "$PKG"
|
||||||
|
else
|
||||||
|
echo "Installing:"
|
||||||
|
"$KPT" --type Plasma/Applet --install "$PKG"
|
||||||
|
fi
|
||||||
|
restart_shell
|
||||||
|
echo
|
||||||
|
echo "Done. Right-click a panel or the desktop, Add Widgets, search 'Insta360'."
|
||||||
|
;;
|
||||||
|
uninstall)
|
||||||
|
echo "Removing:"
|
||||||
|
"$KPT" --type Plasma/Applet --remove "$APPLET_ID"
|
||||||
|
restart_shell
|
||||||
|
echo "Done."
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import QtQuick
|
||||||
|
import org.kde.plasma.configuration
|
||||||
|
|
||||||
|
ConfigModel {
|
||||||
|
ConfigCategory {
|
||||||
|
name: "General"
|
||||||
|
icon: "camera-web"
|
||||||
|
source: "configGeneral.qml"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<kcfg xmlns="http://www.kde.org/standards/kcfg/1.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.kde.org/standards/kcfg/1.0
|
||||||
|
http://www.kde.org/standards/kcfg/1.0/kcfg.xsd">
|
||||||
|
<kcfgfile name=""/>
|
||||||
|
<group name="General">
|
||||||
|
<entry name="linkctlPath" type="String">
|
||||||
|
<label>Path to the linkctl binary</label>
|
||||||
|
<default>linkctl</default>
|
||||||
|
</entry>
|
||||||
|
<entry name="device" type="String">
|
||||||
|
<label>Video device, blank to auto-detect</label>
|
||||||
|
<default></default>
|
||||||
|
</entry>
|
||||||
|
<entry name="homePreset" type="String">
|
||||||
|
<label>Preset loaded by the Home button</label>
|
||||||
|
<default>home</default>
|
||||||
|
</entry>
|
||||||
|
<entry name="maxStep" type="Int">
|
||||||
|
<label>Degrees per step at full deflection</label>
|
||||||
|
<default>6</default>
|
||||||
|
</entry>
|
||||||
|
<entry name="tickInterval" type="Int">
|
||||||
|
<label>Milliseconds between steps while held</label>
|
||||||
|
<default>160</default>
|
||||||
|
</entry>
|
||||||
|
<entry name="deadzone" type="Int">
|
||||||
|
<label>Deadzone as a percentage of the radius</label>
|
||||||
|
<default>15</default>
|
||||||
|
</entry>
|
||||||
|
<entry name="invertPan" type="Bool">
|
||||||
|
<label>Invert the horizontal axis</label>
|
||||||
|
<default>false</default>
|
||||||
|
</entry>
|
||||||
|
<entry name="invertTilt" type="Bool">
|
||||||
|
<label>Invert the vertical axis</label>
|
||||||
|
<default>false</default>
|
||||||
|
</entry>
|
||||||
|
</group>
|
||||||
|
</kcfg>
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/*
|
||||||
|
* A circular virtual joystick.
|
||||||
|
*
|
||||||
|
* Reports displacement as a normalised vector: nx and ny in -1..1, with ny
|
||||||
|
* positive upwards. Emits nothing itself; the parent samples nx/ny on a timer
|
||||||
|
* while `held` is true, which is what turns a position into a velocity.
|
||||||
|
*/
|
||||||
|
import QtQuick
|
||||||
|
import org.kde.kirigami as Kirigami
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: pad
|
||||||
|
|
||||||
|
property real nx: 0
|
||||||
|
property real ny: 0
|
||||||
|
property bool held: false
|
||||||
|
// Displacement below this fraction of the radius counts as centred, so
|
||||||
|
// resting a finger slightly off-centre does not creep the camera.
|
||||||
|
property real deadzone: 0.15
|
||||||
|
|
||||||
|
readonly property real magnitude: Math.sqrt(nx * nx + ny * ny)
|
||||||
|
readonly property bool engaged: held && magnitude > deadzone
|
||||||
|
|
||||||
|
implicitWidth: Kirigami.Units.gridUnit * 9
|
||||||
|
implicitHeight: implicitWidth
|
||||||
|
|
||||||
|
signal released()
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
nx = 0;
|
||||||
|
ny = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base
|
||||||
|
Rectangle {
|
||||||
|
id: base
|
||||||
|
anchors.centerIn: parent
|
||||||
|
width: Math.min(pad.width, pad.height)
|
||||||
|
height: width
|
||||||
|
radius: width / 2
|
||||||
|
color: Kirigami.Theme.alternateBackgroundColor
|
||||||
|
border.width: 1
|
||||||
|
border.color: Kirigami.Theme.separatorColor ?? Kirigami.Theme.textColor
|
||||||
|
|
||||||
|
// Deadzone ring, drawn so the inactive area is visible rather than
|
||||||
|
// something the user has to discover.
|
||||||
|
Rectangle {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
width: base.width * pad.deadzone
|
||||||
|
height: width
|
||||||
|
radius: width / 2
|
||||||
|
color: "transparent"
|
||||||
|
border.width: 1
|
||||||
|
border.color: Kirigami.Theme.separatorColor ?? Kirigami.Theme.textColor
|
||||||
|
opacity: 0.4
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crosshair
|
||||||
|
Rectangle {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
width: base.width * 0.9
|
||||||
|
height: 1
|
||||||
|
color: Kirigami.Theme.textColor
|
||||||
|
opacity: 0.15
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
width: 1
|
||||||
|
height: base.height * 0.9
|
||||||
|
color: Kirigami.Theme.textColor
|
||||||
|
opacity: 0.15
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property real knobSize: base.width * 0.28
|
||||||
|
// Travel available to the knob centre.
|
||||||
|
readonly property real travel: (base.width - knobSize) / 2
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: knob
|
||||||
|
width: pad.knobSize
|
||||||
|
height: pad.knobSize
|
||||||
|
radius: width / 2
|
||||||
|
color: pad.engaged ? Kirigami.Theme.highlightColor
|
||||||
|
: Kirigami.Theme.backgroundColor
|
||||||
|
border.width: 2
|
||||||
|
border.color: Kirigami.Theme.highlightColor
|
||||||
|
x: base.x + base.width / 2 - width / 2 + pad.nx * pad.travel
|
||||||
|
y: base.y + base.height / 2 - height / 2 - pad.ny * pad.travel
|
||||||
|
|
||||||
|
Behavior on x {
|
||||||
|
enabled: !pad.held
|
||||||
|
NumberAnimation { duration: 120; easing.type: Easing.OutCubic }
|
||||||
|
}
|
||||||
|
Behavior on y {
|
||||||
|
enabled: !pad.held
|
||||||
|
NumberAnimation { duration: 120; easing.type: Easing.OutCubic }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
preventStealing: true
|
||||||
|
|
||||||
|
function update(mx, my) {
|
||||||
|
const cx = base.x + base.width / 2;
|
||||||
|
const cy = base.y + base.height / 2;
|
||||||
|
let dx = mx - cx;
|
||||||
|
let dy = my - cy;
|
||||||
|
const len = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
// Clamp into the circle so a corner drag cannot exceed full scale.
|
||||||
|
if (len > pad.travel && len > 0) {
|
||||||
|
dx *= pad.travel / len;
|
||||||
|
dy *= pad.travel / len;
|
||||||
|
}
|
||||||
|
pad.nx = pad.travel > 0 ? dx / pad.travel : 0;
|
||||||
|
pad.ny = pad.travel > 0 ? -dy / pad.travel : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
onPressed: (mouse) => {
|
||||||
|
pad.held = true;
|
||||||
|
update(mouse.x, mouse.y);
|
||||||
|
}
|
||||||
|
onPositionChanged: (mouse) => {
|
||||||
|
if (pad.held) {
|
||||||
|
update(mouse.x, mouse.y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onReleased: {
|
||||||
|
pad.held = false;
|
||||||
|
pad.reset();
|
||||||
|
pad.released();
|
||||||
|
}
|
||||||
|
onCanceled: {
|
||||||
|
pad.held = false;
|
||||||
|
pad.reset();
|
||||||
|
pad.released();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls as QQC2
|
||||||
|
import org.kde.kirigami as Kirigami
|
||||||
|
|
||||||
|
Kirigami.FormLayout {
|
||||||
|
id: page
|
||||||
|
|
||||||
|
// The cfg_* aliases are the contract with Plasma's config system: each
|
||||||
|
// name must match an entry in config/main.xml.
|
||||||
|
property alias cfg_linkctlPath: linkctlPath.text
|
||||||
|
property alias cfg_device: device.text
|
||||||
|
property alias cfg_homePreset: homePreset.text
|
||||||
|
property alias cfg_maxStep: maxStep.value
|
||||||
|
property alias cfg_tickInterval: tickInterval.value
|
||||||
|
property alias cfg_deadzone: deadzone.value
|
||||||
|
property alias cfg_invertPan: invertPan.checked
|
||||||
|
property alias cfg_invertTilt: invertTilt.checked
|
||||||
|
|
||||||
|
QQC2.TextField {
|
||||||
|
id: linkctlPath
|
||||||
|
Kirigami.FormData.label: "linkctl binary:"
|
||||||
|
placeholderText: "linkctl"
|
||||||
|
}
|
||||||
|
|
||||||
|
QQC2.TextField {
|
||||||
|
id: device
|
||||||
|
Kirigami.FormData.label: "Video device:"
|
||||||
|
placeholderText: "auto-detect"
|
||||||
|
}
|
||||||
|
|
||||||
|
QQC2.TextField {
|
||||||
|
id: homePreset
|
||||||
|
Kirigami.FormData.label: "Home preset:"
|
||||||
|
placeholderText: "home"
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
Kirigami.FormData.isSection: true
|
||||||
|
}
|
||||||
|
|
||||||
|
QQC2.SpinBox {
|
||||||
|
id: maxStep
|
||||||
|
Kirigami.FormData.label: "Degrees per step:"
|
||||||
|
from: 1
|
||||||
|
to: 30
|
||||||
|
}
|
||||||
|
|
||||||
|
QQC2.SpinBox {
|
||||||
|
id: tickInterval
|
||||||
|
Kirigami.FormData.label: "Step interval (ms):"
|
||||||
|
from: 60
|
||||||
|
to: 1000
|
||||||
|
stepSize: 20
|
||||||
|
}
|
||||||
|
|
||||||
|
QQC2.SpinBox {
|
||||||
|
id: deadzone
|
||||||
|
Kirigami.FormData.label: "Deadzone (%):"
|
||||||
|
from: 0
|
||||||
|
to: 50
|
||||||
|
}
|
||||||
|
|
||||||
|
QQC2.CheckBox {
|
||||||
|
id: invertPan
|
||||||
|
Kirigami.FormData.label: "Invert:"
|
||||||
|
text: "Horizontal axis"
|
||||||
|
}
|
||||||
|
|
||||||
|
QQC2.CheckBox {
|
||||||
|
id: invertTilt
|
||||||
|
text: "Vertical axis"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
/*
|
||||||
|
* Pure helpers for the joystick applet.
|
||||||
|
*
|
||||||
|
* Kept free of QML types so they can be unit-tested with node; see
|
||||||
|
* tests/test_joystick_logic.js. `.pragma library` means this file holds no
|
||||||
|
* per-instance state and cannot touch the QML context, which is exactly the
|
||||||
|
* constraint that keeps it testable.
|
||||||
|
*/
|
||||||
|
.pragma library
|
||||||
|
|
||||||
|
// Quote a value for /bin/sh. Config values (binary path, device, preset name)
|
||||||
|
// all reach a shell, so this is a boundary that has to hold.
|
||||||
|
function shq(s) {
|
||||||
|
return "'" + String(s).replace(/'/g, "'\\''") + "'";
|
||||||
|
}
|
||||||
|
|
||||||
|
// The invocation prefix shared by every command the applet runs.
|
||||||
|
function baseCmd(linkctl, device) {
|
||||||
|
var c = shq(linkctl || "linkctl") + " --force --json";
|
||||||
|
if (device && device !== "") {
|
||||||
|
c += " --device " + shq(device);
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Build the shell command for one joystick tick.
|
||||||
|
*
|
||||||
|
* nx/ny are the normalised stick position, ny positive upwards. Returns ""
|
||||||
|
* when the deflection on both axes is too small to be worth a command, so the
|
||||||
|
* caller can skip the tick entirely.
|
||||||
|
*
|
||||||
|
* Each axis needs its own linkctl call because the relative commands are
|
||||||
|
* single-axis, so a diagonal push produces two, chained with ";".
|
||||||
|
*/
|
||||||
|
function tickCommand(base, nx, ny, stepMax, invertPan, invertTilt) {
|
||||||
|
var x = invertPan ? -nx : nx;
|
||||||
|
var y = invertTilt ? -ny : ny;
|
||||||
|
var dx = Math.abs(x) * stepMax;
|
||||||
|
var dy = Math.abs(y) * stepMax;
|
||||||
|
var parts = [];
|
||||||
|
// Per-axis threshold, so a mostly-horizontal push does not also creep
|
||||||
|
// vertically. Below 0.5 degrees the camera's 0.1 degree resolution makes
|
||||||
|
// the step pointless anyway.
|
||||||
|
if (dx >= 0.5) {
|
||||||
|
parts.push(base + (x > 0 ? " right " : " left ") + dx.toFixed(1));
|
||||||
|
}
|
||||||
|
if (dy >= 0.5) {
|
||||||
|
parts.push(base + (y > 0 ? " up " : " down ") + dy.toFixed(1));
|
||||||
|
}
|
||||||
|
return parts.join(" ; ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Merge every JSON object found in linkctl's stdout into a flat update.
|
||||||
|
*
|
||||||
|
* A diagonal tick runs two linkctl calls, and each reports only the axis it
|
||||||
|
* changed, so the results have to be merged rather than replaced. Only keys
|
||||||
|
* actually present with the right type are returned, which keeps a null from
|
||||||
|
* an unrelated field from clobbering a known value.
|
||||||
|
*/
|
||||||
|
function absorb(stdout) {
|
||||||
|
var update = {};
|
||||||
|
var lines = String(stdout).split("\n");
|
||||||
|
for (var i = 0; i < lines.length; i++) {
|
||||||
|
var t = lines[i].trim();
|
||||||
|
if (t.length === 0 || t.charAt(0) !== "{") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var o;
|
||||||
|
try {
|
||||||
|
o = JSON.parse(t);
|
||||||
|
} catch (e) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (typeof o.pan === "number") {
|
||||||
|
update.pan = o.pan;
|
||||||
|
}
|
||||||
|
if (typeof o.tilt === "number") {
|
||||||
|
update.tilt = o.tilt;
|
||||||
|
}
|
||||||
|
if (typeof o.zoom === "number") {
|
||||||
|
update.zoom = o.zoom;
|
||||||
|
}
|
||||||
|
if (typeof o.tracking === "boolean") {
|
||||||
|
update.tracking = o.tracking;
|
||||||
|
}
|
||||||
|
if (typeof o.state === "string") {
|
||||||
|
update.state = o.state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return update;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull a human-readable reason out of a failed linkctl run.
|
||||||
|
function errorMessage(stdout, stderr, exitCode) {
|
||||||
|
var err = String(stderr || "").trim();
|
||||||
|
try {
|
||||||
|
var o = JSON.parse(String(stdout || "").trim());
|
||||||
|
if (o && o.error) {
|
||||||
|
return String(o.error);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// not JSON, fall through to stderr
|
||||||
|
}
|
||||||
|
if (err !== "") {
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
return "linkctl exited " + exitCode;
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
/*
|
||||||
|
* Insta360 Link Joystick - a Plasma 6 applet.
|
||||||
|
*
|
||||||
|
* The joystick is a velocity control: displacement sets a rate, and a timer
|
||||||
|
* issues relative pan/tilt steps while the stick is held off-centre.
|
||||||
|
*
|
||||||
|
* It uses relative moves (`linkctl left/right/up/down`) rather than absolute
|
||||||
|
* ones on purpose. `linkctl status` reports no pan/tilt at all while the
|
||||||
|
* camera is idle, so there is no reliable position to drive an absolute
|
||||||
|
* target from, and relative steps cannot drift out of sync with the camera.
|
||||||
|
* linkctl clamps each step to the device's own range, so holding the stick at
|
||||||
|
* a limit is harmless.
|
||||||
|
*/
|
||||||
|
import QtQuick
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import org.kde.plasma.plasmoid
|
||||||
|
import org.kde.plasma.components as PlasmaComponents
|
||||||
|
import org.kde.plasma.plasma5support as Plasma5Support
|
||||||
|
import org.kde.kirigami as Kirigami
|
||||||
|
import "logic.js" as Logic
|
||||||
|
|
||||||
|
PlasmoidItem {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property string linkctl: Plasmoid.configuration.linkctlPath || "linkctl"
|
||||||
|
readonly property string device: Plasmoid.configuration.device
|
||||||
|
readonly property int stepMax: Plasmoid.configuration.maxStep
|
||||||
|
readonly property int tickMs: Plasmoid.configuration.tickInterval
|
||||||
|
readonly property bool invertPan: Plasmoid.configuration.invertPan
|
||||||
|
readonly property bool invertTilt: Plasmoid.configuration.invertTilt
|
||||||
|
|
||||||
|
property real panDeg: NaN
|
||||||
|
property real tiltDeg: NaN
|
||||||
|
property real zoomFactor: 1.0
|
||||||
|
property int trackingState: -1 // -1 unknown, 0 off, 1 on
|
||||||
|
property string statusText: "Not queried yet"
|
||||||
|
property bool busy: false
|
||||||
|
|
||||||
|
function baseCmd() {
|
||||||
|
return Logic.baseCmd(root.linkctl, root.device);
|
||||||
|
}
|
||||||
|
|
||||||
|
function absorb(stdout) {
|
||||||
|
const u = Logic.absorb(stdout);
|
||||||
|
if (u.pan !== undefined) {
|
||||||
|
root.panDeg = u.pan;
|
||||||
|
}
|
||||||
|
if (u.tilt !== undefined) {
|
||||||
|
root.tiltDeg = u.tilt;
|
||||||
|
}
|
||||||
|
if (u.zoom !== undefined) {
|
||||||
|
root.zoomFactor = u.zoom;
|
||||||
|
}
|
||||||
|
if (u.tracking !== undefined) {
|
||||||
|
root.trackingState = u.tracking ? 1 : 0;
|
||||||
|
}
|
||||||
|
if (u.state !== undefined) {
|
||||||
|
root.statusText = u.state === "active" ? "Active" : "Idle";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Plasma5Support.DataSource {
|
||||||
|
id: sh
|
||||||
|
engine: "executable"
|
||||||
|
connectedSources: []
|
||||||
|
|
||||||
|
onNewData: (source, data) => {
|
||||||
|
const code = data["exit code"];
|
||||||
|
const out = data.stdout || "";
|
||||||
|
const err = (data.stderr || "").trim();
|
||||||
|
disconnectSource(source);
|
||||||
|
root.busy = false;
|
||||||
|
if (code === 0) {
|
||||||
|
root.absorb(out);
|
||||||
|
} else {
|
||||||
|
root.statusText = Logic.errorMessage(out, err, code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exec(cmd) {
|
||||||
|
root.busy = true;
|
||||||
|
connectSource(cmd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
sh.exec(root.baseCmd() + " status");
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshTracking() {
|
||||||
|
sh.exec(root.baseCmd() + " tracking status");
|
||||||
|
}
|
||||||
|
|
||||||
|
function action(args) {
|
||||||
|
sh.exec(root.baseCmd() + " " + args);
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: refresh()
|
||||||
|
|
||||||
|
fullRepresentation: ColumnLayout {
|
||||||
|
Layout.minimumWidth: Kirigami.Units.gridUnit * 14
|
||||||
|
Layout.minimumHeight: Kirigami.Units.gridUnit * 22
|
||||||
|
spacing: Kirigami.Units.smallSpacing
|
||||||
|
|
||||||
|
PlasmaComponents.Label {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
text: {
|
||||||
|
if (isNaN(root.panDeg) && isNaN(root.tiltDeg)) {
|
||||||
|
return root.statusText;
|
||||||
|
}
|
||||||
|
const p = isNaN(root.panDeg) ? "?" : root.panDeg.toFixed(1);
|
||||||
|
const t = isNaN(root.tiltDeg) ? "?" : root.tiltDeg.toFixed(1);
|
||||||
|
return "Pan " + p + "° Tilt " + t + "°";
|
||||||
|
}
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
Joystick {
|
||||||
|
id: stick
|
||||||
|
Layout.alignment: Qt.AlignHCenter
|
||||||
|
Layout.preferredWidth: Kirigami.Units.gridUnit * 10
|
||||||
|
Layout.preferredHeight: Layout.preferredWidth
|
||||||
|
deadzone: Plasmoid.configuration.deadzone / 100.0
|
||||||
|
|
||||||
|
// Re-read the true position once motion stops, so the readout is
|
||||||
|
// the camera's own value rather than an accumulation of steps.
|
||||||
|
onReleased: root.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
// Drives the stick. Only runs while it is meaningfully displaced.
|
||||||
|
interval: root.tickMs
|
||||||
|
repeat: true
|
||||||
|
running: stick.engaged
|
||||||
|
triggeredOnStart: true
|
||||||
|
onTriggered: {
|
||||||
|
if (root.busy) {
|
||||||
|
return; // let the previous step finish, skip this one
|
||||||
|
}
|
||||||
|
const cmd = Logic.tickCommand(root.baseCmd(), stick.nx, stick.ny,
|
||||||
|
root.stepMax, root.invertPan,
|
||||||
|
root.invertTilt);
|
||||||
|
if (cmd !== "") {
|
||||||
|
sh.exec(cmd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: Kirigami.Units.smallSpacing
|
||||||
|
|
||||||
|
PlasmaComponents.Label {
|
||||||
|
text: "Zoom"
|
||||||
|
}
|
||||||
|
PlasmaComponents.Slider {
|
||||||
|
id: zoomSlider
|
||||||
|
Layout.fillWidth: true
|
||||||
|
from: 1.0
|
||||||
|
to: 4.0
|
||||||
|
stepSize: 0.1
|
||||||
|
value: root.zoomFactor
|
||||||
|
// Only act when the drag ends: one command per gesture.
|
||||||
|
onPressedChanged: {
|
||||||
|
if (!pressed) {
|
||||||
|
root.action("zoom " + value.toFixed(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PlasmaComponents.Label {
|
||||||
|
text: zoomSlider.value.toFixed(1) + "x"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: Kirigami.Units.smallSpacing
|
||||||
|
|
||||||
|
PlasmaComponents.Button {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
icon.name: "go-home"
|
||||||
|
text: "Home"
|
||||||
|
onClicked: root.action("preset load "
|
||||||
|
+ Logic.shq(Plasmoid.configuration.homePreset))
|
||||||
|
}
|
||||||
|
PlasmaComponents.Button {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
icon.name: "crosshairs"
|
||||||
|
text: "Center"
|
||||||
|
onClicked: root.action("center")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PlasmaComponents.Button {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
icon.name: root.trackingState === 1 ? "view-visible" : "view-hidden"
|
||||||
|
text: root.trackingState === 1 ? "Tracking: on"
|
||||||
|
: root.trackingState === 0 ? "Tracking: off"
|
||||||
|
: "Tracking: unknown"
|
||||||
|
onClicked: root.action("tracking toggle")
|
||||||
|
}
|
||||||
|
|
||||||
|
PlasmaComponents.Button {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
icon.name: "view-refresh"
|
||||||
|
text: "Refresh"
|
||||||
|
onClicked: {
|
||||||
|
root.refresh();
|
||||||
|
root.refreshTracking();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
Layout.fillHeight: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"KPackageStructure": "Plasma/Applet",
|
||||||
|
"KPlugin": {
|
||||||
|
"Authors": [
|
||||||
|
{
|
||||||
|
"Name": "Insta360 Link tools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Category": "Multimedia",
|
||||||
|
"Description": "Virtual joystick and quick controls for an Insta360 Link webcam",
|
||||||
|
"Icon": "camera-web",
|
||||||
|
"Id": "com.github.insta360linktools.joystick",
|
||||||
|
"License": "MIT",
|
||||||
|
"Name": "Insta360 Link Joystick",
|
||||||
|
"Version": "1.0"
|
||||||
|
},
|
||||||
|
"X-Plasma-API-Minimum-Version": "6.0"
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
/*
|
||||||
|
* Tests for the joystick applet's pure helpers.
|
||||||
|
* Run: node tests/test_joystick_logic.js
|
||||||
|
*
|
||||||
|
* logic.js is a QML .js library, so it is loaded by stripping the `.pragma`
|
||||||
|
* line (meaningless to node) and evaluating the rest. That keeps one copy of
|
||||||
|
* the logic: the file under test is the file the applet ships.
|
||||||
|
*/
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const vm = require("vm");
|
||||||
|
|
||||||
|
const src = fs.readFileSync(
|
||||||
|
path.join(__dirname, "../plasmoid/package/contents/ui/logic.js"), "utf8");
|
||||||
|
const ctx = { Math, JSON, String };
|
||||||
|
vm.createContext(ctx);
|
||||||
|
vm.runInContext(src.replace(/^\s*\.pragma\s+library\s*$/m, ""), ctx);
|
||||||
|
|
||||||
|
let checks = 0, failures = 0;
|
||||||
|
function eq(got, want, label) {
|
||||||
|
checks++;
|
||||||
|
if (got !== want) {
|
||||||
|
failures++;
|
||||||
|
console.log(`FAIL ${label}\n got: ${got}\n want: ${want}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function deep(got, want, label) {
|
||||||
|
eq(JSON.stringify(got), JSON.stringify(want), label);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- shq: the shell boundary -------------------------------------------
|
||||||
|
eq(ctx.shq("home"), "'home'", "shq plain");
|
||||||
|
eq(ctx.shq("my desk"), "'my desk'", "shq space");
|
||||||
|
eq(ctx.shq("it's"), "'it'\\''s'", "shq embedded quote");
|
||||||
|
eq(ctx.shq("a; rm -rf /"), "'a; rm -rf /'", "shq neutralises separator");
|
||||||
|
eq(ctx.shq("$(whoami)"), "'$(whoami)'", "shq neutralises substitution");
|
||||||
|
eq(ctx.shq("`id`"), "'`id`'", "shq neutralises backticks");
|
||||||
|
// A preset name chosen to break out of the quoting must stay inert.
|
||||||
|
eq(ctx.shq("x'; touch /tmp/pwned; '"), "'x'\\''; touch /tmp/pwned; '\\'''",
|
||||||
|
"shq contains an escape attempt");
|
||||||
|
|
||||||
|
// --- baseCmd -----------------------------------------------------------
|
||||||
|
eq(ctx.baseCmd("linkctl", ""), "'linkctl' --force --json", "baseCmd plain");
|
||||||
|
eq(ctx.baseCmd("", ""), "'linkctl' --force --json", "baseCmd defaults binary");
|
||||||
|
eq(ctx.baseCmd("/usr/local/bin/linkctl", "/dev/video0"),
|
||||||
|
"'/usr/local/bin/linkctl' --force --json --device '/dev/video0'",
|
||||||
|
"baseCmd with device");
|
||||||
|
|
||||||
|
// --- tickCommand -------------------------------------------------------
|
||||||
|
const B = "'linkctl' --force --json";
|
||||||
|
eq(ctx.tickCommand(B, 0, 0, 6, false, false), "", "centred stick does nothing");
|
||||||
|
eq(ctx.tickCommand(B, 0.01, 0.01, 6, false, false), "",
|
||||||
|
"tiny deflection does nothing");
|
||||||
|
eq(ctx.tickCommand(B, 1, 0, 6, false, false), B + " right 6.0", "full right");
|
||||||
|
eq(ctx.tickCommand(B, -1, 0, 6, false, false), B + " left 6.0", "full left");
|
||||||
|
eq(ctx.tickCommand(B, 0, 1, 6, false, false), B + " up 6.0", "full up");
|
||||||
|
eq(ctx.tickCommand(B, 0, -1, 6, false, false), B + " down 6.0", "full down");
|
||||||
|
eq(ctx.tickCommand(B, 0.5, 0, 6, false, false), B + " right 3.0",
|
||||||
|
"half deflection halves the step");
|
||||||
|
|
||||||
|
// Diagonal: two single-axis calls chained.
|
||||||
|
eq(ctx.tickCommand(B, 1, 1, 6, false, false),
|
||||||
|
B + " right 6.0 ; " + B + " up 6.0", "diagonal chains both axes");
|
||||||
|
|
||||||
|
// A mostly-horizontal push must not creep vertically.
|
||||||
|
eq(ctx.tickCommand(B, 1, 0.05, 6, false, false), B + " right 6.0",
|
||||||
|
"small off-axis component is dropped");
|
||||||
|
|
||||||
|
// Inversion
|
||||||
|
eq(ctx.tickCommand(B, 1, 0, 6, true, false), B + " left 6.0", "invert pan");
|
||||||
|
eq(ctx.tickCommand(B, 0, 1, 6, false, true), B + " down 6.0", "invert tilt");
|
||||||
|
eq(ctx.tickCommand(B, 1, 1, 6, true, true), B + " left 6.0 ; " + B + " down 6.0",
|
||||||
|
"invert both");
|
||||||
|
|
||||||
|
// --- absorb ------------------------------------------------------------
|
||||||
|
deep(ctx.absorb('{"pan":12.3,"tilt":null}'), { pan: 12.3 },
|
||||||
|
"absorb takes pan, ignores null tilt");
|
||||||
|
// The diagonal case: two objects, each carrying one axis.
|
||||||
|
deep(ctx.absorb('{"pan":12.3,"tilt":null}\n{"pan":null,"tilt":-4}'),
|
||||||
|
{ pan: 12.3, tilt: -4 }, "absorb merges both axes across lines");
|
||||||
|
deep(ctx.absorb('{"tracking":true}'), { tracking: true }, "absorb tracking true");
|
||||||
|
deep(ctx.absorb('{"tracking":false}'), { tracking: false },
|
||||||
|
"absorb tracking false is kept, not treated as absent");
|
||||||
|
deep(ctx.absorb('{"state":"inactive","pan":null}'), { state: "inactive" },
|
||||||
|
"absorb state");
|
||||||
|
deep(ctx.absorb('{"pan":0,"tilt":0}'), { pan: 0, tilt: 0 },
|
||||||
|
"absorb keeps zero values");
|
||||||
|
deep(ctx.absorb(""), {}, "absorb empty");
|
||||||
|
deep(ctx.absorb("not json at all"), {}, "absorb ignores non-json");
|
||||||
|
deep(ctx.absorb('{"pan":1}\nbroken {\n{"tilt":2}'), { pan: 1, tilt: 2 },
|
||||||
|
"absorb survives a malformed line between good ones");
|
||||||
|
deep(ctx.absorb('Pan: 12.3°'), {}, "absorb ignores human-readable output");
|
||||||
|
|
||||||
|
// --- errorMessage ------------------------------------------------------
|
||||||
|
eq(ctx.errorMessage('{"error":"camera not found"}', "", 3), "camera not found",
|
||||||
|
"errorMessage prefers the json error");
|
||||||
|
eq(ctx.errorMessage("", "permission denied", 6), "permission denied",
|
||||||
|
"errorMessage falls back to stderr");
|
||||||
|
eq(ctx.errorMessage("", "", 7), "linkctl exited 7",
|
||||||
|
"errorMessage falls back to the exit code");
|
||||||
|
eq(ctx.errorMessage("not json", "stderr text", 8), "stderr text",
|
||||||
|
"errorMessage uses stderr when stdout is not json");
|
||||||
|
|
||||||
|
console.log(`\n${checks} checks, ${failures} failures`);
|
||||||
|
process.exit(failures === 0 ? 0 : 1);
|
||||||
Reference in New Issue
Block a user