@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.
Files changed (59) hide show
  1. package/CHANGELOG.md +50 -1
  2. package/README.md +11 -11
  3. package/dist/config.js +3 -1
  4. package/dist/connectors/cast/index.d.ts +4 -1
  5. package/dist/connectors/cast/index.js +10 -2
  6. package/dist/connectors/cast/schemas.d.ts +2 -0
  7. package/dist/connectors/cast/schemas.js +1 -0
  8. package/dist/connectors/cast/serve.d.ts +8 -1
  9. package/dist/connectors/cast/state.d.ts +4 -0
  10. package/dist/connectors/cast/state.js +12 -0
  11. package/dist/connectors/cast/tls.js +1 -1
  12. package/dist/connectors/cast/writes.d.ts +9 -0
  13. package/dist/connectors/cast/writes.js +42 -6
  14. package/dist/connectors/spotify/changes.d.ts +1 -1
  15. package/dist/connectors/spotify/changes.js +4 -2
  16. package/dist/connectors/spotify/client.d.ts +1 -1
  17. package/dist/connectors/spotify/index.d.ts +3 -1
  18. package/dist/connectors/spotify/index.js +114 -45
  19. package/dist/connectors/up/index.d.ts +4 -2
  20. package/dist/connectors/up/index.js +2 -1
  21. package/dist/connectors/weather/index.js +16 -3
  22. package/dist/discover.d.ts +5 -1
  23. package/dist/discover.js +12 -3
  24. package/dist/energy.js +1 -1
  25. package/dist/finders.d.ts +3 -0
  26. package/dist/finders.js +6 -3
  27. package/dist/lane.js +24 -20
  28. package/docs/cast.md +1 -1
  29. package/docs/cli.md +5 -3
  30. package/docs/config.md +2 -2
  31. package/docs/decisions.md +1 -0
  32. package/docs/google.md +1 -1
  33. package/package.json +2 -2
  34. package/skills/draw-home/README.md +5 -6
  35. package/skills/draw-home/SKILL.md +3 -5
  36. package/src/cli/cast.ts +19 -11
  37. package/src/cli/discover.ts +8 -1
  38. package/src/cli/spotify.ts +2 -1
  39. package/src/config.ts +3 -1
  40. package/src/connectors/cast/README.md +7 -5
  41. package/src/connectors/cast/index.ts +19 -5
  42. package/src/connectors/cast/schemas.ts +1 -0
  43. package/src/connectors/cast/serve.ts +11 -1
  44. package/src/connectors/cast/state.ts +12 -0
  45. package/src/connectors/cast/tls.ts +1 -1
  46. package/src/connectors/cast/writes.ts +58 -9
  47. package/src/connectors/daikin/README.md +1 -1
  48. package/src/connectors/spotify/README.md +23 -8
  49. package/src/connectors/spotify/changes.ts +4 -2
  50. package/src/connectors/spotify/client.ts +1 -1
  51. package/src/connectors/spotify/index.ts +120 -48
  52. package/src/connectors/up/README.md +1 -1
  53. package/src/connectors/up/index.ts +5 -2
  54. package/src/connectors/weather/README.md +1 -1
  55. package/src/connectors/weather/index.ts +19 -3
  56. package/src/discover.ts +14 -6
  57. package/src/energy.ts +1 -1
  58. package/src/finders.ts +8 -5
  59. 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 an idle → playing change, a volume above the cap is lowered for this long: the volume can come before or after the status (issue #23). */
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
- let open = false;
82
- let heard = false; // an auth_state on this socket: until then the part stays connecting
83
- let whole = false; // signed in, the first playback_state has come too (or HELLO_MS passed): a one-shot read sees the track
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 has been sent in this window
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 (!open)
107
+ if (link === "down")
102
108
  ((status = reported || down ? "disconnected" : "connecting"),
103
109
  (detail = down));
104
- else if (!heard || (state.loggedIn === true && !whole))
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 || !open || s.readyState !== OPEN)
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() || !open, CONFIRM_MS);
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 === v,
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
- /** Within the window after idle → playing, a volume above the cap is lowered to it, once (issue #23). */
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
- clock.now() > capUntil ||
215
- state.volume <= cap)
234
+ state.volume <= cap + VOLUME_SLACK ||
235
+ state.active === false ||
236
+ clock.now() > capUntil)
216
237
  return;
217
- capped = true;
218
- capNote = `started at volume ${state.volume}, above maxVolume ${cap}; lowered it (Soloist issue #23)`;
219
- change("volume", {
220
- command: "set_volume",
221
- fields: { volume: cap },
222
- took: (s) => s.volume === cap,
223
- }).catch((error) => ((note = `lowering the volume to ${cap} failed: ${errorText(error)}`),
224
- publish()));
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 (!heard && f.logged_in !== true)
260
- whole = true;
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
- whole = true;
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 playing: a phone picked the speaker and played (issue #23).
278
- const started = f.status === "playing" && (statusKnown ? before === "idle" : signedIn);
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 = open;
318
- open = false;
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
- open = true;
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 (!heard)
391
+ if (link === "open")
340
392
  return (end(`Soloist said nothing in ${HELLO_MS / 1000} s`),
341
393
  s.close());
342
- if (!whole)
343
- ((whole = true), publish()); // signed in, no playback_state: connected with what there is
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 = () => !open
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 (open)
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 !== Math.round(to))
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 = () => open && state.status === "playing" && !state.aside;
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 = () => open &&
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 && open && s.readyState === OPEN)
693
- return query(state.loggedIn === false || !heard ? "get_auth_state" : "get_state");
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. Reads only; it never writes. */
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. Reads only; it never writes. */
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 ? "" : `${d.rainChance}% chance of rain`,
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
@@ -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) => (errors.push(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(new Error(error.code === "ENOENT"
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
- while (queue.length) {
28
- const now = clock.now();
29
- const next = queue.find((p) => readyAt(p.slot) <= now);
30
- if (!next) {
31
- wake = new AbortController();
32
- await clock.sleep(Math.min(...queue.map((p) => readyAt(p.slot))) - now, wake.signal);
33
- continue;
34
- }
35
- queue.splice(queue.indexOf(next), 1);
36
- lastAt = now;
37
- sent.set(next.slot, [...recent(next.slot), now]);
38
- try {
39
- const result = await next.write(next.body);
40
- for (const w of next.waiters)
41
- w.resolve(result);
42
- }
43
- catch (error) {
44
- for (const w of next.waiters)
45
- w.reject(error);
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
- running = false;
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, AAC, WAV; not AIFF): `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.
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