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.
544 lines
20 KiB
QML
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
|
|
}
|
|
}
|
|
}
|