@khalido/home 0.1.1 → 0.1.3

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 (88) hide show
  1. package/CHANGELOG.md +60 -2
  2. package/README.md +12 -12
  3. package/dist/clock.js +2 -0
  4. package/dist/config.js +3 -1
  5. package/dist/connector.d.ts +9 -2
  6. package/dist/connectors/amber/index.js +1 -0
  7. package/dist/connectors/cast/index.d.ts +10 -5
  8. package/dist/connectors/cast/index.js +36 -4
  9. package/dist/connectors/cast/schemas.d.ts +2 -0
  10. package/dist/connectors/cast/schemas.js +1 -0
  11. package/dist/connectors/cast/state.d.ts +56 -0
  12. package/dist/connectors/cast/state.js +134 -0
  13. package/dist/connectors/cast/writes.d.ts +9 -2
  14. package/dist/connectors/cast/writes.js +67 -13
  15. package/dist/connectors/daikin/index.d.ts +6 -2
  16. package/dist/connectors/daikin/index.js +21 -1
  17. package/dist/connectors/enphase/index.d.ts +5 -1
  18. package/dist/connectors/enphase/index.js +34 -2
  19. package/dist/connectors/hue/index.d.ts +6 -2
  20. package/dist/connectors/hue/index.js +39 -6
  21. package/dist/connectors/printers/index.d.ts +6 -2
  22. package/dist/connectors/printers/index.js +23 -0
  23. package/dist/connectors/spotify/changes.d.ts +1 -1
  24. package/dist/connectors/spotify/changes.js +4 -2
  25. package/dist/connectors/spotify/client.d.ts +1 -1
  26. package/dist/connectors/spotify/index.d.ts +9 -2
  27. package/dist/connectors/spotify/index.js +130 -45
  28. package/dist/connectors/up/index.d.ts +4 -2
  29. package/dist/connectors/up/index.js +3 -1
  30. package/dist/connectors/weather/bom.js +1 -0
  31. package/dist/connectors/weather/google.js +52 -9
  32. package/dist/connectors/weather/index.d.ts +2 -1
  33. package/dist/connectors/weather/index.js +7 -0
  34. package/dist/connectors/weather/source.d.ts +2 -0
  35. package/dist/devices.d.ts +27 -0
  36. package/dist/devices.js +61 -0
  37. package/dist/discover.d.ts +2 -0
  38. package/dist/discover.js +4 -1
  39. package/dist/health.d.ts +2 -1
  40. package/dist/health.js +2 -1
  41. package/dist/home.d.ts +41 -3
  42. package/dist/home.js +24 -7
  43. package/dist/index.d.ts +3 -2
  44. package/dist/index.js +2 -1
  45. package/dist/lane.js +24 -20
  46. package/docs/cast.md +1 -1
  47. package/docs/cli.md +3 -3
  48. package/docs/config.md +8 -3
  49. package/docs/decisions.md +5 -0
  50. package/docs/using.md +17 -0
  51. package/package.json +1 -1
  52. package/skills/use-home/SKILL.md +7 -7
  53. package/src/cli/cast.ts +19 -11
  54. package/src/cli/enphase.ts +5 -1
  55. package/src/cli/spotify.ts +2 -1
  56. package/src/cli/status.ts +26 -3
  57. package/src/clock.ts +1 -0
  58. package/src/config.ts +3 -1
  59. package/src/connector.ts +18 -2
  60. package/src/connectors/amber/index.ts +1 -0
  61. package/src/connectors/cast/README.md +10 -5
  62. package/src/connectors/cast/index.ts +74 -9
  63. package/src/connectors/cast/schemas.ts +1 -0
  64. package/src/connectors/cast/state.ts +189 -0
  65. package/src/connectors/cast/writes.ts +87 -17
  66. package/src/connectors/daikin/README.md +4 -0
  67. package/src/connectors/daikin/index.ts +41 -2
  68. package/src/connectors/enphase/index.ts +49 -2
  69. package/src/connectors/hue/index.ts +57 -8
  70. package/src/connectors/printers/README.md +4 -2
  71. package/src/connectors/printers/index.ts +43 -1
  72. package/src/connectors/spotify/README.md +33 -9
  73. package/src/connectors/spotify/changes.ts +4 -2
  74. package/src/connectors/spotify/client.ts +1 -1
  75. package/src/connectors/spotify/index.ts +157 -49
  76. package/src/connectors/up/README.md +1 -1
  77. package/src/connectors/up/index.ts +6 -2
  78. package/src/connectors/weather/README.md +1 -1
  79. package/src/connectors/weather/bom.ts +1 -0
  80. package/src/connectors/weather/google.ts +66 -10
  81. package/src/connectors/weather/index.ts +16 -2
  82. package/src/connectors/weather/source.ts +2 -0
  83. package/src/devices.ts +102 -0
  84. package/src/discover.ts +6 -2
  85. package/src/health.ts +4 -2
  86. package/src/home.ts +75 -8
  87. package/src/index.ts +5 -1
  88. package/src/lane.ts +23 -20
