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.
212 lines
6.9 KiB
JavaScript
212 lines
6.9 KiB
JavaScript
// SPDX-FileCopyrightText: 2026 branderson
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
/*
|
|
* 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;
|
|
}
|
|
|
|
/*
|
|
* Make a command string unique by appending a shell comment.
|
|
*
|
|
* Plasma's executable data engine keys its containers by the command string.
|
|
* DataEngine::connectSource hands back a pre-existing container and delivers
|
|
* its *cached* data rather than running the command again; the process only
|
|
* starts when the container is first created. So re-issuing an identical
|
|
* command can report success without the camera moving at all, which is
|
|
* exactly what a stick held at full deflection produces every tick.
|
|
*
|
|
* `#` starts a comment in sh, so the trailing counter changes the key the
|
|
* engine sees without changing what runs.
|
|
*/
|
|
function uniqueify(cmd, seq) {
|
|
return cmd + " # " + seq;
|
|
}
|
|
|
|
// Directories searched for the binary, in order. Mirrors the resolver in
|
|
// bin/linkctl-macro. $HOME is left for the shell to expand.
|
|
var SEARCH_DIRS = [
|
|
"/usr/local/bin",
|
|
"/usr/bin",
|
|
"\"$HOME/.local/bin\"",
|
|
"\"$HOME/.cargo/bin\"",
|
|
"\"$HOME/.local/share/mise/shims\"",
|
|
"\"$HOME/.asdf/shims\"",
|
|
"/var/lib/flatpak/exports/bin"
|
|
];
|
|
|
|
/*
|
|
* Build a shell command that prints the absolute path of the binary.
|
|
*
|
|
* Plasma runs applet commands through KProcess::setShellCommand, which is
|
|
* `/bin/sh -c` inheriting plasmashell's environment. That environment never
|
|
* sources a login profile, so anything that puts linkctl on PATH from
|
|
* ~/.bashrc or similar (mise and asdf shims, ~/.local/bin on some distros) is
|
|
* invisible to the applet even though it works in a terminal. Searching
|
|
* explicitly is the only reliable way to find it.
|
|
*/
|
|
function resolveCommand(name) {
|
|
return "n=" + shq(name || "linkctl") + "; "
|
|
+ "command -v \"$n\" 2>/dev/null || "
|
|
+ "{ for d in " + SEARCH_DIRS.join(" ") + "; do "
|
|
+ "[ -x \"$d/$n\" ] && printf '%s\\n' \"$d/$n\" && break; "
|
|
+ "done; }";
|
|
}
|
|
|
|
/*
|
|
* 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(" ; ");
|
|
}
|
|
|
|
/*
|
|
* Turn a set of held direction keys into a stick vector.
|
|
*
|
|
* `held` has boolean left/right/up/down members. Opposing keys cancel, and a
|
|
* diagonal is normalised to length 1 so holding two keys does not move the
|
|
* camera faster than holding one.
|
|
*
|
|
* Key codes are deliberately not mapped here: this file is a `.pragma
|
|
* library` and so cannot reach the Qt namespace for Qt.Key_*. The QML side
|
|
* resolves a key code to a direction name and passes the result in.
|
|
*/
|
|
function vectorFromKeys(held) {
|
|
var x = 0;
|
|
var y = 0;
|
|
if (held.left) {
|
|
x -= 1;
|
|
}
|
|
if (held.right) {
|
|
x += 1;
|
|
}
|
|
if (held.up) {
|
|
y += 1;
|
|
}
|
|
if (held.down) {
|
|
y -= 1;
|
|
}
|
|
var len = Math.sqrt(x * x + y * y);
|
|
if (len > 1) {
|
|
x /= len;
|
|
y /= len;
|
|
}
|
|
return { nx: x, ny: y };
|
|
}
|
|
|
|
/*
|
|
* 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
|
|
}
|
|
// 127 is the shell's "command not found", which here means the resolved
|
|
// path has stopped being valid rather than anything about the camera.
|
|
if (exitCode === 127) {
|
|
return "linkctl could not be run. Set its full path in settings.";
|
|
}
|
|
if (err !== "") {
|
|
return err;
|
|
}
|
|
// Exit codes that usually mean a pinned device node is stale. The Link's
|
|
// /dev/videoN numbering moves between boots and replugs.
|
|
if (exitCode === 3) {
|
|
return "Camera not found. If a device is set in settings, clear it: "
|
|
+ "the node number changes between boots.";
|
|
}
|
|
if (exitCode === 4) {
|
|
return "Several cameras found. Set the device in settings.";
|
|
}
|
|
if (exitCode === 6) {
|
|
return "Permission denied. Add yourself to the 'video' group.";
|
|
}
|
|
return "linkctl exited " + exitCode;
|
|
}
|