From bf6595614a77399c2eb1a63fc9b2d723f551389f Mon Sep 17 00:00:00 2001 From: brad Date: Wed, 7 Oct 2026 21:03:38 -0700 Subject: [PATCH] Fix KDE shortcuts never binding, and add a joystick widget The shortcuts wrote the Plasma 5 value format and so never bound anything, which is what produced "No default shortcuts" in System Settings. kglobalacceld's KServiceActionComponent::loadSettings reads the services _launch key as a bare key sequence and hands it straight to QKeySequence::fromString. Plasma 5 stored a "keys,default,name" triplet there and Plasma 6 does not, so "Meta+Alt+T,none,Insta360: Toggle AI tracking" parsed to an empty sequence and bound no key. The value is now just the key sequence; alternates would be tab-separated and "none" means unbound. Two supporting changes. The desktop entries now carry X-KDE-Shortcuts, which is where kglobalacceld reads an entry's default shortcut from, so the binding shows up in System Settings as a default instead of as nothing. And they are no longer NoDisplay by default: KApplicationTrader skips NoDisplay services, so hiding them from the launcher also hid them from the Shortcuts page, which is the opposite of helpful. HIDE_FROM_MENU=1 restores the old behaviour. --list is now a diagnostic rather than a bare dump: it reports the registered key, the default from the desktop entry, whether the entry file exists, and flags any comma-bearing value as the unparseable Plasma 5 form. It would have identified this fault directly. The new plasmoid is a Plasma 6 applet: a velocity-style virtual joystick plus zoom slider and Home/Center/Tracking buttons. It drives the gimbal with relative moves, not absolute ones, because linkctl status reports no pan or tilt while the camera is idle, leaving no trustworthy position to aim from; relative steps work in either state and cannot drift, and linkctl clamps them to the device range. The pure logic lives in a .js library so it is testable outside Plasma. Tested: 36 new node checks over shell quoting, tick command construction, inversion, the per-axis threshold and the multi-object JSON merge, each confirmed meaningful by mutation. Reproduced the broken shortcut state in a throwaway HOME and verified --list flags it and reinstalling repairs it. All QML passes qmllint with no syntax errors (KDE modules are unresolvable offline). shellcheck clean. The applet has not been loaded in a real Plasma session. --- README.md | 51 +++- kde/install-shortcuts.sh | 54 ++++- plasmoid/install.sh | 86 +++++++ plasmoid/package/contents/config/config.qml | 10 + plasmoid/package/contents/config/main.xml | 41 ++++ plasmoid/package/contents/ui/Joystick.qml | 140 +++++++++++ .../package/contents/ui/configGeneral.qml | 73 ++++++ plasmoid/package/contents/ui/logic.js | 110 +++++++++ plasmoid/package/contents/ui/main.qml | 218 ++++++++++++++++++ plasmoid/package/metadata.json | 18 ++ tests/test_joystick_logic.js | 105 +++++++++ 11 files changed, 887 insertions(+), 19 deletions(-) create mode 100755 plasmoid/install.sh create mode 100644 plasmoid/package/contents/config/config.qml create mode 100644 plasmoid/package/contents/config/main.xml create mode 100644 plasmoid/package/contents/ui/Joystick.qml create mode 100644 plasmoid/package/contents/ui/configGeneral.qml create mode 100644 plasmoid/package/contents/ui/logic.js create mode 100644 plasmoid/package/contents/ui/main.qml create mode 100644 plasmoid/package/metadata.json create mode 100644 tests/test_joystick_logic.js diff --git a/README.md b/README.md index 7caaa91..e831c5d 100644 --- a/README.md +++ b/README.md @@ -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. | | `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. | +| `plasmoid/` | A Plasma 6 widget with a virtual joystick, zoom slider, and Home/Center/Tracking buttons. | ## 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+C` | recenter gimbal | -Check them afterwards with `./kde/install-shortcuts.sh --list`, or in System -Settings under Keyboard, Shortcuts, searching for "Insta360". Reverse the whole -thing with `--uninstall`. +Check them afterwards with `./kde/install-shortcuts.sh --list`, which shows the +registered key, the default from the desktop entry, and flags any value Plasma +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 @@ -88,9 +116,11 @@ case where the gimbal is parked facing down. ```sh 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 +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 ./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 @@ -99,10 +129,17 @@ captured too early) without needing OBS or a camera. ## 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 -that works but the key does not, re-run the installer with `SHOW_IN_MENU=1`; -some Plasma builds only resolve launcher-visible application entries. +that works but the key does not, check for a conflict in System Settings, and +confirm `--list` shows the entry as `present`. **`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 diff --git a/kde/install-shortcuts.sh b/kde/install-shortcuts.sh index 59d7f8a..0e8972a 100755 --- a/kde/install-shortcuts.sh +++ b/kde/install-shortcuts.sh @@ -31,9 +31,10 @@ BINDINGS=( "center|Meta+Alt+C|center|Insta360: Recenter gimbal" ) -# Set to 1 if the shortcuts fail to register; some Plasma builds only resolve -# application entries that are visible in the launcher. -SHOW_IN_MENU=${SHOW_IN_MENU:-0} +# The entries are visible by default: KApplicationTrader skips NoDisplay +# services, so hiding them also hides them from the Shortcuts page in System +# 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 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"; } write_desktop() { - local id=$1 args=$2 label=$3 path + local id=$1 args=$2 label=$3 keys=$4 path path=$(desktop_file "$id") if (( DRY_RUN )); then printf ' would write %s\n' "$path" printf ' Exec=%s %s\n' "$MACRO" "$args" + printf ' X-KDE-Shortcuts=%s\n' "$keys" return fi - local nodisplay=true - (( SHOW_IN_MENU )) && nodisplay=false + local 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" < linkctl $args" done @@ -214,15 +226,33 @@ do_uninstall() { } do_list() { - local entry id keys args label value - printf '%-22s %-14s %s\n' ACTION KEY REGISTERED + local entry id keys args label value path dflt problems=0 + printf '%-20s %-12s %-14s %-12s %s\n' ACTION KEY _launch DEFAULT ENTRY for entry in "${BINDINGS[@]}"; do IFS='|' read -r id keys args label <<< "$entry" value=$("$KREAD" --file kglobalshortcutsrc \ --group services --group "$(desktop_name "$id")" \ --key _launch 2>/dev/null || true) - printf '%-22s %-14s %s\n' "$id" "$keys" "${value:-}" + 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:-}" "${dflt:-}" \ + "$([[ -r $path ]] && echo present || echo MISSING)" 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 diff --git a/plasmoid/install.sh b/plasmoid/install.sh new file mode 100755 index 0000000..5550f01 --- /dev/null +++ b/plasmoid/install.sh @@ -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 diff --git a/plasmoid/package/contents/config/config.qml b/plasmoid/package/contents/config/config.qml new file mode 100644 index 0000000..ca61180 --- /dev/null +++ b/plasmoid/package/contents/config/config.qml @@ -0,0 +1,10 @@ +import QtQuick +import org.kde.plasma.configuration + +ConfigModel { + ConfigCategory { + name: "General" + icon: "camera-web" + source: "configGeneral.qml" + } +} diff --git a/plasmoid/package/contents/config/main.xml b/plasmoid/package/contents/config/main.xml new file mode 100644 index 0000000..aad1f9e --- /dev/null +++ b/plasmoid/package/contents/config/main.xml @@ -0,0 +1,41 @@ + + + + + + + linkctl + + + + + + + + home + + + + 6 + + + + 160 + + + + 15 + + + + false + + + + false + + + diff --git a/plasmoid/package/contents/ui/Joystick.qml b/plasmoid/package/contents/ui/Joystick.qml new file mode 100644 index 0000000..8a527db --- /dev/null +++ b/plasmoid/package/contents/ui/Joystick.qml @@ -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(); + } + } +} diff --git a/plasmoid/package/contents/ui/configGeneral.qml b/plasmoid/package/contents/ui/configGeneral.qml new file mode 100644 index 0000000..0ec088d --- /dev/null +++ b/plasmoid/package/contents/ui/configGeneral.qml @@ -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" + } +} diff --git a/plasmoid/package/contents/ui/logic.js b/plasmoid/package/contents/ui/logic.js new file mode 100644 index 0000000..03bda3c --- /dev/null +++ b/plasmoid/package/contents/ui/logic.js @@ -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; +} diff --git a/plasmoid/package/contents/ui/main.qml b/plasmoid/package/contents/ui/main.qml new file mode 100644 index 0000000..98d65ae --- /dev/null +++ b/plasmoid/package/contents/ui/main.qml @@ -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 + } + } +} diff --git a/plasmoid/package/metadata.json b/plasmoid/package/metadata.json new file mode 100644 index 0000000..e535b85 --- /dev/null +++ b/plasmoid/package/metadata.json @@ -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" +} diff --git a/tests/test_joystick_logic.js b/tests/test_joystick_logic.js new file mode 100644 index 0000000..1464afe --- /dev/null +++ b/tests/test_joystick_logic.js @@ -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); -- 2.54.0