@@ -185,7 +185,7 @@ export type SpotifyState = {
185
185
  /** The playlist, album or show playing from. */
186
186
  context?: { uri: string; type?: string; name?: string };
187
187
  position?: PositionAnchor;
188
- /** 0–100, as Soloist reports it. */
188
+ /** 0–100, as Soloist reports it, rounded. Until `active` it isn't the level that will play (it reads `--initial-volume`, seen live). */
189
189
  volume?: number;
190
190
  shuffle?: boolean;
191
191
  /** off, context or track. */
@@ -1,10 +1,21 @@
1
1
  import { z } from "zod";
2
2
  import { asOf, type Clock, realClock } from "../../clock.ts";
3
3
  import { ConnectorError, errorText } from "../../connector.ts";
4
- import type { Integration, Part, Status } from "../../home.ts";
4
+ import {
5
+ type DeviceRecord,
6
+ deviceRecords,
7
+ type Integration,
8
+ type Part,
9
+ type Status,
10
+ } from "../../home.ts";
5
11
  import { writeLane } from "../../lane.ts";
6
12
  import type { Manifest } from "../../manifest.ts";
7
- import { spotifyText, stallText } from "./changes.ts";
13
+ import {
14
+ type SpeakerState,
15
+ speakerState,
16
+ spotifyText,
17
+ stallText,
18
+ } from "./changes.ts";
8
19
  import {
9
20
  commandFrame,
10
21
  fold,
@@ -99,9 +110,11 @@ export type Aside<T> = { value: T; music: Music; why?: string };
99
110
  /**
100
111
  * The writes, on the object `spotify()` returns. Each resolves once Soloist reports the change it asked for, with the state
101
112
  * 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`.
113
+ * `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
114
  */
104
115
  export type SpotifyActions = {
116
+ /** 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. */
117
+ activate(): Promise<Applied>;
105
118
  /** Play a Spotify link or URI (track, album, playlist, artist, episode, show), or resume what is loaded when none is given. */
106
119
  play(linkOrUri?: string): Promise<Applied>;
107
120
  pause(): Promise<Applied>;
@@ -137,10 +150,12 @@ const COMMAND_TIMEOUT_MS = 3000;
137
150
  const QUERY_TIMEOUT_MS = 1000;
138
151
  /** A socket that has said nothing this long after opening is closed: Soloist sends `auth_state` at once on connect. */
139
152
  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). */
153
+ /** 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
154
  const CAP_WINDOW_MS = 10_000;
142
155
  /** How long a write waits for Soloist to report the change it asked for. Loopback events arrive in milliseconds; a stated guess. */
143
156
  const CONFIRM_MS = 2000;
157
+ /** Between the two halves of `setRepeat`: back to back left repeat on `track`, 600 ms apart worked (live, 2026-11-09). */
158
+ const REPEAT_GAP_MS = 600;
144
159
  /** Between two commands. Soloist publishes no limit; it is on loopback; a stated guess. */
145
160
  const WRITE_GAP_MS = 100;
146
161
  /** 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 +164,8 @@ const STALL_CHECK_MS = 20_000;
149
164
  const OVERRUN_MS = 15_000;
150
165
  /** After a repair's seek, how long before looking whether the position moves again. */
151
166
  const REPAIR_WAIT_MS = 5000;
167
+ /** A reported volume is rounded (phone steps are sixteenths: 43.75 shows as 44), so "it took" allows this much. */
168
+ const VOLUME_SLACK = 1;
152
169
  const MIN_BACKOFF_MS = 1000;
153
170
  const MAX_BACKOFF_MS = 60_000;
154
171
 
@@ -190,15 +207,16 @@ export function spotify(
190
207
 
191
208
  let state: SpotifyState = { asOf: asOf(clock) };
192
209
  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
210
+ // 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).
211
+ let link: "down" | "open" | "heard" | "whole" = "down";
212
+ const isOpen = () => link !== "down";
196
213
  let down: string | undefined; // why the last socket ended, while there is none open
197
214
  let statusKnown = false; // Soloist has told this socket its status: only then is idle → playing an edge
198
215
  let queueAsked = false; // get_queue sent on this socket
199
216
  let signedIn = false; // signed out → in on this socket: the first status after it counts as a start if it is playing
200
217
  let capUntil = -Infinity;
201
- let capped = false; // the cap has been sent in this window
218
+ let capped = false; // the cap took in this window
219
+ let capping = false; // a lowering is on its way
202
220
  let startBuffering = false; // a start from idle is still buffering
203
221
  let capNote: string | undefined; // the last first-play lowering, in `anomaly` until the speaker goes idle
204
222
  let frameNote: string | undefined; // a frame the schema rejected, or an error nothing was waiting for
@@ -214,11 +232,10 @@ export function spotify(
214
232
  const publish = () => {
215
233
  let status: Status;
216
234
  let detail: string | undefined;
217
- if (!open)
235
+ if (link === "down")
218
236
  ((status = reported || down ? "disconnected" : "connecting"),
219
237
  (detail = down));
220
- else if (!heard || (state.loggedIn === true && !whole))
221
- status = "connecting";
238
+ else if (link !== "whole") status = "connecting";
222
239
  else
223
240
  ((status = "connected"),
224
241
  (detail = state.loggedIn === false ? WAITING : note));
@@ -251,7 +268,7 @@ export function spotify(
251
268
  ) =>
252
269
  new Promise<void>((resolve, reject) => {
253
270
  const s = socket;
254
- if (!s || !open || s.readyState !== OPEN) return reject(offline());
271
+ if (!s || !isOpen() || s.readyState !== OPEN) return reject(offline());
255
272
  if (state.loggedIn === false && !answers)
256
273
  return reject(new ConnectorError(WAITING, { code: "waiting" }));
257
274
  const stop = new AbortController();
@@ -291,12 +308,20 @@ export function spotify(
291
308
  answers?: string;
292
309
  then?: { command: string; fields: Record<string, unknown> };
293
310
  took?: (now: SpotifyState, before: SpotifyState, at: number) => boolean;
311
+ activeFirst?: boolean; // an inactive speaker goes active before this, in this write's own turn so the last transport write still wins
294
312
  };
295
313
  const send = async (body: Body) => {
314
+ if (body.activeFirst && inactive()) {
315
+ await command("activate").catch(() => {}); // best effort: the write itself says how it went
316
+ await waitFor(() => state.active === true || !isOpen(), CONFIRM_MS);
317
+ }
296
318
  const before = state;
297
319
  const at = clock.now();
298
320
  await command(body.command, body.fields, body.answers);
299
- if (body.then) await command(body.then.command, body.then.fields); // repeat's second half, still this write's turn
321
+ if (body.then) {
322
+ await clock.sleep(REPEAT_GAP_MS);
323
+ await command(body.then.command, body.then.fields); // repeat's second half, still this write's turn
324
+ }
300
325
  return { before, at };
301
326
  };
302
327
  let once = 0; // skips and queued tracks never merge: two nexts are two skips
@@ -309,7 +334,7 @@ export function spotify(
309
334
  const change = async (slot: string, body: Body): Promise<Applied> => {
310
335
  const sent = await write(slot, body);
311
336
  const took = () => body.took?.(state, sent.before, sent.at) ?? true;
312
- await waitFor(() => took() || !open, CONFIRM_MS);
337
+ await waitFor(() => took() || !isOpen(), CONFIRM_MS);
313
338
  return { state, confirmed: took() };
314
339
  };
315
340
  /** 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). */
@@ -321,12 +346,17 @@ export function spotify(
321
346
  !!s.track && s.track.uri !== b.track?.uri;
322
347
  const invalid = (message: string) =>
323
348
  new ConnectorError(message, { code: "invalid" });
349
+ const near = (volume: number | undefined, v: number) =>
350
+ volume !== undefined && Math.abs(volume - v) <= VOLUME_SLACK;
324
351
  const volumeTo = (v: number) =>
325
352
  change("volume", {
326
353
  command: "set_volume",
327
354
  fields: { volume: v },
328
- took: (s) => s.volume === v,
355
+ took: (s) => near(s.volume, v),
329
356
  });
357
+ /** Soloist accepts a `set_volume` while it isn't the active device and drops it (three times live, 2026-11-09). */
358
+ const inactive = () =>
359
+ isOpen() && state.loggedIn !== false && state.active === false;
330
360
  /** 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. */
331
361
  const ANSWERS = {
332
362
  get_state: "playback_state",
@@ -353,27 +383,50 @@ export function spotify(
353
383
  publish();
354
384
  });
355
385
 
356
- /** Within the window after a start from idle, a volume above the cap is lowered to it, once (issue #23). */
386
+ /**
387
+ * Within the window after a start signal, a volume above the cap is lowered to it (issue #23). Only while active, since
388
+ * Soloist drops it otherwise, and retried until Soloist reports it. A volume raised after that is someone's choice;
389
+ * one raised before it is reported can't be told from the start's own jump, so it is lowered too.
390
+ */
357
391
  const applyCap = () => {
358
392
  if (
359
393
  cap === undefined ||
360
394
  capped ||
395
+ capping ||
361
396
  state.volume === undefined ||
362
- clock.now() > capUntil ||
363
- state.volume <= cap
397
+ state.volume <= cap + VOLUME_SLACK ||
398
+ state.active === false ||
399
+ clock.now() > capUntil
364
400
  )
365
401
  return;
366
- capped = true;
367
- capNote = `started at volume ${state.volume}, above maxVolume ${cap}; lowered it (Soloist issue #23)`;
368
- change("volume", {
369
- command: "set_volume",
370
- fields: { volume: cap },
371
- took: (s) => s.volume === cap,
372
- }).catch(
373
- (error) => (
374
- (note = `lowering the volume to ${cap} failed: ${errorText(error)}`),
375
- publish()
376
- ),
402
+ capping = true;
403
+ const from = state.volume;
404
+ const window = capUntil;
405
+ volumeTo(cap).then(
406
+ ({ confirmed }) => {
407
+ capping = false;
408
+ if (capUntil !== window) return applyCap(); // a new start while it went: this lowering doesn't speak for that one
409
+ if (confirmed) {
410
+ capped = true;
411
+ if (state.status === "idle") return;
412
+ capNote = `started at volume ${from}, above maxVolume ${cap}; lowered it (Soloist issue #23)`;
413
+ note = undefined;
414
+ return publish();
415
+ }
416
+ if (clock.now() > capUntil) {
417
+ note = `could not lower the volume to ${cap}: Soloist has not reported it`;
418
+ return publish();
419
+ }
420
+ applyCap();
421
+ },
422
+ (error) => {
423
+ capping = false;
424
+ if (error instanceof ConnectorError && error.code === "offline") return; // the status says it
425
+ if (capUntil !== window) return applyCap(); // a new start while it went: this failure doesn't speak for that one
426
+ capped = true; // refused or unanswered: not again this window
427
+ note = `lowering the volume to ${cap} failed: ${errorText(error)}`;
428
+ publish();
429
+ },
377
430
  );
378
431
  };
379
432
 
@@ -411,11 +464,16 @@ export function spotify(
411
464
  }
412
465
  const before = state.status;
413
466
  const wasSignedIn = state.loggedIn;
467
+ const wasActive = link === "open" ? undefined : state.active; // the first frames on a socket are not a change
414
468
  state = fold(state, f, asOf(clock));
469
+ if (state.track?.uri !== repairedFor) repairedFor = undefined; // one repair per play of a track
470
+ // Going active makes a new stream, at WirePlumber's saved level (live, 2026-11-09): the likeliest moment for a loud start.
471
+ if (wasActive === false && state.active === true)
472
+ ((capUntil = clock.now() + CAP_WINDOW_MS), (capped = false));
415
473
  if (f.type === "auth_state") {
416
474
  // Signed out there is no playback_state to wait for, and a later sign-in must not send the part back to connecting.
417
- if (!heard && f.logged_in !== true) whole = true;
418
- heard = true;
475
+ if (link === "open" || (link === "heard" && f.logged_in === false))
476
+ link = f.logged_in === true ? "heard" : "whole";
419
477
  if (f.logged_in === false) statusKnown = false;
420
478
  // A first sign-in (someone picked the speaker) sends only auth_state; the docs don't promise a playback_state after it.
421
479
  else if (f.logged_in === true && wasSignedIn === false)
@@ -423,15 +481,15 @@ export function spotify(
423
481
  }
424
482
  if (f.type === "playback_state") {
425
483
  if (!queueAsked) ((queueAsked = true), query("get_queue", { limit: 10 })); // queue_changed pushes changes only, so ask once per socket
426
- whole = true;
484
+ link = "whole";
427
485
  }
428
486
  if (pending?.answers === f.type) pending.finish();
429
487
  if (
430
488
  (f.type === "playback_state" || f.type === "playback_changed") &&
431
489
  f.status !== undefined
432
490
  ) {
433
- // A start is idle → playing or buffering, or a sign-in whose first status is either: a phone picked the speaker and played (issue #23).
434
- // Soloist's jump to 100 comes about 100 ms after `playing` (issue #23), so a start through buffering opens the window again then.
491
+ // A start is idle → playing or buffering, or a sign-in whose first status is either: a phone picked the speaker and played.
492
+ // The volume can land just after `playing` (issue #23), so a start through buffering opens the window again then.
435
493
  const started =
436
494
  ((f.status === "playing" || f.status === "buffering") &&
437
495
  (statusKnown ? before === "idle" : signedIn)) ||
@@ -470,10 +528,8 @@ export function spotify(
470
528
  quiet.abort();
471
529
  signal.removeEventListener("abort", stop);
472
530
  if (socket === s) socket = undefined;
473
- const was = open;
474
- open = false;
475
- heard = false;
476
- whole = false;
531
+ const was = isOpen();
532
+ link = "down";
477
533
  queueAsked = false;
478
534
  statusKnown = false;
479
535
  signedIn = false;
@@ -488,17 +544,17 @@ export function spotify(
488
544
  // An ended socket's late events belong to no session: they must not touch the next socket's state.
489
545
  s.onopen = () => {
490
546
  if (ended) return;
491
- open = true;
547
+ link = "open";
492
548
  down = undefined;
493
549
  note = undefined; // a new socket: what failed on the last one is history
494
550
  void clock.sleep(HELLO_MS, quiet.signal).then(() => {
495
551
  if (quiet.signal.aborted) return;
496
- if (!heard)
552
+ if (link === "open")
497
553
  return (
498
554
  end(`Soloist said nothing in ${HELLO_MS / 1000} s`),
499
555
  s.close()
500
556
  );
501
- if (!whole) ((whole = true), publish()); // signed in, no playback_state: connected with what there is
557
+ if (link === "heard") ((link = "whole"), publish()); // signed in, no playback_state: connected with what there is
502
558
  });
503
559
  };
504
560
  s.onmessage = (ev) => void (ended || handle(ev.data));
@@ -518,7 +574,7 @@ export function spotify(
518
574
 
519
575
  /** Not connected or not playing: the reason `duck` and `interrupt` just run `fn`. */
520
576
  const notPlaying = () =>
521
- !open
577
+ !isOpen()
522
578
  ? "Soloist is not connected"
523
579
  : state.status === "playing" || state.status === "buffering"
524
580
  ? undefined
@@ -544,7 +600,7 @@ export function spotify(
544
600
  leave: () => Promise<Left>,
545
601
  ) => {
546
602
  const run = async (): Promise<Aside<T>> => {
547
- if (open) marked(kind);
603
+ if (isOpen()) marked(kind);
548
604
  try {
549
605
  return await within();
550
606
  } finally {
@@ -578,10 +634,20 @@ export function spotify(
578
634
  };
579
635
 
580
636
  const actions: SpotifyActions = {
637
+ async activate() {
638
+ if (isOpen() && state.loggedIn !== false && state.active === true)
639
+ return { state, confirmed: true }; // a second activate might make a new stream
640
+ return change("activate", {
641
+ command: "activate",
642
+ took: (s) => s.active === true,
643
+ });
644
+ },
645
+ // A `play` while inactive didn't take live; going active first is untested for this. `resume` worked inactive, so it doesn't.
581
646
  async play(linkOrUri) {
582
647
  if (linkOrUri === undefined)
583
648
  return change("transport", {
584
649
  command: "play",
650
+ activeFirst: true,
585
651
  took: (s) => s.status === "playing",
586
652
  });
587
653
  const uri = toSpotifyUri(linkOrUri);
@@ -589,6 +655,7 @@ export function spotify(
589
655
  return change("transport", {
590
656
  command: "play",
591
657
  fields: { uri },
658
+ activeFirst: true,
592
659
  took: (s) =>
593
660
  s.status === "playing" &&
594
661
  (s.track?.uri === uri || s.context?.uri === uri),
@@ -632,6 +699,11 @@ export function spotify(
632
699
  async setVolume(volume) {
633
700
  if (!Number.isFinite(volume) || volume < 0 || volume > 100)
634
701
  throw invalid(`volume is 0–100, not ${volume}`);
702
+ if (inactive())
703
+ throw new ConnectorError(
704
+ "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)",
705
+ { code: "inactive" },
706
+ );
635
707
  return volumeTo(
636
708
  Math.round(cap === undefined ? volume : Math.min(volume, cap)),
637
709
  );
@@ -673,6 +745,9 @@ export function spotify(
673
745
  async () => {
674
746
  const no = notPlaying();
675
747
  if (no) return no;
748
+ // "playing" while inactive is the account playing on another speaker (live, 2026-11-10): a pause would stop that one.
749
+ if (inactive())
750
+ return "it is not the active device, so the music playing is on another speaker";
676
751
  track = state.track?.uri;
677
752
  try {
678
753
  shown = (await actions.pause()).confirmed; // a pause that never shows still runs fn: what interrupts matters more
@@ -712,6 +787,8 @@ export function spotify(
712
787
  async () => {
713
788
  const no = notPlaying();
714
789
  if (no) return no;
790
+ if (inactive())
791
+ return "it is not the active device, so its volume can't be changed";
715
792
  if (state.volume === undefined) return "its volume is not known";
716
793
  if (state.volume <= to) return `it was already at ${state.volume}`;
717
794
  earlier = state.volume;
@@ -724,7 +801,7 @@ export function spotify(
724
801
  }
725
802
  },
726
803
  async () => {
727
- if (state.volume !== Math.round(to))
804
+ if (!near(state.volume, Math.round(to)))
728
805
  return {
729
806
  music: "kept",
730
807
  why: shown
@@ -766,7 +843,7 @@ export function spotify(
766
843
  state = rest;
767
844
  }
768
845
  let seen: { track: string; ms: number; atMs: number } | undefined; // the last check's reported position
769
- let repairedFor: string | undefined; // at most one repair per track
846
+ let repairedFor: string | undefined; // at most one repair per play of a track
770
847
  /** Ask Soloist where it is, waiting for the answer (or the query's timeout). */
771
848
  const fresh = () =>
772
849
  write("query-get_state", {
@@ -793,7 +870,7 @@ export function spotify(
793
870
  a.track === b.track &&
794
871
  b.atMs - a.atMs >= gap &&
795
872
  Math.abs(b.ms - a.ms) < 1000;
796
- const playing = () => open && state.status === "playing" && !state.aside;
873
+ const playing = () => isOpen() && state.status === "playing" && !state.aside;
797
874
 
798
875
  /** One look while playing: a fresh position, then frozen, overrun, recovered or fine (Soloist issue #5). */
799
876
  const checkStall = async () => {
@@ -837,7 +914,7 @@ export function spotify(
837
914
  state.stalled && stallAs({ ...state.stalled, tried: what });
838
915
  // Still ours to fix: playing this track, stalled, and no duck, interrupt or person in between.
839
916
  const ours = () =>
840
- open &&
917
+ isOpen() &&
841
918
  state.status === "playing" &&
842
919
  state.track?.uri === track &&
843
920
  state.stalled?.track === track &&
@@ -875,9 +952,11 @@ export function spotify(
875
952
  /** Ask Soloist for its state again (`get_state`, or `get_auth_state` without a session); while it is unreachable, reconnect now. */
876
953
  refresh() {
877
954
  const s = socket;
878
- if (s && open && s.readyState === OPEN)
955
+ if (s && isOpen() && s.readyState === OPEN)
879
956
  return query(
880
- state.loggedIn === false || !heard ? "get_auth_state" : "get_state",
957
+ state.loggedIn === false || link === "open"
958
+ ? "get_auth_state"
959
+ : "get_state",
881
960
  );
882
961
  wake?.abort();
883
962
  },
@@ -935,3 +1014,32 @@ export const manifest: Manifest<SpotifyState> = {
935
1014
  ? [spotifyText(state)]
936
1015
  : [],
937
1016
  };
1017
+
1018
+ /** The Soloist speaker as a home device: `state` is `speakerState`'s word; `detail` its `SpotifyState`. */
1019
+ export type SpotifyHomeDevice = DeviceRecord<
1020
+ "spotify",
1021
+ "speaker",
1022
+ SpeakerState,
1023
+ SpotifyState
1024
+ >;
1025
+
1026
+ /** The one speaker, once Soloist has said something. Its name is Soloist's `--device-name`, the one phones show. */
1027
+ export function spotifyHomeDevices(
1028
+ part: Part<SpotifyState> | undefined,
1029
+ ): SpotifyHomeDevice[] {
1030
+ const s = part?.state;
1031
+ if (!s || (s.loggedIn === undefined && s.status === undefined)) return [];
1032
+ return deviceRecords<SpotifyHomeDevice>(part, [
1033
+ {
1034
+ connector: "spotify",
1035
+ id: "spotify",
1036
+ name: s.deviceName ?? "Soloist",
1037
+ kind: "speaker",
1038
+ available: true,
1039
+ state: speakerState(part),
1040
+ asOf: s.asOf,
1041
+ text: spotifyText(s),
1042
+ detail: s,
1043
+ },
1044
+ ]);
1045
+ }
@@ -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;
@@ -288,6 +291,7 @@ export function up(config: UpConfig): Integration<UpSnapshot, "up"> {
288
291
  if (error instanceof UpError && error.status === 401)
289
292
  return update({
290
293
  status: "unavailable",
294
+ reason: "auth",
291
295
  detail: "Up rejected the token",
292
296
  state: last,
293
297
  });
@@ -62,7 +62,7 @@ Plain `fetch` cannot read `ftp://` (Bun 1.4.2: "protocol must be http:, https: o
62
62
 
63
63
  **Open-Meteo** (`open-meteo.ts`, [docs](https://open-meteo.com/en/docs)). One keyless JSON call per poll: `current` is 15-minutely model data (`interval: 900`), so it is read every poll, and the daily and hourly parts, from models that update hourly at best (6-hourly for IFS), are added to one call an hour. That call reads 48 hours from today's local midnight (`forecast_days=2`) for `hourly`; `sky.hours` is the 6 from the hour it was read. Open-Meteo gives local times and one `utc_offset_seconds`, and applies that offset to every time, across a daylight-saving change too (it returned 02:00 on the night Sydney skipped it; live, 2026-10-16), so `wall − offset` is right for all of them. `dayBlocks` labels each UTC hour in the real zone. 96 calls a day at the default, against 10,000 free. No `models` parameter: [Open-Meteo's BOM page](https://open-meteo.com/en/docs/bom-api) says BOM's open-data delivery is still suspended, and its `bom_access_global` answers only nulls. Radiation is the mean over the preceding interval. `precipitation` is the last 15 minutes, so `now.rainMm` stays empty rather than pretending to be since 9am. 1.3 s a call from Sydney, nearly all of it the TLS handshake with a server in Germany. Plain `fetch` of the JSON API, not the `openmeteo` SDK: the SDK forces FlatBuffers, reads variables by position, calls the global `fetch` (no injection for tests) and adds about 700 KB.
64
64
 
65
- **Google Weather** (`google.ts`, [docs](https://developers.google.com/maps/documentation/weather)). Built from the docs' example responses (`test/fixtures/weather/google-*.json`, the complete objects only; the docs elide the rest) and checked live against Sydney on 2026-10-13: `now`, `forecast` and `sky` in 1.19 s. `google({ key })` reads `forecast/minutes:lookup` every 15 minutes (its floor), `currentConditions:lookup` every 30 and `forecast/days:lookup?days=2` and `forecast/hours:lookup?hours=6` hourly, since Google documents nothing that changes faster. That is 192 calls a day, about 5,800–5,950 a month. Google bills each call as one Weather Usage event: 10,000 free a month, then $0.15 per 1,000 ([pricing](https://developers.google.com/maps/billing-and-pricing/pricing)). Any other use of the same key shares that quota. Whether the minute forecast bills as the same event is unverified: its docs page shows no price. Every slice is `modelled: true`: the docs never say it is observed. A day's rain chance is the higher of its day and night halves, its amount their sum; `currentConditionsHistory.qpf` is a rolling 24 hours, so it is not used as rain since 9am. A 400, 401 or 403 stops the source (`stop`), with no retry loop. The key goes in the query string and never into a message.
65
+ **Google Weather** (`google.ts`, [docs](https://developers.google.com/maps/documentation/weather)). Built from the docs' example responses (`test/fixtures/weather/google-*.json`, the complete objects only; the docs elide the rest) and checked live against Sydney on 2026-10-13: `now`, `forecast` and `sky` in 1.19 s. `google({ key })` reads `forecast/minutes:lookup` every 15 minutes (its floor), `currentConditions:lookup` every 30 and `forecast/days:lookup?days=2` and `forecast/hours:lookup?hours=6` hourly, since Google documents nothing that changes faster. That is 192 calls a day, about 5,800–5,950 a month. Google bills each call as one Weather Usage event: 10,000 free a month, then $0.15 per 1,000 ([pricing](https://developers.google.com/maps/billing-and-pricing/pricing)). Any other use of the same key shares that quota. The key goes in the `X-Goog-Api-Key` header: all four lookups answered 200 that way, live against a public Sydney point on 2026-11-10 (Bun 1.4.4). A bad key and a bad latitude both come back HTTP 400 `INVALID_ARGUMENT`; only the key's error body carries `API_KEY_INVALID`, which is how the source tells `auth` from `config`, and no key at all is 403. Other key failures (expired, the API not enabled) are assumed to be 401, 403 or an `API_KEY_…` reason: unverified. Whether the minute forecast bills as the same event is unverified: its docs page shows no price. Every slice is `modelled: true`: the docs never say it is observed. A day's rain chance is the higher of its day and night halves, its amount their sum; `currentConditionsHistory.qpf` is a rolling 24 hours, so it is not used as rain since 9am. A rejected key or a refused request stops the source (`stop`, with `stopReason`), with no retry loop.
66
66
 
67
67
  **Google minute forecast** (`toRain` in `google.ts`, [docs](https://developers.google.com/maps/documentation/weather/minute-forecast)). `GET /v1/forecast/minutes:lookup` for a point returns `overallPredictionTimeframe` and 24 `segments` (6 hours) of 15 minutes, each with `type` (`NONE`, `RAIN`, `SNOW`, `HAIL`), `probability` 0–100, `qpf` in mm and `intensity`. Probed for Sydney CBD on 2026-10-15 (curl): HTTP 200 in 1.0 s, every segment dry; the first segment began 12 minutes before the forecast's start. Google calls it Experimental, so its failure is a `problem` on `rain` and never stops the other slices. The docs do not list its coverage by country, say nothing of how it is made, and allow keeping minute values for one hour at most: `rain` owns 15 minutes of age on top of one poll, so a stale slice is dropped, not served (with a `pollMinutes` above 45 the slice outlives that hour: keep it at 15), and the test fixture is invented values in the real shape. We keep the segments that start within 2 hours (`RAIN_HORIZON_MINUTES`). **`rainSoon(rain, at, windowMin = 120)` is the owner**: when the first wet stretch starts and ends, its chance, amount and weight, and `coveredMin`, how much of the window the forecast covers. A segment with no `type` is unknown, not dry. Next: if the minute forecast fails, fall back to the hourly rain chance (`forecast/hours:lookup` already carries `precipitation`; not read yet).
68
68
 
@@ -284,6 +284,7 @@ export function bom(options: BomOptions = {}): WeatherSource {
284
284
  if (!options.fetch && base.startsWith("ftp:"))
285
285
  return {
286
286
  stop: "bom: plain fetch cannot read ftp://; pass fetch: ftpFetch() from @khalido/home/weather/ftp",
287
+ stopReason: "config",
287
288
  nextAt: Infinity,
288
289
  };
289
290
  if (force || at >= obsDue) {
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { type ConnectorError, getJson } from "../../connector.ts";
2
+ import { ConnectorError, fetchTimeout } from "../../connector.ts";
3
3
  import { RAIN_INTENSITIES } from "./rain.ts";
4
4
  import { sunDisagrees } from "./sun.ts";
5
5
  import {
@@ -324,6 +324,33 @@ export type GoogleOptions = {
324
324
  export const MIN_POLL_MINUTES = 15;
325
325
  export const CURRENT_MINUTES = 30;
326
326
 
327
+ /**
328
+ * Why Google refused: the `reason` of its error body's ErrorInfo ("API_KEY_INVALID"), since the status alone doesn't say.
329
+ * A bad key and a bad latitude are both HTTP 400 INVALID_ARGUMENT; only the key's carries a reason (live, 2026-11-10).
330
+ */
331
+ async function refusal(res: Response): Promise<string | undefined> {
332
+ try {
333
+ const body = (await res.json()) as {
334
+ error?: { details?: { reason?: unknown }[] };
335
+ };
336
+ const reason = body.error?.details?.find((d) => d.reason)?.reason;
337
+ return typeof reason === "string" ? reason : undefined;
338
+ } catch {
339
+ return undefined; // not JSON: the status is all there is
340
+ }
341
+ }
342
+
343
+ /** Google's refusal: the HTTP status, and `why`, the reason its error body gave. */
344
+ class GoogleError extends ConnectorError {
345
+ constructor(
346
+ message: string,
347
+ status: number,
348
+ readonly why?: string,
349
+ ) {
350
+ super(message, { status });
351
+ }
352
+ }
353
+
327
354
  /**
328
355
  * Google Weather as a source: modelled `now`, `forecast` (10 days available, 2 read), `sky` without radiation, and `rain` from the
329
356
  * minute forecast (Experimental at Google, so its failure is a `problem` on `rain` and never stops the other slices).
@@ -353,12 +380,28 @@ export function google(options: GoogleOptions): WeatherSource {
353
380
  signal,
354
381
  }): Promise<SourceReading> {
355
382
  const q = (extra: string) =>
356
- `key=${encodeURIComponent(options.key)}&location.latitude=${place.latitude}&location.longitude=${place.longitude}${extra}`;
357
- const json = (path: string) =>
358
- getJson(fetcher, `${base}/${path}`, {
359
- init: { headers: { "user-agent": USER_AGENT } },
360
- signal,
361
- });
383
+ `location.latitude=${place.latitude}&location.longitude=${place.longitude}${extra}`;
384
+ // The key goes in a header, never the URL, so no error, log or proxy that shows a URL can show it (all four lookups answered 200 this way, live 2026-11-10).
385
+ const json = async (path: string): Promise<unknown> => {
386
+ const res = await fetchTimeout(
387
+ fetcher,
388
+ `${base}/${path}`,
389
+ {
390
+ headers: {
391
+ "user-agent": USER_AGENT,
392
+ "x-goog-api-key": options.key,
393
+ },
394
+ },
395
+ { signal },
396
+ );
397
+ if (res.ok) return res.json();
398
+ const why = await refusal(res);
399
+ throw new GoogleError(
400
+ `HTTP ${res.status}${why ? ` ${why}` : ""} on GET /${path.split("?")[0]}`,
401
+ res.status,
402
+ why,
403
+ );
404
+ };
362
405
  // Started first and never throws: a failed minute forecast must not sink the rest.
363
406
  const minutes = json(`forecast/minutes:lookup?${q("")}`).then(
364
407
  (value) => ({ value }),
@@ -385,10 +428,23 @@ export function google(options: GoogleOptions): WeatherSource {
385
428
  slowDue = nextPoll(at, Math.max(pollMinutes, CURRENT_MINUTES));
386
429
  }
387
430
  } catch (error) {
388
- const status = (error as ConnectorError).status;
389
- if (status === 400 || status === 401 || status === 403)
431
+ // Only a GoogleError is Google's answer; a timeout or an abort is a DOMException, with fields of its own.
432
+ const { status, why } =
433
+ error instanceof GoogleError
434
+ ? error
435
+ : ({} as { status?: number; why?: string });
436
+ const said = `HTTP ${status}${why ? ` ${why}` : ""}`;
437
+ if (status === 401 || status === 403 || why?.startsWith("API_KEY_"))
438
+ return {
439
+ stop: `google: the key was rejected (${said})`,
440
+ stopReason: "auth",
441
+ nextAt: Infinity,
442
+ };
443
+ // A 400 with a good key is the request itself, and here that is the location: asking again won't change it.
444
+ if (status === 400)
390
445
  return {
391
- stop: `google: key rejected or request refused (HTTP ${status})`,
446
+ stop: `google: the request was refused (${said}); check the latitude and longitude`,
447
+ stopReason: "config",
392
448
  nextAt: Infinity,
393
449
  };
394
450
  problem = `google: ${(error as Error).message}`; // the old slices stay; the minute forecast below is still used