1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
|
// 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
// GNU General Public License for more details.
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() {
// The player counts a client as present for 20s after its last
// request; saying goodbye ends that at once.
if (root.holds > 0 && --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; root.player = ({}); root.error = ""; 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(() => { finish(false, null, "timed out"); xhr.abort(); });
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"
err=$(mktemp) || exit 1
code=$(curl -sS -m 120 -o /dev/null -w '%{http_code}' --form-string "path=$d/" -F "files[]=@\"$f\"" "http://$h/upload" 2>"$err") \
|| { cat "$err"; rm -f "$err"; exit 1; }
rm -f "$err"
[ "$code" = 200 ] || { echo "HTTP $code"; exit 1; }
`
Process {
id: up
property var cb: null
property int code: -1
property bool streamed: false
stdout: StdioCollector { id: upOut; onStreamFinished: { up.streamed = true; up.deliver(); } }
onExited: code => { up.code = code; up.deliver(); }
function deliver() {
if (up.code < 0 || !up.streamed || !up.cb) return;
const cb = up.cb;
up.cb = null;
cb(up.code, upOut.text.trim());
}
}
function upload(file, cardDir, cb) {
if (/["\\]/.test(file)) { cb(1, "the output folder name contains \" or \\"); return; }
if (up.running) { cb(1, "an upload is already running"); return; }
up.cb = cb;
up.code = -1;
up.streamed = false;
up.command = ["sh", "-c", root.uploadScript, "sh", root.host, file, cardDir];
up.running = false;
up.running = true;
}
}
|