diff options
| author | Danilo M. <danix@danix.xyz> | 2026-11-07 11:52:57 +0100 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-11-07 11:52:57 +0100 |
| commit | 9559ad71d6f66ccfb681deace80cd17d0029def1 (patch) | |
| tree | 2ae54ee2361973f434951e1e7a7606008d040fd8 /desktop | |
| parent | a184ecfa9a38bb5739c5b3bbbd843abfbed0423b (diff) | |
| download | quickshell-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')
| -rw-r--r-- | desktop/Sonix.qml | 145 | ||||
| -rw-r--r-- | desktop/shell.qml | 4 |
2 files changed, 149 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; + } +} diff --git a/desktop/shell.qml b/desktop/shell.qml index 44a31ca..d9e5b6c 100644 --- a/desktop/shell.qml +++ b/desktop/shell.qml @@ -66,6 +66,10 @@ ShellRoot { Component.onCompleted: Status.inhibitWindow = keepalive } + // The Sonix player's presence drives an island icon and a drawer tile, + // so its beacon is listened for from startup. See modules/sonix/README.md. + Component.onCompleted: Sonix.present + WallpaperSpread {} Drawer { |
