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

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

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

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

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

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

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

544 lines
20 KiB
QML

// SPDX-FileCopyrightText: 2026 branderson
// SPDX-License-Identifier: GPL-3.0-or-later
/*
* 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 configuredBin: Plasmoid.configuration.linkctlPath || "linkctl"
readonly property string device: Plasmoid.configuration.device
// The binary's absolute path, found at startup. Empty until resolved, and
// commands are held back until then: Plasma runs these through a plain
// `sh -c` with plasmashell's environment, which does not include whatever
// a login shell adds to PATH, so a bare name often cannot be run here
// even when it works in a terminal.
// Starts as the configured name so commands always have something to
// run. The probe only upgrades this to an absolute path; it can never
// leave the widget unable to act.
property string resolvedBin: Plasmoid.configuration.linkctlPath || "linkctl"
property bool resolving: false
// Incremented per command so no two command strings are ever identical.
property int seq: 0
readonly property bool ready: root.resolvedBin !== ""
property string lastCmd: ""
property int lastExit: -1
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
// Held movement keys. Separate booleans rather than one object, because
// mutating a member of a `var` object does not re-evaluate bindings.
property bool keyLeft: false
property bool keyRight: false
property bool keyUp: false
property bool keyDown: false
// Keyboard vector. Computed defensively on purpose: the pointer path
// reads inNx/inEngaged too, so anything that throws in here would
// disable dragging as well. Qt caches compiled QML and JS on disk, and a
// stale cached logic.js lacking a newer helper is enough to do it.
readonly property bool keyboardAvailable: typeof Logic !== "undefined"
&& typeof Logic.vectorFromKeys === "function"
readonly property var keyVec: root.keyboardAvailable
? Logic.vectorFromKeys({
left: root.keyLeft, right: root.keyRight,
up: root.keyUp, down: root.keyDown
})
: { nx: 0, ny: 0 }
readonly property real keyNx: (root.keyVec && typeof root.keyVec.nx === "number")
? root.keyVec.nx : 0
readonly property real keyNy: (root.keyVec && typeof root.keyVec.ny === "number")
? root.keyVec.ny : 0
readonly property bool keyEngaged: root.keyNx !== 0 || root.keyNy !== 0
// WASD and the arrow keys. Returns "" for anything that is not a
// movement key, so the handler can leave it unaccepted.
function dirForKey(key) {
switch (key) {
case Qt.Key_A:
case Qt.Key_Left:
return "left";
case Qt.Key_D:
case Qt.Key_Right:
return "right";
case Qt.Key_W:
case Qt.Key_Up:
return "up";
case Qt.Key_S:
case Qt.Key_Down:
return "down";
}
return "";
}
function setKey(dir, down) {
switch (dir) {
case "left": root.keyLeft = down; break;
case "right": root.keyRight = down; break;
case "up": root.keyUp = down; break;
case "down": root.keyDown = down; break;
}
}
function clearKeys() {
root.keyLeft = false;
root.keyRight = false;
root.keyUp = false;
root.keyDown = false;
}
function baseCmd() {
return Logic.baseCmd(root.resolvedBin, root.device);
}
// An absolute path is taken as given; anything else is searched for.
function resolveBinary() {
if (root.configuredBin.charAt(0) === "/") {
root.resolvedBin = root.configuredBin;
root.refresh();
root.refreshTracking();
return;
}
if (typeof Logic.resolveCommand !== "function") {
// Stale cached logic.js: keep the configured name and carry on.
root.refresh();
return;
}
root.statusText = "Looking for linkctl...";
root.resolving = true;
sh.exec(Logic.resolveCommand(root.configuredBin));
}
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;
root.lastExit = code;
console.log("[insta360] exit", code, "out:", String(out).trim(),
"err:", err);
if (root.resolving) {
root.resolving = false;
const found = String(out).trim().split("\n")[0].trim();
if (found !== "") {
root.resolvedBin = found;
root.refresh();
root.refreshTracking();
} else {
// Keep the configured name as the fallback rather than
// blocking: on a packaged install it is on PATH anyway.
root.statusText = "Could not locate linkctl; using '"
+ root.configuredBin + "'.";
root.refresh();
}
return;
}
if (code === 0) {
root.absorb(out);
} else {
root.statusText = Logic.errorMessage(out, err, code);
}
}
function exec(cmd) {
root.busy = true;
root.seq += 1;
// Never reuse a command string: the engine would serve the
// previous run's cached output and not run anything.
const unique = (typeof Logic.uniqueify === "function")
? Logic.uniqueify(cmd, root.seq)
: cmd + " # " + root.seq;
root.lastCmd = unique;
console.log("[insta360] run:", unique);
connectSource(unique);
}
}
Timer {
// Plasma's executable engine silently drops a request when an
// identical command is still running, so `busy` would otherwise be
// able to stick on and freeze every later step.
id: busyWatchdog
interval: 4000
running: root.busy
onTriggered: {
root.busy = false;
root.statusText = "linkctl did not respond in time.";
}
}
function refresh() {
sh.exec(root.baseCmd() + " status");
}
function refreshTracking() {
sh.exec(root.baseCmd() + " tracking status");
}
function action(args) {
sh.exec(root.baseCmd() + " " + args);
}
property bool started: false
Component.onCompleted: {
root.started = true;
root.resolveBinary();
}
// Re-resolve if the configured path is edited. Guarded because this also
// fires while the component is still being set up, which would otherwise
// probe twice and duplicate every follow-up query.
onConfiguredBinChanged: {
if (!root.started) {
return;
}
root.resolvedBin = root.configuredBin;
root.resolveBinary();
}
fullRepresentation: ColumnLayout {
id: panel
// The combined pointer and keyboard vector. These live here, not on
// root, because `stick` is an id declared inside this Component and
// ids are not visible from the enclosing scope. Binding to it from
// root raised "ReferenceError: stick is not defined" and silently
// disabled every form of movement.
//
// The pointer wins while it is down, so a drag is never fought over
// by a key left held down underneath it.
readonly property real inNx: stick.held ? stick.nx : root.keyNx
readonly property real inNy: stick.held ? stick.ny : root.keyNy
readonly property bool inEngaged: stick.engaged || root.keyEngaged
Layout.minimumWidth: Kirigami.Units.gridUnit * 14
Layout.minimumHeight: Kirigami.Units.gridUnit * 22
spacing: Kirigami.Units.smallSpacing
// Keyboard control lives here rather than on the pad so the keys work
// as soon as the popup opens, without having to click the pad first.
focus: true
Component.onCompleted: forceActiveFocus()
Keys.onPressed: (event) => {
// Auto-repeat would otherwise look like a rapid press/release
// cycle and make the motion stutter.
if (event.isAutoRepeat) {
event.accepted = true;
return;
}
const d = root.dirForKey(event.key);
if (d !== "") {
root.setKey(d, true);
event.accepted = true;
}
}
Keys.onReleased: (event) => {
if (event.isAutoRepeat) {
event.accepted = true;
return;
}
const d = root.dirForKey(event.key);
if (d !== "") {
root.setKey(d, false);
event.accepted = true;
}
}
// Losing focus mid-press means the release never arrives, which would
// leave the camera panning on its own.
onActiveFocusChanged: {
if (!activeFocus) {
root.clearKeys();
}
}
PlasmaComponents.Label {
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
text: {
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
}
// Always visible, so a failure part way through a session is not
// hidden behind a stale position readout.
PlasmaComponents.Label {
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
text: root.statusText
opacity: 0.7
font: Kirigami.Theme.smallFont
wrapMode: Text.WordWrap
maximumLineCount: 3
}
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()
}
PlasmaComponents.Label {
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
text: "Drag, or use WASD / arrow keys"
opacity: 0.6
font: Kirigami.Theme.smallFont
}
Timer {
id: moveTimer
// Drives both input methods. Only runs while something is asking
// for movement, so an idle popup costs nothing.
interval: root.tickMs
repeat: true
running: panel.inEngaged
triggeredOnStart: true
onTriggered: {
if (root.busy) {
return; // let the previous step finish, skip this one
}
const cmd = Logic.tickCommand(root.baseCmd(),
panel.inNx, panel.inNy,
root.stepMax, root.invertPan,
root.invertTilt);
if (cmd !== "") {
sh.exec(cmd);
}
}
}
// Refresh the readout once keyboard movement stops, matching what the
// pad does on release.
Connections {
target: root
function onKeyEngagedChanged() {
if (!root.keyEngaged) {
root.refresh();
}
}
// Component.onCompleted can run before the popup is on screen, so
// the focus grab there does not always stick. Claim it again each
// time the popup opens, which is when the keys need to work.
function onExpandedChanged() {
if (root.expanded) {
panel.forceActiveFocus();
} else {
root.clearKeys();
}
}
}
RowLayout {
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
PlasmaComponents.Label {
text: "Zoom"
}
PlasmaComponents.Slider {
id: zoomSlider
Layout.fillWidth: true
// Keep arrow keys with the panel instead of nudging zoom.
focusPolicy: Qt.NoFocus
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 {
focusPolicy: Qt.NoFocus
Layout.fillWidth: true
icon.name: "go-home"
text: "Home"
onClicked: root.action("preset load "
+ Logic.shq(Plasmoid.configuration.homePreset))
}
PlasmaComponents.Button {
focusPolicy: Qt.NoFocus
Layout.fillWidth: true
icon.name: "crosshairs"
text: "Center"
onClicked: root.action("center")
}
}
PlasmaComponents.Button {
focusPolicy: Qt.NoFocus
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 {
focusPolicy: Qt.NoFocus
Layout.fillWidth: true
icon.name: "view-refresh"
text: "Refresh"
onClicked: {
if (!root.ready) {
root.resolveBinary();
} else {
root.refresh();
root.refreshTracking();
}
}
}
PlasmaComponents.Button {
id: diagToggle
Layout.fillWidth: true
focusPolicy: Qt.NoFocus
checkable: true
icon.name: "help-about"
text: "Diagnostics"
}
// Live internal state. Exists because every failure so far has
// presented as "nothing happens", which is the one symptom that
// carries no information.
//
// Deliberately one label built by one guarded function rather than a
// Repeater over an array of expressions: in that shape a single
// throwing sub-expression discards the whole model and the panel
// renders empty, which is the failure mode it is meant to diagnose.
PlasmaComponents.Label {
Layout.fillWidth: true
visible: diagToggle.checked
font: Kirigami.Theme.smallFont
opacity: 0.85
wrapMode: Text.WrapAnywhere
textFormat: Text.PlainText
function line(label, value) {
try {
return label + ": " + value + "\n";
} catch (e) {
return label + ": <error: " + e + ">\n";
}
}
text: {
let out = "";
try {
out += line("binary", root.resolvedBin);
out += line("configured", root.configuredBin);
out += line("device",
root.device === "" ? "(auto)" : root.device);
out += line("helpers",
"vectorFromKeys=" + root.keyboardAvailable
+ " uniqueify="
+ (typeof Logic !== "undefined"
&& typeof Logic.uniqueify === "function")
+ " resolveCommand="
+ (typeof Logic !== "undefined"
&& typeof Logic.resolveCommand === "function"));
out += line("stick", "held=" + stick.held
+ " nx=" + stick.nx.toFixed(2)
+ " ny=" + stick.ny.toFixed(2)
+ " engaged=" + stick.engaged);
out += line("keys", "nx=" + root.keyNx.toFixed(2)
+ " ny=" + root.keyNy.toFixed(2)
+ " focus=" + panel.activeFocus);
out += line("input", "engaged=" + panel.inEngaged
+ " timer=" + moveTimer.running);
out += line("busy", root.busy + " seq=" + root.seq
+ " lastExit=" + root.lastExit);
out += line("lastCmd", root.lastCmd);
} catch (e) {
out += "diagnostics failed: " + e;
}
return out;
}
}
Item {
Layout.fillHeight: true
}
}
}