@khalido/home 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -1
- package/README.md +11 -11
- package/dist/config.js +3 -1
- package/dist/connectors/cast/index.d.ts +4 -1
- package/dist/connectors/cast/index.js +10 -2
- package/dist/connectors/cast/schemas.d.ts +2 -0
- package/dist/connectors/cast/schemas.js +1 -0
- package/dist/connectors/cast/serve.d.ts +8 -1
- package/dist/connectors/cast/state.d.ts +4 -0
- package/dist/connectors/cast/state.js +12 -0
- package/dist/connectors/cast/tls.js +1 -1
- package/dist/connectors/cast/writes.d.ts +9 -0
- package/dist/connectors/cast/writes.js +42 -6
- package/dist/connectors/spotify/changes.d.ts +1 -1
- package/dist/connectors/spotify/changes.js +4 -2
- package/dist/connectors/spotify/client.d.ts +1 -1
- package/dist/connectors/spotify/index.d.ts +3 -1
- package/dist/connectors/spotify/index.js +114 -45
- package/dist/connectors/up/index.d.ts +4 -2
- package/dist/connectors/up/index.js +2 -1
- package/dist/connectors/weather/index.js +16 -3
- package/dist/discover.d.ts +5 -1
- package/dist/discover.js +12 -3
- package/dist/energy.js +1 -1
- package/dist/finders.d.ts +3 -0
- package/dist/finders.js +6 -3
- package/dist/lane.js +24 -20
- package/docs/cast.md +1 -1
- package/docs/cli.md +5 -3
- package/docs/config.md +2 -2
- package/docs/decisions.md +1 -0
- package/docs/google.md +1 -1
- package/package.json +2 -2
- package/skills/draw-home/README.md +5 -6
- package/skills/draw-home/SKILL.md +3 -5
- package/src/cli/cast.ts +19 -11
- package/src/cli/discover.ts +8 -1
- package/src/cli/spotify.ts +2 -1
- package/src/config.ts +3 -1
- package/src/connectors/cast/README.md +7 -5
- package/src/connectors/cast/index.ts +19 -5
- package/src/connectors/cast/schemas.ts +1 -0
- package/src/connectors/cast/serve.ts +11 -1
- package/src/connectors/cast/state.ts +12 -0
- package/src/connectors/cast/tls.ts +1 -1
- package/src/connectors/cast/writes.ts +58 -9
- package/src/connectors/daikin/README.md +1 -1
- package/src/connectors/spotify/README.md +23 -8
- package/src/connectors/spotify/changes.ts +4 -2
- package/src/connectors/spotify/client.ts +1 -1
- package/src/connectors/spotify/index.ts +120 -48
- package/src/connectors/up/README.md +1 -1
- package/src/connectors/up/index.ts +5 -2
- package/src/connectors/weather/README.md +1 -1
- package/src/connectors/weather/index.ts +19 -3
- package/src/discover.ts +14 -6
- package/src/energy.ts +1 -1
- package/src/finders.ts +8 -5
- package/src/lane.ts +23 -20
|
@@ -33,10 +33,12 @@ const COMMAND_TIMEOUT_MS = 3000;
|
|
|
33
33
|
const QUERY_TIMEOUT_MS = 1000;
|
|
34
34
|
/** A socket that has said nothing this long after opening is closed: Soloist sends `auth_state` at once on connect. */
|
|
35
35
|
const HELLO_MS = 10_000;
|
|
36
|
-
/** After
|
|
36
|
+
/** After a start signal, a volume above the cap is lowered for this long: the volume can come before or after the status (issue #23). */
|
|
37
37
|
const CAP_WINDOW_MS = 10_000;
|
|
38
38
|
/** How long a write waits for Soloist to report the change it asked for. Loopback events arrive in milliseconds; a stated guess. */
|
|
39
39
|
const CONFIRM_MS = 2000;
|
|
40
|
+
/** Between the two halves of `setRepeat`: back to back left repeat on `track`, 600 ms apart worked (live, 2026-11-09). */
|
|
41
|
+
const REPEAT_GAP_MS = 600;
|
|
40
42
|
/** Between two commands. Soloist publishes no limit; it is on loopback; a stated guess. */
|
|
41
43
|
const WRITE_GAP_MS = 100;
|
|
42
44
|
/** While playing, a stall check this often: one `get_state`. Soloist issue #5 reports stalls 71–150 s into a track; a stated guess. */
|
|
@@ -45,6 +47,8 @@ const STALL_CHECK_MS = 20_000;
|
|
|
45
47
|
const OVERRUN_MS = 15_000;
|
|
46
48
|
/** After a repair's seek, how long before looking whether the position moves again. */
|
|
47
49
|
const REPAIR_WAIT_MS = 5000;
|
|
50
|
+
/** A reported volume is rounded (phone steps are sixteenths: 43.75 shows as 44), so "it took" allows this much. */
|
|
51
|
+
const VOLUME_SLACK = 1;
|
|
48
52
|
const MIN_BACKOFF_MS = 1000;
|
|
49
53
|
const MAX_BACKOFF_MS = 60_000;
|
|
50
54
|
/** Signed out is Soloist's normal state until someone picks the speaker (seen live 2026-11-08); a bad key looks the same (issue #18). */
|
|
@@ -78,15 +82,17 @@ export function spotify(config = {}) {
|
|
|
78
82
|
const checkMs = config.stallCheckMs ?? STALL_CHECK_MS;
|
|
79
83
|
let state = { asOf: asOf(clock) };
|
|
80
84
|
let socket;
|
|
81
|
-
|
|
82
|
-
let
|
|
83
|
-
|
|
85
|
+
// This socket's progress. "heard": signed in, the first playback_state still to come. "whole": a one-shot read sees the track (or HELLO_MS passed).
|
|
86
|
+
let link = "down";
|
|
87
|
+
const isOpen = () => link !== "down";
|
|
84
88
|
let down; // why the last socket ended, while there is none open
|
|
85
89
|
let statusKnown = false; // Soloist has told this socket its status: only then is idle → playing an edge
|
|
86
90
|
let queueAsked = false; // get_queue sent on this socket
|
|
87
91
|
let signedIn = false; // signed out → in on this socket: the first status after it counts as a start if it is playing
|
|
88
92
|
let capUntil = -Infinity;
|
|
89
|
-
let capped = false; // the cap
|
|
93
|
+
let capped = false; // the cap took in this window
|
|
94
|
+
let capping = false; // a lowering is on its way
|
|
95
|
+
let startBuffering = false; // a start from idle is still buffering
|
|
90
96
|
let capNote; // the last first-play lowering, in `anomaly` until the speaker goes idle
|
|
91
97
|
let frameNote; // a frame the schema rejected, or an error nothing was waiting for
|
|
92
98
|
let note; // a write the integration made itself that failed (the cap, interrupt's resume), in `detail`
|
|
@@ -98,10 +104,10 @@ export function spotify(config = {}) {
|
|
|
98
104
|
const publish = () => {
|
|
99
105
|
let status;
|
|
100
106
|
let detail;
|
|
101
|
-
if (
|
|
107
|
+
if (link === "down")
|
|
102
108
|
((status = reported || down ? "disconnected" : "connecting"),
|
|
103
109
|
(detail = down));
|
|
104
|
-
else if (
|
|
110
|
+
else if (link !== "whole")
|
|
105
111
|
status = "connecting";
|
|
106
112
|
else
|
|
107
113
|
((status = "connected"),
|
|
@@ -127,7 +133,7 @@ export function spotify(config = {}) {
|
|
|
127
133
|
*/
|
|
128
134
|
const command = (name, fields = {}, answers) => new Promise((resolve, reject) => {
|
|
129
135
|
const s = socket;
|
|
130
|
-
if (!s || !
|
|
136
|
+
if (!s || !isOpen() || s.readyState !== OPEN)
|
|
131
137
|
return reject(offline());
|
|
132
138
|
if (state.loggedIn === false && !answers)
|
|
133
139
|
return reject(new ConnectorError(WAITING, { code: "waiting" }));
|
|
@@ -156,11 +162,17 @@ export function spotify(config = {}) {
|
|
|
156
162
|
p.finish(new ConnectorError(`Soloist did not answer ${name} in ${ms / 1000} s`, { code: "timeout" })));
|
|
157
163
|
});
|
|
158
164
|
const send = async (body) => {
|
|
165
|
+
if (body.activeFirst && inactive()) {
|
|
166
|
+
await command("activate").catch(() => { }); // best effort: the write itself says how it went
|
|
167
|
+
await waitFor(() => state.active === true || !isOpen(), CONFIRM_MS);
|
|
168
|
+
}
|
|
159
169
|
const before = state;
|
|
160
170
|
const at = clock.now();
|
|
161
171
|
await command(body.command, body.fields, body.answers);
|
|
162
|
-
if (body.then)
|
|
172
|
+
if (body.then) {
|
|
173
|
+
await clock.sleep(REPEAT_GAP_MS);
|
|
163
174
|
await command(body.then.command, body.then.fields); // repeat's second half, still this write's turn
|
|
175
|
+
}
|
|
164
176
|
return { before, at };
|
|
165
177
|
};
|
|
166
178
|
let once = 0; // skips and queued tracks never merge: two nexts are two skips
|
|
@@ -173,7 +185,7 @@ export function spotify(config = {}) {
|
|
|
173
185
|
const change = async (slot, body) => {
|
|
174
186
|
const sent = await write(slot, body);
|
|
175
187
|
const took = () => body.took?.(state, sent.before, sent.at) ?? true;
|
|
176
|
-
await waitFor(() => took() || !
|
|
188
|
+
await waitFor(() => took() || !isOpen(), CONFIRM_MS);
|
|
177
189
|
return { state, confirmed: took() };
|
|
178
190
|
};
|
|
179
191
|
/** The track restarted: a new anchor near 0 when it had been 5 s or more in (a prev past the first seconds, a next on repeat-track). */
|
|
@@ -182,11 +194,14 @@ export function spotify(config = {}) {
|
|
|
182
194
|
(positionMs(b, at) ?? 0) >= 5000;
|
|
183
195
|
const newTrack = (s, b) => !!s.track && s.track.uri !== b.track?.uri;
|
|
184
196
|
const invalid = (message) => new ConnectorError(message, { code: "invalid" });
|
|
197
|
+
const near = (volume, v) => volume !== undefined && Math.abs(volume - v) <= VOLUME_SLACK;
|
|
185
198
|
const volumeTo = (v) => change("volume", {
|
|
186
199
|
command: "set_volume",
|
|
187
200
|
fields: { volume: v },
|
|
188
|
-
took: (s) => s.volume
|
|
201
|
+
took: (s) => near(s.volume, v),
|
|
189
202
|
});
|
|
203
|
+
/** Soloist accepts a `set_volume` while it isn't the active device and drops it (three times live, 2026-11-09). */
|
|
204
|
+
const inactive = () => isOpen() && state.loggedIn !== false && state.active === false;
|
|
190
205
|
/** Ask Soloist for something, through the lane; the same query twice merges. A refusal is an anomaly, a drop is the status's to say. */
|
|
191
206
|
const ANSWERS = {
|
|
192
207
|
get_state: "playback_state",
|
|
@@ -206,22 +221,48 @@ export function spotify(config = {}) {
|
|
|
206
221
|
frameNote = `${name} failed: ${errorText(error)}`;
|
|
207
222
|
publish();
|
|
208
223
|
});
|
|
209
|
-
/**
|
|
224
|
+
/**
|
|
225
|
+
* Within the window after a start signal, a volume above the cap is lowered to it (issue #23). Only while active, since
|
|
226
|
+
* Soloist drops it otherwise, and retried until Soloist reports it. A volume raised after that is someone's choice;
|
|
227
|
+
* one raised before it is reported can't be told from the start's own jump, so it is lowered too.
|
|
228
|
+
*/
|
|
210
229
|
const applyCap = () => {
|
|
211
230
|
if (cap === undefined ||
|
|
212
231
|
capped ||
|
|
232
|
+
capping ||
|
|
213
233
|
state.volume === undefined ||
|
|
214
|
-
|
|
215
|
-
state.
|
|
234
|
+
state.volume <= cap + VOLUME_SLACK ||
|
|
235
|
+
state.active === false ||
|
|
236
|
+
clock.now() > capUntil)
|
|
216
237
|
return;
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
238
|
+
capping = true;
|
|
239
|
+
const from = state.volume;
|
|
240
|
+
const window = capUntil;
|
|
241
|
+
volumeTo(cap).then(({ confirmed }) => {
|
|
242
|
+
capping = false;
|
|
243
|
+
if (capUntil !== window)
|
|
244
|
+
return applyCap(); // a new start while it went: this lowering doesn't speak for that one
|
|
245
|
+
if (confirmed) {
|
|
246
|
+
capped = true;
|
|
247
|
+
if (state.status === "idle")
|
|
248
|
+
return;
|
|
249
|
+
capNote = `started at volume ${from}, above maxVolume ${cap}; lowered it (Soloist issue #23)`;
|
|
250
|
+
note = undefined;
|
|
251
|
+
return publish();
|
|
252
|
+
}
|
|
253
|
+
if (clock.now() > capUntil) {
|
|
254
|
+
note = `could not lower the volume to ${cap}: Soloist has not reported it`;
|
|
255
|
+
return publish();
|
|
256
|
+
}
|
|
257
|
+
applyCap();
|
|
258
|
+
}, (error) => {
|
|
259
|
+
capping = false;
|
|
260
|
+
if (error instanceof ConnectorError && error.code === "offline")
|
|
261
|
+
return; // the status says it
|
|
262
|
+
capped = true; // refused or unanswered: not again this window
|
|
263
|
+
note = `lowering the volume to ${cap} failed: ${errorText(error)}`;
|
|
264
|
+
publish();
|
|
265
|
+
});
|
|
225
266
|
};
|
|
226
267
|
const handle = (data) => {
|
|
227
268
|
let raw;
|
|
@@ -253,12 +294,17 @@ export function spotify(config = {}) {
|
|
|
253
294
|
}
|
|
254
295
|
const before = state.status;
|
|
255
296
|
const wasSignedIn = state.loggedIn;
|
|
297
|
+
const wasActive = link === "open" ? undefined : state.active; // the first frames on a socket are not a change
|
|
256
298
|
state = fold(state, f, asOf(clock));
|
|
299
|
+
if (state.track?.uri !== repairedFor)
|
|
300
|
+
repairedFor = undefined; // one repair per play of a track
|
|
301
|
+
// Going active makes a new stream, at WirePlumber's saved level (live, 2026-11-09): the likeliest moment for a loud start.
|
|
302
|
+
if (wasActive === false && state.active === true)
|
|
303
|
+
((capUntil = clock.now() + CAP_WINDOW_MS), (capped = false));
|
|
257
304
|
if (f.type === "auth_state") {
|
|
258
305
|
// Signed out there is no playback_state to wait for, and a later sign-in must not send the part back to connecting.
|
|
259
|
-
if (
|
|
260
|
-
|
|
261
|
-
heard = true;
|
|
306
|
+
if (link === "open" || (link === "heard" && f.logged_in === false))
|
|
307
|
+
link = f.logged_in === true ? "heard" : "whole";
|
|
262
308
|
if (f.logged_in === false)
|
|
263
309
|
statusKnown = false;
|
|
264
310
|
// A first sign-in (someone picked the speaker) sends only auth_state; the docs don't promise a playback_state after it.
|
|
@@ -268,16 +314,20 @@ export function spotify(config = {}) {
|
|
|
268
314
|
if (f.type === "playback_state") {
|
|
269
315
|
if (!queueAsked)
|
|
270
316
|
((queueAsked = true), query("get_queue", { limit: 10 })); // queue_changed pushes changes only, so ask once per socket
|
|
271
|
-
|
|
317
|
+
link = "whole";
|
|
272
318
|
}
|
|
273
319
|
if (pending?.answers === f.type)
|
|
274
320
|
pending.finish();
|
|
275
321
|
if ((f.type === "playback_state" || f.type === "playback_changed") &&
|
|
276
322
|
f.status !== undefined) {
|
|
277
|
-
// A start is idle → playing, or a sign-in whose first status is
|
|
278
|
-
|
|
323
|
+
// A start is idle → playing or buffering, or a sign-in whose first status is either: a phone picked the speaker and played.
|
|
324
|
+
// The volume can land just after `playing` (issue #23), so a start through buffering opens the window again then.
|
|
325
|
+
const started = ((f.status === "playing" || f.status === "buffering") &&
|
|
326
|
+
(statusKnown ? before === "idle" : signedIn)) ||
|
|
327
|
+
(f.status === "playing" && startBuffering);
|
|
279
328
|
if (started)
|
|
280
329
|
((capUntil = clock.now() + CAP_WINDOW_MS), (capped = false));
|
|
330
|
+
startBuffering = f.status === "buffering" && (started || startBuffering);
|
|
281
331
|
signedIn = false;
|
|
282
332
|
if (f.status === "idle")
|
|
283
333
|
capNote = undefined;
|
|
@@ -314,13 +364,12 @@ export function spotify(config = {}) {
|
|
|
314
364
|
signal.removeEventListener("abort", stop);
|
|
315
365
|
if (socket === s)
|
|
316
366
|
socket = undefined;
|
|
317
|
-
const was =
|
|
318
|
-
|
|
319
|
-
heard = false;
|
|
320
|
-
whole = false;
|
|
367
|
+
const was = isOpen();
|
|
368
|
+
link = "down";
|
|
321
369
|
queueAsked = false;
|
|
322
370
|
statusKnown = false;
|
|
323
371
|
signedIn = false;
|
|
372
|
+
startBuffering = false;
|
|
324
373
|
pending?.finish(offline());
|
|
325
374
|
for (const w of [...waiters])
|
|
326
375
|
w(); // a write waiting to confirm stops waiting
|
|
@@ -329,21 +378,24 @@ export function spotify(config = {}) {
|
|
|
329
378
|
};
|
|
330
379
|
const stop = () => (end("stopped"), s.close());
|
|
331
380
|
signal.addEventListener("abort", stop, { once: true });
|
|
381
|
+
// An ended socket's late events belong to no session: they must not touch the next socket's state.
|
|
332
382
|
s.onopen = () => {
|
|
333
|
-
|
|
383
|
+
if (ended)
|
|
384
|
+
return;
|
|
385
|
+
link = "open";
|
|
334
386
|
down = undefined;
|
|
335
387
|
note = undefined; // a new socket: what failed on the last one is history
|
|
336
388
|
void clock.sleep(HELLO_MS, quiet.signal).then(() => {
|
|
337
389
|
if (quiet.signal.aborted)
|
|
338
390
|
return;
|
|
339
|
-
if (
|
|
391
|
+
if (link === "open")
|
|
340
392
|
return (end(`Soloist said nothing in ${HELLO_MS / 1000} s`),
|
|
341
393
|
s.close());
|
|
342
|
-
if (
|
|
343
|
-
((
|
|
394
|
+
if (link === "heard")
|
|
395
|
+
((link = "whole"), publish()); // signed in, no playback_state: connected with what there is
|
|
344
396
|
});
|
|
345
397
|
};
|
|
346
|
-
s.onmessage = (ev) => handle(ev.data);
|
|
398
|
+
s.onmessage = (ev) => void (ended || handle(ev.data));
|
|
347
399
|
s.onerror = () => { }; // onclose follows and says it
|
|
348
400
|
s.onclose = () => end("Soloist closed the socket");
|
|
349
401
|
});
|
|
@@ -358,7 +410,7 @@ export function spotify(config = {}) {
|
|
|
358
410
|
waiters.delete(check);
|
|
359
411
|
};
|
|
360
412
|
/** Not connected or not playing: the reason `duck` and `interrupt` just run `fn`. */
|
|
361
|
-
const notPlaying = () => !
|
|
413
|
+
const notPlaying = () => !isOpen()
|
|
362
414
|
? "Soloist is not connected"
|
|
363
415
|
: state.status === "playing" || state.status === "buffering"
|
|
364
416
|
? undefined
|
|
@@ -379,7 +431,7 @@ export function spotify(config = {}) {
|
|
|
379
431
|
*/
|
|
380
432
|
const aside = (kind, fn, enter, leave) => {
|
|
381
433
|
const run = async () => {
|
|
382
|
-
if (
|
|
434
|
+
if (isOpen())
|
|
383
435
|
marked(kind);
|
|
384
436
|
try {
|
|
385
437
|
return await within();
|
|
@@ -414,10 +466,20 @@ export function spotify(config = {}) {
|
|
|
414
466
|
why: failed(`sent the ${what}; Soloist has not reported it`),
|
|
415
467
|
};
|
|
416
468
|
const actions = {
|
|
469
|
+
async activate() {
|
|
470
|
+
if (isOpen() && state.loggedIn !== false && state.active === true)
|
|
471
|
+
return { state, confirmed: true }; // a second activate might make a new stream
|
|
472
|
+
return change("activate", {
|
|
473
|
+
command: "activate",
|
|
474
|
+
took: (s) => s.active === true,
|
|
475
|
+
});
|
|
476
|
+
},
|
|
477
|
+
// A `play` while inactive didn't take live; going active first is untested for this. `resume` worked inactive, so it doesn't.
|
|
417
478
|
async play(linkOrUri) {
|
|
418
479
|
if (linkOrUri === undefined)
|
|
419
480
|
return change("transport", {
|
|
420
481
|
command: "play",
|
|
482
|
+
activeFirst: true,
|
|
421
483
|
took: (s) => s.status === "playing",
|
|
422
484
|
});
|
|
423
485
|
const uri = toSpotifyUri(linkOrUri);
|
|
@@ -426,6 +488,7 @@ export function spotify(config = {}) {
|
|
|
426
488
|
return change("transport", {
|
|
427
489
|
command: "play",
|
|
428
490
|
fields: { uri },
|
|
491
|
+
activeFirst: true,
|
|
429
492
|
took: (s) => s.status === "playing" &&
|
|
430
493
|
(s.track?.uri === uri || s.context?.uri === uri),
|
|
431
494
|
});
|
|
@@ -462,6 +525,8 @@ export function spotify(config = {}) {
|
|
|
462
525
|
async setVolume(volume) {
|
|
463
526
|
if (!Number.isFinite(volume) || volume < 0 || volume > 100)
|
|
464
527
|
throw invalid(`volume is 0–100, not ${volume}`);
|
|
528
|
+
if (inactive())
|
|
529
|
+
throw new ConnectorError("the speaker is not the active Spotify device, so Soloist would drop this; activate it first (`home spotify activate`; its volume may jump to the level last saved)", { code: "inactive" });
|
|
465
530
|
return volumeTo(Math.round(cap === undefined ? volume : Math.min(volume, cap)));
|
|
466
531
|
},
|
|
467
532
|
async queue(track) {
|
|
@@ -535,6 +600,8 @@ export function spotify(config = {}) {
|
|
|
535
600
|
const no = notPlaying();
|
|
536
601
|
if (no)
|
|
537
602
|
return no;
|
|
603
|
+
if (inactive())
|
|
604
|
+
return "it is not the active device, so its volume can't be changed";
|
|
538
605
|
if (state.volume === undefined)
|
|
539
606
|
return "its volume is not known";
|
|
540
607
|
if (state.volume <= to)
|
|
@@ -547,7 +614,7 @@ export function spotify(config = {}) {
|
|
|
547
614
|
return failed(`lowering the volume for duck failed: ${errorText(error)}`);
|
|
548
615
|
}
|
|
549
616
|
}, async () => {
|
|
550
|
-
if (state.volume
|
|
617
|
+
if (!near(state.volume, Math.round(to)))
|
|
551
618
|
return {
|
|
552
619
|
music: "kept",
|
|
553
620
|
why: shown
|
|
@@ -584,7 +651,7 @@ export function spotify(config = {}) {
|
|
|
584
651
|
state = rest;
|
|
585
652
|
}
|
|
586
653
|
let seen; // the last check's reported position
|
|
587
|
-
let repairedFor; // at most one repair per track
|
|
654
|
+
let repairedFor; // at most one repair per play of a track
|
|
588
655
|
/** Ask Soloist where it is, waiting for the answer (or the query's timeout). */
|
|
589
656
|
const fresh = () => write("query-get_state", {
|
|
590
657
|
command: "get_state",
|
|
@@ -605,7 +672,7 @@ export function spotify(config = {}) {
|
|
|
605
672
|
a.track === b.track &&
|
|
606
673
|
b.atMs - a.atMs >= gap &&
|
|
607
674
|
Math.abs(b.ms - a.ms) < 1000;
|
|
608
|
-
const playing = () =>
|
|
675
|
+
const playing = () => isOpen() && state.status === "playing" && !state.aside;
|
|
609
676
|
/** One look while playing: a fresh position, then frozen, overrun, recovered or fine (Soloist issue #5). */
|
|
610
677
|
const checkStall = async () => {
|
|
611
678
|
if (!playing())
|
|
@@ -645,7 +712,7 @@ export function spotify(config = {}) {
|
|
|
645
712
|
repairedFor = track;
|
|
646
713
|
const tried = (what) => state.stalled && stallAs({ ...state.stalled, tried: what });
|
|
647
714
|
// Still ours to fix: playing this track, stalled, and no duck, interrupt or person in between.
|
|
648
|
-
const ours = () =>
|
|
715
|
+
const ours = () => isOpen() &&
|
|
649
716
|
state.status === "playing" &&
|
|
650
717
|
state.track?.uri === track &&
|
|
651
718
|
state.stalled?.track === track &&
|
|
@@ -689,8 +756,10 @@ export function spotify(config = {}) {
|
|
|
689
756
|
/** Ask Soloist for its state again (`get_state`, or `get_auth_state` without a session); while it is unreachable, reconnect now. */
|
|
690
757
|
refresh() {
|
|
691
758
|
const s = socket;
|
|
692
|
-
if (s &&
|
|
693
|
-
return query(state.loggedIn === false ||
|
|
759
|
+
if (s && isOpen() && s.readyState === OPEN)
|
|
760
|
+
return query(state.loggedIn === false || link === "open"
|
|
761
|
+
? "get_auth_state"
|
|
762
|
+
: "get_state");
|
|
694
763
|
wake?.abort();
|
|
695
764
|
},
|
|
696
765
|
async run(signal, update) {
|
|
@@ -82,8 +82,10 @@ export declare function toSnapshot(txs: Transaction[], categories: Category[], a
|
|
|
82
82
|
truncated: boolean;
|
|
83
83
|
household?: Household;
|
|
84
84
|
}): UpSnapshot;
|
|
85
|
-
/** Up as a home integration: two months of transactions now, then every 15 minutes.
|
|
86
|
-
export declare function up(config: UpConfig): Integration<UpSnapshot, "up"
|
|
85
|
+
/** Up as a home integration: two months of transactions now, then every 15 minutes. The integration only reads; `client` is there for a consumer's writes. */
|
|
86
|
+
export declare function up(config: UpConfig): Integration<UpSnapshot, "up"> & {
|
|
87
|
+
client: UpClient;
|
|
88
|
+
};
|
|
87
89
|
/** "Oct so far $2,140 · Sep $1,980 · 3 to categorise": this month's spend against last month's. */
|
|
88
90
|
export declare function upText(s: UpSnapshot): string;
|
|
89
91
|
/** One line per parent category this month, largest five: "home $1,030: groceries $620, utilities $410". */
|
|
@@ -97,13 +97,14 @@ export function toSnapshot(txs, categories, at) {
|
|
|
97
97
|
truncated: at.truncated,
|
|
98
98
|
};
|
|
99
99
|
}
|
|
100
|
-
/** Up as a home integration: two months of transactions now, then every 15 minutes.
|
|
100
|
+
/** Up as a home integration: two months of transactions now, then every 15 minutes. The integration only reads; `client` is there for a consumer's writes. */
|
|
101
101
|
export function up(config) {
|
|
102
102
|
const client = upClient(config);
|
|
103
103
|
const clock = config.clock ?? realClock;
|
|
104
104
|
const poll = poller(clock);
|
|
105
105
|
return {
|
|
106
106
|
name: "up",
|
|
107
|
+
client,
|
|
107
108
|
refresh: poll.nudge,
|
|
108
109
|
async run(signal, update) {
|
|
109
110
|
let last;
|
|
@@ -151,17 +151,30 @@ export function weather(config) {
|
|
|
151
151
|
}
|
|
152
152
|
/** "20:20" in the home's time zone (the runtime's when unset), or "?". */
|
|
153
153
|
const hhmmIn = (iso, timeZone) => iso ? localTime(Date.parse(iso), timeZone) : "?";
|
|
154
|
-
const day = (d) => d
|
|
154
|
+
const day = (d, rainLeft) => d
|
|
155
155
|
? [
|
|
156
156
|
d.minC === undefined && d.maxC === undefined
|
|
157
157
|
? ""
|
|
158
158
|
: `${d.minC ?? "?"}–${d.maxC ?? "?"} °C`,
|
|
159
159
|
d.text?.replace(/\.$/, "").toLowerCase() ?? "",
|
|
160
|
-
d.rainChance === undefined
|
|
160
|
+
d.rainChance === undefined
|
|
161
|
+
? ""
|
|
162
|
+
: rainLeft !== undefined && rainLeft < d.rainChance
|
|
163
|
+
? `${rainLeft}% chance of rain the rest of the day`
|
|
164
|
+
: `${d.rainChance}% chance of rain`,
|
|
161
165
|
]
|
|
162
166
|
.filter(Boolean)
|
|
163
167
|
.join(" ")
|
|
164
168
|
: "?";
|
|
169
|
+
/** The highest hourly chance left today, all 24 hours of it, when the hourly forecast is the same source and day as the forecast: sources are never mixed. */
|
|
170
|
+
const rainLeftToday = (w, at) => {
|
|
171
|
+
if (!w.hourly || w.hourly.source !== w.forecast?.source)
|
|
172
|
+
return undefined;
|
|
173
|
+
const rest = dayBlocks(w.hourly, at, w.timeZone, [
|
|
174
|
+
{ name: "rest", from: 0, to: 24 },
|
|
175
|
+
])?.today[0];
|
|
176
|
+
return rest?.date === w.forecast.today?.date ? rest?.rainChance : undefined;
|
|
177
|
+
};
|
|
165
178
|
/** "rain in 30 min, ends in 55 min, 70%, 0.8 mm, moderate (google)", "raining, ends in 25 min", or "no rain for 2 h"; nothing when the forecast covers none of the window. */
|
|
166
179
|
export function rainText(rain, r) {
|
|
167
180
|
if (!r || !rain)
|
|
@@ -206,7 +219,7 @@ export function weatherText(w, sun) {
|
|
|
206
219
|
: "";
|
|
207
220
|
const f = w.forecast;
|
|
208
221
|
const forecast = f
|
|
209
|
-
? `today ${day(f.today)}, tomorrow ${day(f.tomorrow)} (${f.source}${f.modelled ? " modelled" : ""})`
|
|
222
|
+
? `today ${day(f.today, rainLeftToday(w, Date.parse(sun.at)))}, tomorrow ${day(f.tomorrow)} (${f.source}${f.modelled ? " modelled" : ""})`
|
|
210
223
|
: "";
|
|
211
224
|
const k = w.sky;
|
|
212
225
|
const skyParts = k
|
package/dist/discover.d.ts
CHANGED
|
@@ -85,6 +85,7 @@ export type CachedAddress = {
|
|
|
85
85
|
host: string;
|
|
86
86
|
port?: number;
|
|
87
87
|
name?: string;
|
|
88
|
+
model?: string;
|
|
88
89
|
seenAt: string;
|
|
89
90
|
};
|
|
90
91
|
/**
|
|
@@ -111,6 +112,7 @@ export declare function addressBook(cache: AddressCache | undefined, options?: {
|
|
|
111
112
|
host: string;
|
|
112
113
|
port?: number;
|
|
113
114
|
name?: string;
|
|
115
|
+
model?: string;
|
|
114
116
|
}): void;
|
|
115
117
|
entries: () => {
|
|
116
118
|
[x: string]: CachedAddress;
|
|
@@ -119,7 +121,8 @@ export declare function addressBook(cache: AddressCache | undefined, options?: {
|
|
|
119
121
|
/**
|
|
120
122
|
* Run one discovery `passes` times, `gapMs` apart, and return the union (first answer wins). `onPass` sees each pass's
|
|
121
123
|
* additions as they come; `done` ends it early once what was looked for has answered. A pass that throws adds nothing;
|
|
122
|
-
* when every pass threw, discovery never ran, so it throws the last error rather than reporting nothing found.
|
|
124
|
+
* when every pass threw, discovery never ran, so it throws the last error rather than reporting nothing found. An error
|
|
125
|
+
* `stopOn` accepts is thrown at once: no later pass could do better.
|
|
123
126
|
*/
|
|
124
127
|
export declare function discover<T = Found>(pass: () => Promise<T[]>, options: {
|
|
125
128
|
clock: Clock;
|
|
@@ -127,6 +130,7 @@ export declare function discover<T = Found>(pass: () => Promise<T[]>, options: {
|
|
|
127
130
|
gapMs?: number;
|
|
128
131
|
key?: (t: T) => string;
|
|
129
132
|
done?: (all: T[]) => boolean;
|
|
133
|
+
stopOn?: (error: unknown) => boolean;
|
|
130
134
|
onPass?: (n: number, added: T[]) => void;
|
|
131
135
|
signal?: AbortSignal;
|
|
132
136
|
}): Promise<T[]>;
|
package/dist/discover.js
CHANGED
|
@@ -241,13 +241,16 @@ export function addressBook(cache, options = {}) {
|
|
|
241
241
|
c.host === address.host &&
|
|
242
242
|
c.port === address.port &&
|
|
243
243
|
isFresh(c) &&
|
|
244
|
-
(!address.name || address.name === c.name)
|
|
244
|
+
(!address.name || address.name === c.name) &&
|
|
245
|
+
(!address.model || address.model === c.model))
|
|
245
246
|
return;
|
|
246
247
|
const name = address.name || c?.name;
|
|
248
|
+
const model = address.model || c?.model;
|
|
247
249
|
entries[key] = {
|
|
248
250
|
host: address.host,
|
|
249
251
|
...(address.port === undefined ? {} : { port: address.port }),
|
|
250
252
|
...(name ? { name } : {}),
|
|
253
|
+
...(model ? { model } : {}),
|
|
251
254
|
seenAt: new Date(now()).toISOString(),
|
|
252
255
|
};
|
|
253
256
|
cache.write({ ...entries });
|
|
@@ -258,7 +261,8 @@ export function addressBook(cache, options = {}) {
|
|
|
258
261
|
/**
|
|
259
262
|
* Run one discovery `passes` times, `gapMs` apart, and return the union (first answer wins). `onPass` sees each pass's
|
|
260
263
|
* additions as they come; `done` ends it early once what was looked for has answered. A pass that throws adds nothing;
|
|
261
|
-
* when every pass threw, discovery never ran, so it throws the last error rather than reporting nothing found.
|
|
264
|
+
* when every pass threw, discovery never ran, so it throws the last error rather than reporting nothing found. An error
|
|
265
|
+
* `stopOn` accepts is thrown at once: no later pass could do better.
|
|
262
266
|
*/
|
|
263
267
|
export async function discover(pass, options) {
|
|
264
268
|
const key = options.key ?? foundKey;
|
|
@@ -272,7 +276,12 @@ export async function discover(pass, options) {
|
|
|
272
276
|
if (options.signal?.aborted)
|
|
273
277
|
break;
|
|
274
278
|
ran++;
|
|
275
|
-
const answers = await pass().catch((error) =>
|
|
279
|
+
const answers = await pass().catch((error) => {
|
|
280
|
+
if (options.stopOn?.(error))
|
|
281
|
+
throw error;
|
|
282
|
+
errors.push(error);
|
|
283
|
+
return [];
|
|
284
|
+
});
|
|
276
285
|
const added = answers.filter((t) => !all.has(key(t)) && all.set(key(t), t));
|
|
277
286
|
options.onPass?.(n, added);
|
|
278
287
|
if (options.done?.([...all.values()]))
|
package/dist/energy.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { kw } from "./manifest.js";
|
|
1
2
|
/**
|
|
2
3
|
* Within this many watts either way the grid flow reads as "balanced": a display rule for the word and the animation,
|
|
3
4
|
* so a meter wobbling around zero does not flip between importing and exporting. `gridW` and `surplusW` stay as read.
|
|
@@ -77,7 +78,6 @@ export function solarLevel(now, capacityW) {
|
|
|
77
78
|
* "balanced (-10 W)", or "" with no grid reading. Rounded for reading only; the numbers above stay as read.
|
|
78
79
|
*/
|
|
79
80
|
export function gridText(now) {
|
|
80
|
-
const kw = (w) => `${(w / 1000).toFixed(1)} kW`;
|
|
81
81
|
const rate = (c, positive, negative) => c === undefined
|
|
82
82
|
? ""
|
|
83
83
|
: `, ${c >= 0 ? positive : negative} ${Math.round(Math.abs(c))} c/h`;
|
package/dist/finders.d.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { type AddressCache, type Connector, type Finder, type Found } from "./discover.ts";
|
|
2
|
+
/** The system's mDNS tool is not installed: discovery can't run until it is. */
|
|
3
|
+
export declare class MissingTool extends Error {
|
|
4
|
+
}
|
|
2
5
|
/**
|
|
3
6
|
* Run a long-lived `dns-sd` query (or `avahi-browse`, by `command`) and return what it printed. It never exits by itself, so it is stopped once `done`
|
|
4
7
|
* says the answer is in, or `quietMs` after the last output, or at `maxMs`. The system's mDNS cache usually answers in
|
package/dist/finders.js
CHANGED
|
@@ -12,6 +12,9 @@ import { parseAvahi, parseAvahiBrowse, parseBrowse, parseResolve, } from "./disc
|
|
|
12
12
|
// nothing here binds UDP 5353 beside it; a slow device (the Enphase gateway took 4 s to answer a fresh query; Bun 1.4.3, 2026-10-16)
|
|
13
13
|
// does not slow a scan. Without the tool the mDNS finders throw, naming it, rather than "nothing answered".
|
|
14
14
|
// `fileCache` keeps the addresses discovery found in a file between runs, so the next run connects straight away.
|
|
15
|
+
/** The system's mDNS tool is not installed: discovery can't run until it is. */
|
|
16
|
+
export class MissingTool extends Error {
|
|
17
|
+
}
|
|
15
18
|
/**
|
|
16
19
|
* Run a long-lived `dns-sd` query (or `avahi-browse`, by `command`) and return what it printed. It never exits by itself, so it is stopped once `done`
|
|
17
20
|
* says the answer is in, or `quietMs` after the last output, or at `maxMs`. The system's mDNS cache usually answers in
|
|
@@ -38,9 +41,9 @@ export function dnsSd(argv, options = {}) {
|
|
|
38
41
|
resolve(out));
|
|
39
42
|
proc.on("error", (error) => {
|
|
40
43
|
(clearTimeout(timer), clearTimeout(quiet));
|
|
41
|
-
reject(
|
|
42
|
-
? `${command} not found; mDNS discovery needs it${install ? ` (${install})` : ""}`
|
|
43
|
-
: `${command}: ${error.message}`));
|
|
44
|
+
reject(error.code === "ENOENT"
|
|
45
|
+
? new MissingTool(`${command} not found; mDNS discovery needs it${install ? ` (${install})` : ""}`)
|
|
46
|
+
: new Error(`${command}: ${error.message}`));
|
|
44
47
|
});
|
|
45
48
|
proc.on("close", finish);
|
|
46
49
|
});
|
package/dist/lane.js
CHANGED
|
@@ -24,28 +24,32 @@ export function writeLane(options) {
|
|
|
24
24
|
}
|
|
25
25
|
async function run() {
|
|
26
26
|
running = true;
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
w.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
w.
|
|
27
|
+
try {
|
|
28
|
+
while (queue.length) {
|
|
29
|
+
const now = clock.now();
|
|
30
|
+
const next = queue.find((p) => readyAt(p.slot) <= now);
|
|
31
|
+
if (!next) {
|
|
32
|
+
wake = new AbortController();
|
|
33
|
+
await clock.sleep(Math.min(...queue.map((p) => readyAt(p.slot))) - now, wake.signal);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
queue.splice(queue.indexOf(next), 1);
|
|
37
|
+
lastAt = now;
|
|
38
|
+
sent.set(next.slot, [...recent(next.slot), now]);
|
|
39
|
+
try {
|
|
40
|
+
const result = await next.write(next.body);
|
|
41
|
+
for (const w of next.waiters)
|
|
42
|
+
w.resolve(result);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
for (const w of next.waiters)
|
|
46
|
+
w.reject(error);
|
|
47
|
+
}
|
|
46
48
|
}
|
|
47
49
|
}
|
|
48
|
-
|
|
50
|
+
finally {
|
|
51
|
+
running = false; // a throw here must not leave the lane stuck
|
|
52
|
+
}
|
|
49
53
|
}
|
|
50
54
|
return {
|
|
51
55
|
send(slot, body, write, merge = (_, newer) => newer) {
|
package/docs/cast.md
CHANGED
|
@@ -109,7 +109,7 @@ No host: a group's host changes with its leader. All entries fold into one `cast
|
|
|
109
109
|
- `announce(target, clip, { volume?, timeoutMs? })`, target a device or group, `clip` a URL or bytes with their type (KO's own dictation as recorded, or TTS output; the library never synthesises) served by `serveClip`. The result names each member it could not restore. Read status, set volume, launch Default Media Receiver (`CC1AD845`), `LOAD` the clip, wait for `IDLE`/`FINISHED` (with a timeout), then restore: a prior Default Media Receiver `contentId` is re-loaded at its `currentTime`, an idle device gets `STOP`, then the volume. Spotify, YouTube and other apps cannot be resumed generically (our launch ends their session); the result says so.
|
|
110
110
|
- `castUrl(name, url)` launches DashCast (`84912283`, pychromecast's list) with the URL. HA casts its dashboard with its own registered receiver (`A078F6B0`), a hint at DashCast's limits. KO's own receiver costs a one-off Cast SDK fee and must be published (unpublished ones run only on serial-registered devices). Try DashCast first.
|
|
111
111
|
|
|
112
|
-
**TTS.** The library plays a URL, never synthesises. The speaker fetches it, so it must be HTTP on the LAN, not loopback, and in a format Default Media Receiver plays (MP3
|
|
112
|
+
**TTS.** The library plays a URL, never synthesises. The speaker fetches it, so it must be HTTP on the LAN, not loopback, and in a format Default Media Receiver plays (MP3 or AAC; not AIFF; a 24 kHz mono WAV failed on audio-only Nest speakers, live 2026-11-09): `say -o msg.m4a --file-format=m4af --data-format=aac "…"` (m4af alone holds uncompressed PCM). Smallest server: a ~25-line `serveClip(bytes, type)` over `node:http` beside `tls.ts`, bound to the Mac's LAN address, random port, unguessable path, closed when the clip has played or after a minute (not after the first fetch, so a probe before it does no harm). The consumer runs `say`; `serveClip` picks the address on the target's network; the sidecar will raise macOS's local-network prompt once. A cloud voice later is MP3 bytes served the same way.
|
|
113
113
|
|
|
114
114
|
**CLI.** `home status`: `cast 7 · Display A idle 47% · TV B standby · Whole house (3, led by Display A) idle`. `home probe`: this probe.
|
|
115
115
|
|