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);