Files
insta360-link-tools/plasmoid/package/contents/ui/logic.js
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

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