aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/Sonix.qml
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-11-07 11:52:57 +0100
committerDanilo M. <danix@danix.xyz>2026-11-07 11:52:57 +0100
commit9559ad71d6f66ccfb681deace80cd17d0029def1 (patch)
tree2ae54ee2361973f434951e1e7a7606008d040fd8 /desktop/Sonix.qml
parenta184ecfa9a38bb5739c5b3bbbd843abfbed0423b (diff)
downloadquickshell-9559ad71d6f66ccfb681deace80cd17d0029def1.tar.gz
quickshell-9559ad71d6f66ccfb681deace80cd17d0029def1.zip
sonix: the player service: beacon presence, polling, commands, upload
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Diffstat (limited to 'desktop/Sonix.qml')
-rw-r--r--desktop/Sonix.qml145
1 files changed, 145 insertions, 0 deletions
diff --git a/desktop/Sonix.qml b/desktop/Sonix.qml
new file mode 100644
index 0000000..f3e0de5
--- /dev/null
+++ b/desktop/Sonix.qml
@@ -0,0 +1,145 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+
+pragma Singleton
+
+import Quickshell
+import Quickshell.Io
+import QtQuick
+import "modules/sonix/Sonix.js" as S
+
+// The HiBy R1 running Sonix Player, over its two HTTP servers: SonixLink
+// (port 7800, state and commands) and Wi-Fi transfer (port 80, only while
+// its page is open on the player; used for the boot logo upload). See
+// modules/sonix/README.md.
+Singleton {
+ id: root
+
+ // Presence comes from the beacon alone: a failed request never clears it.
+ property bool present: false
+ property string host: ""
+ property int port: 7800
+ property string name: ""
+ readonly property string base: `http://${root.host}:${root.port}`
+
+ // The last /api/state answer, and the last request error ("" when fine).
+ property var player: ({})
+ property string error: ""
+
+ // Views that want state (island panel, drawer tile and page). Polled
+ // only while this is above zero.
+ property int holds: 0
+ function hold() { root.holds++; }
+ function release() {
+ root.holds = Math.max(0, root.holds - 1);
+ // The player counts a client as present for 20s after its last
+ // request; saying goodbye ends that at once.
+ if (root.holds === 0) root.request("GET", "/api/bye", null, () => {});
+ }
+
+ Process {
+ id: beacon
+ command: ["/usr/bin/python3", "-u", `${Quickshell.shellDir}/modules/sonix/sonix-beacon.py`]
+ running: true
+ stdout: SplitParser {
+ onRead: line => {
+ const b = S.parseBeacon(line);
+ if (!b) return;
+ root.host = b.host;
+ root.port = b.port;
+ root.name = b.name;
+ if (!root.present) console.log("sonix:", b.name, "at", b.host);
+ root.present = true;
+ lost.restart();
+ }
+ }
+ onExited: code => { console.warn("sonix: beacon relay exited", code); respawn.restart(); }
+ }
+ Timer { id: respawn; interval: 5000; onTriggered: { beacon.running = false; beacon.running = true; } }
+ Timer { id: lost; interval: 6000; onTriggered: { root.present = false; console.log("sonix: gone"); } }
+
+ Timer {
+ interval: 1000
+ repeat: true
+ triggeredOnStart: true
+ running: root.present && root.holds > 0
+ onTriggered: root.refresh()
+ }
+
+ function refresh() {
+ root.request("GET", "/api/state", null, (ok, d, err) => {
+ root.error = ok ? "" : err;
+ if (ok && d && typeof d === "object") root.player = d;
+ });
+ }
+
+ Component { id: timeout; Timer { interval: 3000 } }
+
+ // cb(ok, data, error): data is parsed JSON when the body is JSON.
+ function request(method, path, body, cb) {
+ if (!root.present) { cb(false, null, "not on the network"); return; }
+ const xhr = new XMLHttpRequest();
+ const t = timeout.createObject(root);
+ let done = false;
+ const finish = (ok, data, err) => {
+ if (done) return;
+ done = true;
+ t.destroy();
+ cb(ok, data, err);
+ };
+ t.triggered.connect(() => { xhr.abort(); finish(false, null, "timed out"); });
+ xhr.onreadystatechange = () => {
+ if (xhr.readyState !== XMLHttpRequest.DONE) return;
+ if (xhr.status === 200) {
+ let data = xhr.responseText;
+ try { data = JSON.parse(data); } catch (e) {}
+ finish(true, data, "");
+ } else {
+ finish(false, null, xhr.status ? `${xhr.status}: ${xhr.responseText.trim()}` : "no answer");
+ }
+ };
+ xhr.open(method, root.base + path);
+ if (body !== null) xhr.setRequestHeader("Content-Type", "text/plain");
+ xhr.send(body);
+ t.start();
+ }
+
+ function get(path, cb) { root.request("GET", path, null, cb); }
+
+ // params: { do: ..., ... }. body: track paths, one per line, or null.
+ function command(params, body, cb) {
+ root.request(body === null ? "GET" : "POST", "/api/command?" + S.query(params), body,
+ (ok, d, err) => { if (ok) root.refresh(); if (cb) cb(ok, d, err); });
+ }
+
+ // Upload a file into a card folder through Wi-Fi transfer. cb(code, text):
+ // 0 done, 3 Wi-Fi transfer not answering, 1 anything else (text says why).
+ readonly property string uploadScript: `
+ h=$1 f=$2 d=$3
+ curl -s -m 3 -o /dev/null "http://$h/" || exit 3
+ curl -s -m 10 -o /dev/null --data-urlencode "path=$d" "http://$h/create"
+ code=$(curl -s -m 120 -o /dev/null -w '%{http_code}' -F "path=$d/" -F "files[]=@$f" "http://$h/upload") \
+ || { echo "curl failed"; exit 1; }
+ [ "$code" = 200 ] || { echo "HTTP $code"; exit 1; }
+ `
+ Process {
+ id: up
+ property var cb: null
+ stdout: StdioCollector { id: upOut }
+ onExited: code => { const cb = up.cb; up.cb = null; if (cb) cb(code, upOut.text.trim()); }
+ }
+ function upload(file, cardDir, cb) {
+ if (up.running) { cb(1, "an upload is already running"); return; }
+ up.cb = cb;
+ up.command = ["sh", "-c", root.uploadScript, "sh", root.host, file, cardDir];
+ up.running = false;
+ up.running = true;
+ }
+}