@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
@@ -99,9 +99,11 @@ export type Aside<T> = { value: T; music: Music; why?: string };
99
99
  /**
100
100
  * The writes, on the object `spotify()` returns. Each resolves once Soloist reports the change it asked for, with the state
101
101
  * then (`confirmed: true`), or after `CONFIRM_MS` with the state as it stands (`confirmed: false`). Each rejects with a
102
- * `ConnectorError` whose `code` is `offline`, `waiting` (nobody has picked the speaker), `refused`, `timeout` or `invalid`.
102
+ * `ConnectorError` whose `code` is `offline`, `waiting` (nobody has picked the speaker), `inactive` (`setVolume` on a speaker that isn't the active device: `activate()` first), `refused`, `timeout` or `invalid`.
103
103
  */
104
104
  export type SpotifyActions = {
105
+ /** Make this the account's active Spotify device. Soloist drops volume changes until it is. It makes a new audio stream, so the volume can jump to the level last saved. */
106
+ activate(): Promise<Applied>;
105
107
  /** Play a Spotify link or URI (track, album, playlist, artist, episode, show), or resume what is loaded when none is given. */
106
108
  play(linkOrUri?: string): Promise<Applied>;
107
109
  pause(): Promise<Applied>;
@@ -137,10 +139,12 @@ const COMMAND_TIMEOUT_MS = 3000;
137
139
  const QUERY_TIMEOUT_MS = 1000;
138
140
  /** A socket that has said nothing this long after opening is closed: Soloist sends `auth_state` at once on connect. */
139
141
  const HELLO_MS = 10_000;
140
- /** 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). */
142
+ /** 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). */
141
143
  const CAP_WINDOW_MS = 10_000;
142
144
  /** How long a write waits for Soloist to report the change it asked for. Loopback events arrive in milliseconds; a stated guess. */
143
145
  const CONFIRM_MS = 2000;
146
+ /** Between the two halves of `setRepeat`: back to back left repeat on `track`, 600 ms apart worked (live, 2026-11-09). */
147
+ const REPEAT_GAP_MS = 600;
144
148
  /** Between two commands. Soloist publishes no limit; it is on loopback; a stated guess. */
145
149
  const WRITE_GAP_MS = 100;
146
150
  /** While playing, a stall check this often: one `get_state`. Soloist issue #5 reports stalls 71–150 s into a track; a stated guess. */
@@ -149,6 +153,8 @@ const STALL_CHECK_MS = 20_000;
149
153
  const OVERRUN_MS = 15_000;
150
154
  /** After a repair's seek, how long before looking whether the position moves again. */
151
155
  const REPAIR_WAIT_MS = 5000;
156
+ /** A reported volume is rounded (phone steps are sixteenths: 43.75 shows as 44), so "it took" allows this much. */
157
+ const VOLUME_SLACK = 1;
152
158
  const MIN_BACKOFF_MS = 1000;
153
159
  const MAX_BACKOFF_MS = 60_000;
154
160
 
@@ -190,15 +196,17 @@ export function spotify(
190
196
 
191
197
  let state: SpotifyState = { asOf: asOf(clock) };
192
198
  let socket: SocketLike | undefined;
193
- let open = false;
194
- let heard = false; // an auth_state on this socket: until then the part stays connecting
195
- let whole = false; // signed in, the first playback_state has come too (or HELLO_MS passed): a one-shot read sees the track
199
+ // 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).
200
+ let link: "down" | "open" | "heard" | "whole" = "down";
201
+ const isOpen = () => link !== "down";
196
202
  let down: string | undefined; // why the last socket ended, while there is none open
197
203
  let statusKnown = false; // Soloist has told this socket its status: only then is idle → playing an edge
198
204
  let queueAsked = false; // get_queue sent on this socket
199
205
  let signedIn = false; // signed out → in on this socket: the first status after it counts as a start if it is playing
200
206
  let capUntil = -Infinity;
201
- let capped = false; // the cap has been sent in this window
207
+ let capped = false; // the cap took in this window
208
+ let capping = false; // a lowering is on its way
209
+ let startBuffering = false; // a start from idle is still buffering
202
210
  let capNote: string | undefined; // the last first-play lowering, in `anomaly` until the speaker goes idle
203
211
  let frameNote: string | undefined; // a frame the schema rejected, or an error nothing was waiting for
204
212
  let note: string | undefined; // a write the integration made itself that failed (the cap, interrupt's resume), in `detail`
@@ -213,11 +221,10 @@ export function spotify(
213
221
  const publish = () => {
214
222
  let status: Status;
215
223
  let detail: string | undefined;
216
- if (!open)
224
+ if (link === "down")
217
225
  ((status = reported || down ? "disconnected" : "connecting"),
218
226
  (detail = down));
219
- else if (!heard || (state.loggedIn === true && !whole))
220
- status = "connecting";
227
+ else if (link !== "whole") status = "connecting";
221
228
  else
222
229
  ((status = "connected"),
223
230
  (detail = state.loggedIn === false ? WAITING : note));
@@ -250,7 +257,7 @@ export function spotify(
250
257
  ) =>
251
258
  new Promise<void>((resolve, reject) => {
252
259
  const s = socket;
253
- if (!s || !open || s.readyState !== OPEN) return reject(offline());
260
+ if (!s || !isOpen() || s.readyState !== OPEN) return reject(offline());
254
261
  if (state.loggedIn === false && !answers)
255
262
  return reject(new ConnectorError(WAITING, { code: "waiting" }));
256
263
  const stop = new AbortController();
@@ -290,12 +297,20 @@ export function spotify(
290
297
  answers?: string;
291
298
  then?: { command: string; fields: Record<string, unknown> };
292
299
  took?: (now: SpotifyState, before: SpotifyState, at: number) => boolean;
300
+ activeFirst?: boolean; // an inactive speaker goes active before this, in this write's own turn so the last transport write still wins
293
301
  };
294
302
  const send = async (body: Body) => {
303
+ if (body.activeFirst && inactive()) {
304
+ await command("activate").catch(() => {}); // best effort: the write itself says how it went
305
+ await waitFor(() => state.active === true || !isOpen(), CONFIRM_MS);
306
+ }
295
307
  const before = state;
296
308
  const at = clock.now();
297
309
  await command(body.command, body.fields, body.answers);
298
- if (body.then) await command(body.then.command, body.then.fields); // repeat's second half, still this write's turn
310
+ if (body.then) {
311
+ await clock.sleep(REPEAT_GAP_MS);
312
+ await command(body.then.command, body.then.fields); // repeat's second half, still this write's turn
313
+ }
299
314
  return { before, at };
300
315
  };
301
316
  let once = 0; // skips and queued tracks never merge: two nexts are two skips
@@ -308,7 +323,7 @@ export function spotify(
308
323
  const change = async (slot: string, body: Body): Promise<Applied> => {
309
324
  const sent = await write(slot, body);
310
325
  const took = () => body.took?.(state, sent.before, sent.at) ?? true;
311
- await waitFor(() => took() || !open, CONFIRM_MS);
326
+ await waitFor(() => took() || !isOpen(), CONFIRM_MS);
312
327
  return { state, confirmed: took() };
313
328
  };
314
329
  /** 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). */
@@ -320,12 +335,17 @@ export function spotify(
320
335
  !!s.track && s.track.uri !== b.track?.uri;
321
336
  const invalid = (message: string) =>
322
337
  new ConnectorError(message, { code: "invalid" });
338
+ const near = (volume: number | undefined, v: number) =>
339
+ volume !== undefined && Math.abs(volume - v) <= VOLUME_SLACK;
323
340
  const volumeTo = (v: number) =>
324
341
  change("volume", {
325
342
  command: "set_volume",
326
343
  fields: { volume: v },
327
- took: (s) => s.volume === v,
344
+ took: (s) => near(s.volume, v),
328
345
  });
346
+ /** Soloist accepts a `set_volume` while it isn't the active device and drops it (three times live, 2026-11-09). */
347
+ const inactive = () =>
348
+ isOpen() && state.loggedIn !== false && state.active === false;
329
349
  /** 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. */
330
350
  const ANSWERS = {
331
351
  get_state: "playback_state",
@@ -352,27 +372,49 @@ export function spotify(
352
372
  publish();
353
373
  });
354
374
 
355
- /** Within the window after idle → playing, a volume above the cap is lowered to it, once (issue #23). */
375
+ /**
376
+ * Within the window after a start signal, a volume above the cap is lowered to it (issue #23). Only while active, since
377
+ * Soloist drops it otherwise, and retried until Soloist reports it. A volume raised after that is someone's choice;
378
+ * one raised before it is reported can't be told from the start's own jump, so it is lowered too.
379
+ */
356
380
  const applyCap = () => {
357
381
  if (
358
382
  cap === undefined ||
359
383
  capped ||
384
+ capping ||
360
385
  state.volume === undefined ||
361
- clock.now() > capUntil ||
362
- state.volume <= cap
386
+ state.volume <= cap + VOLUME_SLACK ||
387
+ state.active === false ||
388
+ clock.now() > capUntil
363
389
  )
364
390
  return;
365
- capped = true;
366
- capNote = `started at volume ${state.volume}, above maxVolume ${cap}; lowered it (Soloist issue #23)`;
367
- change("volume", {
368
- command: "set_volume",
369
- fields: { volume: cap },
370
- took: (s) => s.volume === cap,
371
- }).catch(
372
- (error) => (
373
- (note = `lowering the volume to ${cap} failed: ${errorText(error)}`),
374
- publish()
375
- ),
391
+ capping = true;
392
+ const from = state.volume;
393
+ const window = capUntil;
394
+ volumeTo(cap).then(
395
+ ({ confirmed }) => {
396
+ capping = false;
397
+ if (capUntil !== window) return applyCap(); // a new start while it went: this lowering doesn't speak for that one
398
+ if (confirmed) {
399
+ capped = true;
400
+ if (state.status === "idle") return;
401
+ capNote = `started at volume ${from}, above maxVolume ${cap}; lowered it (Soloist issue #23)`;
402
+ note = undefined;
403
+ return publish();
404
+ }
405
+ if (clock.now() > capUntil) {
406
+ note = `could not lower the volume to ${cap}: Soloist has not reported it`;
407
+ return publish();
408
+ }
409
+ applyCap();
410
+ },
411
+ (error) => {
412
+ capping = false;
413
+ if (error instanceof ConnectorError && error.code === "offline") return; // the status says it
414
+ capped = true; // refused or unanswered: not again this window
415
+ note = `lowering the volume to ${cap} failed: ${errorText(error)}`;
416
+ publish();
417
+ },
376
418
  );
377
419
  };
378
420
 
@@ -410,11 +452,16 @@ export function spotify(
410
452
  }
411
453
  const before = state.status;
412
454
  const wasSignedIn = state.loggedIn;
455
+ const wasActive = link === "open" ? undefined : state.active; // the first frames on a socket are not a change
413
456
  state = fold(state, f, asOf(clock));
457
+ if (state.track?.uri !== repairedFor) repairedFor = undefined; // one repair per play of a track
458
+ // Going active makes a new stream, at WirePlumber's saved level (live, 2026-11-09): the likeliest moment for a loud start.
459
+ if (wasActive === false && state.active === true)
460
+ ((capUntil = clock.now() + CAP_WINDOW_MS), (capped = false));
414
461
  if (f.type === "auth_state") {
415
462
  // Signed out there is no playback_state to wait for, and a later sign-in must not send the part back to connecting.
416
- if (!heard && f.logged_in !== true) whole = true;
417
- heard = true;
463
+ if (link === "open" || (link === "heard" && f.logged_in === false))
464
+ link = f.logged_in === true ? "heard" : "whole";
418
465
  if (f.logged_in === false) statusKnown = false;
419
466
  // A first sign-in (someone picked the speaker) sends only auth_state; the docs don't promise a playback_state after it.
420
467
  else if (f.logged_in === true && wasSignedIn === false)
@@ -422,17 +469,21 @@ export function spotify(
422
469
  }
423
470
  if (f.type === "playback_state") {
424
471
  if (!queueAsked) ((queueAsked = true), query("get_queue", { limit: 10 })); // queue_changed pushes changes only, so ask once per socket
425
- whole = true;
472
+ link = "whole";
426
473
  }
427
474
  if (pending?.answers === f.type) pending.finish();
428
475
  if (
429
476
  (f.type === "playback_state" || f.type === "playback_changed") &&
430
477
  f.status !== undefined
431
478
  ) {
432
- // A start is idle → playing, or a sign-in whose first status is playing: a phone picked the speaker and played (issue #23).
479
+ // A start is idle → playing or buffering, or a sign-in whose first status is either: a phone picked the speaker and played.
480
+ // The volume can land just after `playing` (issue #23), so a start through buffering opens the window again then.
433
481
  const started =
434
- f.status === "playing" && (statusKnown ? before === "idle" : signedIn);
482
+ ((f.status === "playing" || f.status === "buffering") &&
483
+ (statusKnown ? before === "idle" : signedIn)) ||
484
+ (f.status === "playing" && startBuffering);
435
485
  if (started) ((capUntil = clock.now() + CAP_WINDOW_MS), (capped = false));
486
+ startBuffering = f.status === "buffering" && (started || startBuffering);
436
487
  signedIn = false;
437
488
  if (f.status === "idle") capNote = undefined;
438
489
  statusKnown = true;
@@ -465,13 +516,12 @@ export function spotify(
465
516
  quiet.abort();
466
517
  signal.removeEventListener("abort", stop);
467
518
  if (socket === s) socket = undefined;
468
- const was = open;
469
- open = false;
470
- heard = false;
471
- whole = false;
519
+ const was = isOpen();
520
+ link = "down";
472
521
  queueAsked = false;
473
522
  statusKnown = false;
474
523
  signedIn = false;
524
+ startBuffering = false;
475
525
  pending?.finish(offline());
476
526
  for (const w of [...waiters]) w(); // a write waiting to confirm stops waiting
477
527
  seen = undefined;
@@ -479,21 +529,23 @@ export function spotify(
479
529
  };
480
530
  const stop = () => (end("stopped"), s.close());
481
531
  signal.addEventListener("abort", stop, { once: true });
532
+ // An ended socket's late events belong to no session: they must not touch the next socket's state.
482
533
  s.onopen = () => {
483
- open = true;
534
+ if (ended) return;
535
+ link = "open";
484
536
  down = undefined;
485
537
  note = undefined; // a new socket: what failed on the last one is history
486
538
  void clock.sleep(HELLO_MS, quiet.signal).then(() => {
487
539
  if (quiet.signal.aborted) return;
488
- if (!heard)
540
+ if (link === "open")
489
541
  return (
490
542
  end(`Soloist said nothing in ${HELLO_MS / 1000} s`),
491
543
  s.close()
492
544
  );
493
- if (!whole) ((whole = true), publish()); // signed in, no playback_state: connected with what there is
545
+ if (link === "heard") ((link = "whole"), publish()); // signed in, no playback_state: connected with what there is
494
546
  });
495
547
  };
496
- s.onmessage = (ev) => handle(ev.data);
548
+ s.onmessage = (ev) => void (ended || handle(ev.data));
497
549
  s.onerror = () => {}; // onclose follows and says it
498
550
  s.onclose = () => end("Soloist closed the socket");
499
551
  });
@@ -510,7 +562,7 @@ export function spotify(
510
562
 
511
563
  /** Not connected or not playing: the reason `duck` and `interrupt` just run `fn`. */
512
564
  const notPlaying = () =>
513
- !open
565
+ !isOpen()
514
566
  ? "Soloist is not connected"
515
567
  : state.status === "playing" || state.status === "buffering"
516
568
  ? undefined
@@ -536,7 +588,7 @@ export function spotify(
536
588
  leave: () => Promise<Left>,
537
589
  ) => {
538
590
  const run = async (): Promise<Aside<T>> => {
539
- if (open) marked(kind);
591
+ if (isOpen()) marked(kind);
540
592
  try {
541
593
  return await within();
542
594
  } finally {
@@ -570,10 +622,20 @@ export function spotify(
570
622
  };
571
623
 
572
624
  const actions: SpotifyActions = {
625
+ async activate() {
626
+ if (isOpen() && state.loggedIn !== false && state.active === true)
627
+ return { state, confirmed: true }; // a second activate might make a new stream
628
+ return change("activate", {
629
+ command: "activate",
630
+ took: (s) => s.active === true,
631
+ });
632
+ },
633
+ // A `play` while inactive didn't take live; going active first is untested for this. `resume` worked inactive, so it doesn't.
573
634
  async play(linkOrUri) {
574
635
  if (linkOrUri === undefined)
575
636
  return change("transport", {
576
637
  command: "play",
638
+ activeFirst: true,
577
639
  took: (s) => s.status === "playing",
578
640
  });
579
641
  const uri = toSpotifyUri(linkOrUri);
@@ -581,6 +643,7 @@ export function spotify(
581
643
  return change("transport", {
582
644
  command: "play",
583
645
  fields: { uri },
646
+ activeFirst: true,
584
647
  took: (s) =>
585
648
  s.status === "playing" &&
586
649
  (s.track?.uri === uri || s.context?.uri === uri),
@@ -624,6 +687,11 @@ export function spotify(
624
687
  async setVolume(volume) {
625
688
  if (!Number.isFinite(volume) || volume < 0 || volume > 100)
626
689
  throw invalid(`volume is 0–100, not ${volume}`);
690
+ if (inactive())
691
+ throw new ConnectorError(
692
+ "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)",
693
+ { code: "inactive" },
694
+ );
627
695
  return volumeTo(
628
696
  Math.round(cap === undefined ? volume : Math.min(volume, cap)),
629
697
  );
@@ -704,6 +772,8 @@ export function spotify(
704
772
  async () => {
705
773
  const no = notPlaying();
706
774
  if (no) return no;
775
+ if (inactive())
776
+ return "it is not the active device, so its volume can't be changed";
707
777
  if (state.volume === undefined) return "its volume is not known";
708
778
  if (state.volume <= to) return `it was already at ${state.volume}`;
709
779
  earlier = state.volume;
@@ -716,7 +786,7 @@ export function spotify(
716
786
  }
717
787
  },
718
788
  async () => {
719
- if (state.volume !== Math.round(to))
789
+ if (!near(state.volume, Math.round(to)))
720
790
  return {
721
791
  music: "kept",
722
792
  why: shown
@@ -758,7 +828,7 @@ export function spotify(
758
828
  state = rest;
759
829
  }
760
830
  let seen: { track: string; ms: number; atMs: number } | undefined; // the last check's reported position
761
- let repairedFor: string | undefined; // at most one repair per track
831
+ let repairedFor: string | undefined; // at most one repair per play of a track
762
832
  /** Ask Soloist where it is, waiting for the answer (or the query's timeout). */
763
833
  const fresh = () =>
764
834
  write("query-get_state", {
@@ -785,7 +855,7 @@ export function spotify(
785
855
  a.track === b.track &&
786
856
  b.atMs - a.atMs >= gap &&
787
857
  Math.abs(b.ms - a.ms) < 1000;
788
- const playing = () => open && state.status === "playing" && !state.aside;
858
+ const playing = () => isOpen() && state.status === "playing" && !state.aside;
789
859
 
790
860
  /** One look while playing: a fresh position, then frozen, overrun, recovered or fine (Soloist issue #5). */
791
861
  const checkStall = async () => {
@@ -829,7 +899,7 @@ export function spotify(
829
899
  state.stalled && stallAs({ ...state.stalled, tried: what });
830
900
  // Still ours to fix: playing this track, stalled, and no duck, interrupt or person in between.
831
901
  const ours = () =>
832
- open &&
902
+ isOpen() &&
833
903
  state.status === "playing" &&
834
904
  state.track?.uri === track &&
835
905
  state.stalled?.track === track &&
@@ -867,9 +937,11 @@ export function spotify(
867
937
  /** Ask Soloist for its state again (`get_state`, or `get_auth_state` without a session); while it is unreachable, reconnect now. */
868
938
  refresh() {
869
939
  const s = socket;
870
- if (s && open && s.readyState === OPEN)
940
+ if (s && isOpen() && s.readyState === OPEN)
871
941
  return query(
872
- state.loggedIn === false || !heard ? "get_auth_state" : "get_state",
942
+ state.loggedIn === false || link === "open"
943
+ ? "get_auth_state"
944
+ : "get_state",
873
945
  );
874
946
  wake?.abort();
875
947
  },
@@ -7,7 +7,7 @@ Reads the household's spending from Up Bank's API: two months of transactions, s
7
7
  - **`client.ts`**: `upClient({ token, fetch })`. Reads: `ping`, `accounts(filter)`, `transactions(query)` following cursors to `limit` (default 1000) and `pageCap` (20) and returning `{ transactions, truncated }` so a cut-off list never looks complete, `transaction(id)`, `categories`, `tags`, `webhooks`, `webhook(id)`, `webhookLogs(id)`. Writes: `setCategory(id, categoryId | null)`, `addTags(id, labels)`, `removeTags(id, labels)`, each re-reading and returning the transaction as Up now holds it; `createWebhook`, `deleteWebhook`, `pingWebhook`. A transaction is flattened to cents (Up's `valueInBaseUnits`) and ids.
8
8
  - **`spend.ts`**, pure, one owner per number. `notSpend(t)` is the rule and says why: held, money in, `transferAccount` set (a movement between your own accounts), or a description Up gives its own movements ("Cover from", "Forward to", "Round Up", "Quick save transfer"). "Transfer to …" is not on the list: paying someone else is spend. `isSpend`, `spendByCategory` (parents and children, excluded movements counted and summed so a total can be checked), `spendByMerchant`, `uncategorised` (spend with no category; money in is categorisable too but left out), `recurring` (a heuristic: three or more months within 20% of the median), `unusual` (over twice the median of three or more earlier purchases at that merchant), `dollars` for display.
9
9
  - **`webhook.ts`**: `verifyWebhook(rawBody, signature, secret)`, the SHA-256 HMAC of the raw body in hex, Web Crypto, compared in constant time; `parseWebhookEvent(body)` gives the event type and the transaction id. A consumer's handler verifies, then calls `home.refresh("up")`.
10
- - **The integration** (`up({ token, account?, timeZone?, fetch })`): every 15 minutes it reads the categories, the accounts the filter names, and 63 days of transactions, then cuts this month and last in the home's time zone. The snapshot holds each month's spend by category and top five merchants, the ids still to categorise, and the latest 20 purchases trimmed to id, time, description, amount, category, status and tags (movements between your own accounts are left out: their descriptions name the accounts). Never kept: raw text, message, note, who spent (`performingCustomer`), account ids, balances. A 401 is `unavailable`; a read that hit its page cap is an `anomaly`, as is an amount missing or not in AUD. It never writes.
10
+ - **The integration** (`up({ token, account?, timeZone?, fetch })`, with `client` on it for a consumer's writes): every 15 minutes it reads the categories, the accounts the filter names, and 63 days of transactions, then cuts this month and last in the home's time zone. The snapshot holds each month's spend by category and top five merchants, the ids still to categorise, and the latest 20 purchases trimmed to id, time, description, amount, category, status and tags (movements between your own accounts are left out: their descriptions name the accounts). Never kept: raw text, message, note, who spent (`performingCustomer`), account ids, balances. A 401 is `unavailable`; a read that hit its page cap is an `anomaly`, as is an amount missing or not in AUD. It never writes.
11
11
  - **Config**: `{ connector: "up", key: "UP_TOKEN", account: { ownershipType: "JOINT", accountType: "TRANSACTIONAL" } }`. Accounts are chosen by type and ownership, never by id. Without `account`, every account is read; transfers between them are excluded by `transferAccount`.
12
12
  - **CLI**: `home up` (this month, last month, to categorise, top five categories), `home up transactions [--since] [--until] [--category] [--tag] [--status] [--limit] [--json|--csv]`, `categories`, `uncategorised`, `spend [--by category|merchant|recurring]`, and the writes `categorise <id> <category|none>`, `tag <id> <label> [--remove]`, `webhooks [get|logs|ping|create|delete]`. `UP_ACCOUNT` ("JOINT TRANSACTIONAL") is the filter's env form.
13
13
  - **Household filter** (`household: { categories, tags }` in the config, `UP_HOUSEHOLD="home tag:home"` in env): the totals, top merchants and recent rows count only purchases in a listed category or its parent, or with a listed tag, so a family dashboard shows groceries and utilities, not personal spending. Tag anything else `home` in the Up app to bring it in. "To categorise" still looks at every purchase, since a category is how one joins. A listed category Up does not have is an `anomaly`, not a silent zero. `inHousehold` is the one owner of the rule; `home up spend` applies it too. `webhooks create` writes the secret Up returns once to `~/.config/home/up-webhook-<id>.secret` (0600); it is never printed.
@@ -241,13 +241,16 @@ export function toSnapshot(
241
241
  };
242
242
  }
243
243
 
244
- /** Up as a home integration: two months of transactions now, then every 15 minutes. Reads only; it never writes. */
245
- export function up(config: UpConfig): Integration<UpSnapshot, "up"> {
244
+ /** 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. */
245
+ export function up(config: UpConfig): Integration<UpSnapshot, "up"> & {
246
+ client: UpClient;
247
+ } {
246
248
  const client = upClient(config);
247
249
  const clock = config.clock ?? realClock;
248
250
  const poll = poller(clock);
249
251
  return {
250
252
  name: "up",
253
+ client,
251
254
  refresh: poll.nudge,
252
255
  async run(signal, update) {
253
256
  let last: UpSnapshot | undefined;
@@ -68,7 +68,7 @@ Plain `fetch` cannot read `ftp://` (Bun 1.4.2: "protocol must be http:, https: o
68
68
 
69
69
  **Sun** (`sun.ts`). `sunNow` with [suncalc](https://github.com/mourner/suncalc) 2.1.1, which bundles its own types (degrees, azimuth clockwise from north; `@types/suncalc` describes v1 and is wrong for it). Times are for the local solar day containing `at`. On 2026-10-13 it matched BOM's own Sydney sunrise and sunset to the second. Dawn and dusk are civil (6° below the horizon), `nightEnd` and `night` astronomical (18°), `goldenHour` starts the evening's and `goldenHourEnd` ends the morning's (6° above). `rising` is before solar noon, which in suncalc's solar day means from solar midnight; `daylight` is altitude above 0°. **`sunNow(latitude, longitude, now)` is the owner**: the snapshot's `sun` is recomputed every 5 minutes, so a consumer showing `rising`, `daylight`, `next` or the position calls `sunNow` with its own clock, as `home status` does. `next` holds each of dawn, sunrise, sunset and dusk at its next occurrence after `at` (today's while still ahead, else tomorrow's), missing on a day that has none. The computed sun is the owner: Google's daily `sunEvents` are parsed only to check it, and a sunrise or sunset more than 2 minutes from the computed one (`sunDisagrees`, comparing within the solar day containing Google's time) goes into Google's `anomaly`, never into the sun.
70
70
 
71
- Every request has a 10 s timeout and the user agent `home (+https://github.com/khalido/home)`: BOM's app API gives Bun's default `Bun/1.4.2` no answer at all, so nothing here relies on the default.
71
+ Every request has a 10 s timeout and the user agent `home (+https://github.com/khalido/home.ts)`: BOM's app API gives Bun's default `Bun/1.4.2` no answer at all, so nothing here relies on the default.
72
72
 
73
73
  ## Not built, on purpose
74
74
 
@@ -246,19 +246,35 @@ export function weather(
246
246
  /** "20:20" in the home's time zone (the runtime's when unset), or "?". */
247
247
  const hhmmIn = (iso: string | undefined, timeZone?: string) =>
248
248
  iso ? localTime(Date.parse(iso), timeZone) : "?";
249
- const day = (d?: ForecastDay) =>
249
+ const day = (d?: ForecastDay, rainLeft?: number) =>
250
250
  d
251
251
  ? [
252
252
  d.minC === undefined && d.maxC === undefined
253
253
  ? ""
254
254
  : `${d.minC ?? "?"}–${d.maxC ?? "?"} °C`,
255
255
  d.text?.replace(/\.$/, "").toLowerCase() ?? "",
256
- d.rainChance === undefined ? "" : `${d.rainChance}% chance of rain`,
256
+ d.rainChance === undefined
257
+ ? ""
258
+ : rainLeft !== undefined && rainLeft < d.rainChance
259
+ ? `${rainLeft}% chance of rain the rest of the day`
260
+ : `${d.rainChance}% chance of rain`,
257
261
  ]
258
262
  .filter(Boolean)
259
263
  .join(" ")
260
264
  : "?";
261
265
 
266
+ /** 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. */
267
+ const rainLeftToday = (
268
+ w: Pick<Slices, "forecast" | "hourly"> & { timeZone?: string },
269
+ at: number,
270
+ ) => {
271
+ if (!w.hourly || w.hourly.source !== w.forecast?.source) return undefined;
272
+ const rest = dayBlocks(w.hourly, at, w.timeZone, [
273
+ { name: "rest", from: 0, to: 24 },
274
+ ])?.today[0];
275
+ return rest?.date === w.forecast.today?.date ? rest?.rainChance : undefined;
276
+ };
277
+
262
278
  /** "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. */
263
279
  export function rainText(
264
280
  rain: Slices["rain"],
@@ -310,7 +326,7 @@ export function weatherText(
310
326
  : "";
311
327
  const f = w.forecast;
312
328
  const forecast = f
313
- ? `today ${day(f.today)}, tomorrow ${day(f.tomorrow)} (${f.source}${f.modelled ? " modelled" : ""})`
329
+ ? `today ${day(f.today, rainLeftToday(w, Date.parse(sun.at)))}, tomorrow ${day(f.tomorrow)} (${f.source}${f.modelled ? " modelled" : ""})`
314
330
  : "";
315
331
  const k = w.sky;
316
332
  const skyParts = k
package/src/discover.ts CHANGED
@@ -307,6 +307,7 @@ export type CachedAddress = {
307
307
  host: string;
308
308
  port?: number;
309
309
  name?: string;
310
+ model?: string; // Cast: what kind of device, without a discovery
310
311
  seenAt: string;
311
312
  };
312
313
 
@@ -345,7 +346,7 @@ export function addressBook(
345
346
  seen(
346
347
  connector: Connector,
347
348
  id: string | undefined,
348
- address: { host: string; port?: number; name?: string },
349
+ address: { host: string; port?: number; name?: string; model?: string },
349
350
  ): void {
350
351
  if (!cache || id === undefined) return;
351
352
  const key = addressKey(connector, id);
@@ -355,14 +356,17 @@ export function addressBook(
355
356
  c.host === address.host &&
356
357
  c.port === address.port &&
357
358
  isFresh(c) &&
358
- (!address.name || address.name === c.name)
359
+ (!address.name || address.name === c.name) &&
360
+ (!address.model || address.model === c.model)
359
361
  )
360
362
  return;
361
363
  const name = address.name || c?.name;
364
+ const model = address.model || c?.model;
362
365
  entries[key] = {
363
366
  host: address.host,
364
367
  ...(address.port === undefined ? {} : { port: address.port }),
365
368
  ...(name ? { name } : {}),
369
+ ...(model ? { model } : {}),
366
370
  seenAt: new Date(now()).toISOString(),
367
371
  };
368
372
  cache.write({ ...entries });
@@ -374,7 +378,8 @@ export function addressBook(
374
378
  /**
375
379
  * Run one discovery `passes` times, `gapMs` apart, and return the union (first answer wins). `onPass` sees each pass's
376
380
  * additions as they come; `done` ends it early once what was looked for has answered. A pass that throws adds nothing;
377
- * when every pass threw, discovery never ran, so it throws the last error rather than reporting nothing found.
381
+ * when every pass threw, discovery never ran, so it throws the last error rather than reporting nothing found. An error
382
+ * `stopOn` accepts is thrown at once: no later pass could do better.
378
383
  */
379
384
  export async function discover<T = Found>(
380
385
  pass: () => Promise<T[]>,
@@ -384,6 +389,7 @@ export async function discover<T = Found>(
384
389
  gapMs?: number;
385
390
  key?: (t: T) => string;
386
391
  done?: (all: T[]) => boolean;
392
+ stopOn?: (error: unknown) => boolean;
387
393
  onPass?: (n: number, added: T[]) => void;
388
394
  signal?: AbortSignal;
389
395
  },
@@ -401,9 +407,11 @@ export async function discover<T = Found>(
401
407
  );
402
408
  if (options.signal?.aborted) break;
403
409
  ran++;
404
- const answers = await pass().catch(
405
- (error) => (errors.push(error), [] as T[]),
406
- );
410
+ const answers = await pass().catch((error) => {
411
+ if (options.stopOn?.(error)) throw error;
412
+ errors.push(error);
413
+ return [] as T[];
414
+ });
407
415
  const added = answers.filter((t) => !all.has(key(t)) && all.set(key(t), t));
408
416
  options.onPass?.(n, added);
409
417
  if (options.done?.([...all.values()])) break;
package/src/energy.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { AmberState, PriceInterval } from "./connectors/amber/client.ts";
2
2
  import type { EnphaseState } from "./connectors/enphase/client.ts";
3
3
  import type { EnphaseSnapshot } from "./connectors/enphase/index.ts";
4
+ import { kw } from "./manifest.ts";
4
5
 
5
6
  /** The home's energy right now: power from Enphase, prices from Amber, and what the grid flow costs per hour. */
6
7
  export type EnergyNow = {
@@ -112,7 +113,6 @@ export function solarLevel(
112
113
  * "balanced (-10 W)", or "" with no grid reading. Rounded for reading only; the numbers above stay as read.
113
114
  */
114
115
  export function gridText(now: EnergyNow): string {
115
- const kw = (w: number) => `${(w / 1000).toFixed(1)} kW`;
116
116
  const rate = (c: number | undefined, positive: string, negative: string) =>
117
117
  c === undefined
118
118
  ? ""
package/src/finders.ts CHANGED
@@ -31,6 +31,9 @@ import {
31
31
  // does not slow a scan. Without the tool the mDNS finders throw, naming it, rather than "nothing answered".
32
32
  // `fileCache` keeps the addresses discovery found in a file between runs, so the next run connects straight away.
33
33
 
34
+ /** The system's mDNS tool is not installed: discovery can't run until it is. */
35
+ export class MissingTool extends Error {}
36
+
34
37
  /**
35
38
  * 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`
36
39
  * says the answer is in, or `quietMs` after the last output, or at `maxMs`. The system's mDNS cache usually answers in
@@ -68,11 +71,11 @@ export function dnsSd(
68
71
  proc.on("error", (error: NodeJS.ErrnoException) => {
69
72
  (clearTimeout(timer), clearTimeout(quiet));
70
73
  reject(
71
- new Error(
72
- error.code === "ENOENT"
73
- ? `${command} not found; mDNS discovery needs it${install ? ` (${install})` : ""}`
74
- : `${command}: ${error.message}`,
75
- ),
74
+ error.code === "ENOENT"
75
+ ? new MissingTool(
76
+ `${command} not found; mDNS discovery needs it${install ? ` (${install})` : ""}`,
77
+ )
78
+ : new Error(`${command}: ${error.message}`),
76
79
  );
77
80
  });
78
81
  proc.on("close", finish);