@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
@@ -1,8 +1,9 @@
1
1
  import { z } from "zod";
2
2
  import { asOf, realClock } from "../../clock.js";
3
3
  import { ConnectorError, errorText } from "../../connector.js";
4
+ import { deviceRecords, } from "../../home.js";
4
5
  import { writeLane } from "../../lane.js";
5
- import { spotifyText, stallText } from "./changes.js";
6
+ import { speakerState, spotifyText, stallText, } from "./changes.js";
6
7
  import { commandFrame, fold, Frame, FRAME_TYPES, OPEN, oddities, positionMs, toSpotifyUri, } from "./client.js";
7
8
  export { positionMs, toSpotifyUri, } from "./client.js";
8
9
  export { speakerState, spotifyChanges, spotifyText, } from "./changes.js";
@@ -33,10 +34,12 @@ const COMMAND_TIMEOUT_MS = 3000;
33
34
  const QUERY_TIMEOUT_MS = 1000;
34
35
  /** A socket that has said nothing this long after opening is closed: Soloist sends `auth_state` at once on connect. */
35
36
  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). */
37
+ /** 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
38
  const CAP_WINDOW_MS = 10_000;
38
39
  /** How long a write waits for Soloist to report the change it asked for. Loopback events arrive in milliseconds; a stated guess. */
39
40
  const CONFIRM_MS = 2000;
41
+ /** Between the two halves of `setRepeat`: back to back left repeat on `track`, 600 ms apart worked (live, 2026-11-09). */
42
+ const REPEAT_GAP_MS = 600;
40
43
  /** Between two commands. Soloist publishes no limit; it is on loopback; a stated guess. */
41
44
  const WRITE_GAP_MS = 100;
42
45
  /** 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 +48,8 @@ const STALL_CHECK_MS = 20_000;
45
48
  const OVERRUN_MS = 15_000;
46
49
  /** After a repair's seek, how long before looking whether the position moves again. */
47
50
  const REPAIR_WAIT_MS = 5000;
51
+ /** A reported volume is rounded (phone steps are sixteenths: 43.75 shows as 44), so "it took" allows this much. */
52
+ const VOLUME_SLACK = 1;
48
53
  const MIN_BACKOFF_MS = 1000;
49
54
  const MAX_BACKOFF_MS = 60_000;
50
55
  /** 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 +83,16 @@ export function spotify(config = {}) {
78
83
  const checkMs = config.stallCheckMs ?? STALL_CHECK_MS;
79
84
  let state = { asOf: asOf(clock) };
80
85
  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
86
+ // 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).
87
+ let link = "down";
88
+ const isOpen = () => link !== "down";
84
89
  let down; // why the last socket ended, while there is none open
85
90
  let statusKnown = false; // Soloist has told this socket its status: only then is idle → playing an edge
86
91
  let queueAsked = false; // get_queue sent on this socket
87
92
  let signedIn = false; // signed out → in on this socket: the first status after it counts as a start if it is playing
88
93
  let capUntil = -Infinity;
89
- let capped = false; // the cap has been sent in this window
94
+ let capped = false; // the cap took in this window
95
+ let capping = false; // a lowering is on its way
90
96
  let startBuffering = false; // a start from idle is still buffering
91
97
  let capNote; // the last first-play lowering, in `anomaly` until the speaker goes idle
92
98
  let frameNote; // a frame the schema rejected, or an error nothing was waiting for
@@ -99,10 +105,10 @@ export function spotify(config = {}) {
99
105
  const publish = () => {
100
106
  let status;
101
107
  let detail;
102
- if (!open)
108
+ if (link === "down")
103
109
  ((status = reported || down ? "disconnected" : "connecting"),
104
110
  (detail = down));
105
- else if (!heard || (state.loggedIn === true && !whole))
111
+ else if (link !== "whole")
106
112
  status = "connecting";
107
113
  else
108
114
  ((status = "connected"),
@@ -128,7 +134,7 @@ export function spotify(config = {}) {
128
134
  */
129
135
  const command = (name, fields = {}, answers) => new Promise((resolve, reject) => {
130
136
  const s = socket;
131
- if (!s || !open || s.readyState !== OPEN)
137
+ if (!s || !isOpen() || s.readyState !== OPEN)
132
138
  return reject(offline());
133
139
  if (state.loggedIn === false && !answers)
134
140
  return reject(new ConnectorError(WAITING, { code: "waiting" }));
@@ -157,11 +163,17 @@ export function spotify(config = {}) {
157
163
  p.finish(new ConnectorError(`Soloist did not answer ${name} in ${ms / 1000} s`, { code: "timeout" })));
158
164
  });
159
165
  const send = async (body) => {
166
+ if (body.activeFirst && inactive()) {
167
+ await command("activate").catch(() => { }); // best effort: the write itself says how it went
168
+ await waitFor(() => state.active === true || !isOpen(), CONFIRM_MS);
169
+ }
160
170
  const before = state;
161
171
  const at = clock.now();
162
172
  await command(body.command, body.fields, body.answers);
163
- if (body.then)
173
+ if (body.then) {
174
+ await clock.sleep(REPEAT_GAP_MS);
164
175
  await command(body.then.command, body.then.fields); // repeat's second half, still this write's turn
176
+ }
165
177
  return { before, at };
166
178
  };
167
179
  let once = 0; // skips and queued tracks never merge: two nexts are two skips
@@ -174,7 +186,7 @@ export function spotify(config = {}) {
174
186
  const change = async (slot, body) => {
175
187
  const sent = await write(slot, body);
176
188
  const took = () => body.took?.(state, sent.before, sent.at) ?? true;
177
- await waitFor(() => took() || !open, CONFIRM_MS);
189
+ await waitFor(() => took() || !isOpen(), CONFIRM_MS);
178
190
  return { state, confirmed: took() };
179
191
  };
180
192
  /** 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). */
@@ -183,11 +195,14 @@ export function spotify(config = {}) {
183
195
  (positionMs(b, at) ?? 0) >= 5000;
184
196
  const newTrack = (s, b) => !!s.track && s.track.uri !== b.track?.uri;
185
197
  const invalid = (message) => new ConnectorError(message, { code: "invalid" });
198
+ const near = (volume, v) => volume !== undefined && Math.abs(volume - v) <= VOLUME_SLACK;
186
199
  const volumeTo = (v) => change("volume", {
187
200
  command: "set_volume",
188
201
  fields: { volume: v },
189
- took: (s) => s.volume === v,
202
+ took: (s) => near(s.volume, v),
190
203
  });
204
+ /** Soloist accepts a `set_volume` while it isn't the active device and drops it (three times live, 2026-11-09). */
205
+ const inactive = () => isOpen() && state.loggedIn !== false && state.active === false;
191
206
  /** 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. */
192
207
  const ANSWERS = {
193
208
  get_state: "playback_state",
@@ -207,22 +222,50 @@ export function spotify(config = {}) {
207
222
  frameNote = `${name} failed: ${errorText(error)}`;
208
223
  publish();
209
224
  });
210
- /** Within the window after a start from idle, a volume above the cap is lowered to it, once (issue #23). */
225
+ /**
226
+ * Within the window after a start signal, a volume above the cap is lowered to it (issue #23). Only while active, since
227
+ * Soloist drops it otherwise, and retried until Soloist reports it. A volume raised after that is someone's choice;
228
+ * one raised before it is reported can't be told from the start's own jump, so it is lowered too.
229
+ */
211
230
  const applyCap = () => {
212
231
  if (cap === undefined ||
213
232
  capped ||
233
+ capping ||
214
234
  state.volume === undefined ||
215
- clock.now() > capUntil ||
216
- state.volume <= cap)
235
+ state.volume <= cap + VOLUME_SLACK ||
236
+ state.active === false ||
237
+ clock.now() > capUntil)
217
238
  return;
218
- capped = true;
219
- capNote = `started at volume ${state.volume}, above maxVolume ${cap}; lowered it (Soloist issue #23)`;
220
- change("volume", {
221
- command: "set_volume",
222
- fields: { volume: cap },
223
- took: (s) => s.volume === cap,
224
- }).catch((error) => ((note = `lowering the volume to ${cap} failed: ${errorText(error)}`),
225
- publish()));
239
+ capping = true;
240
+ const from = state.volume;
241
+ const window = capUntil;
242
+ volumeTo(cap).then(({ confirmed }) => {
243
+ capping = false;
244
+ if (capUntil !== window)
245
+ return applyCap(); // a new start while it went: this lowering doesn't speak for that one
246
+ if (confirmed) {
247
+ capped = true;
248
+ if (state.status === "idle")
249
+ return;
250
+ capNote = `started at volume ${from}, above maxVolume ${cap}; lowered it (Soloist issue #23)`;
251
+ note = undefined;
252
+ return publish();
253
+ }
254
+ if (clock.now() > capUntil) {
255
+ note = `could not lower the volume to ${cap}: Soloist has not reported it`;
256
+ return publish();
257
+ }
258
+ applyCap();
259
+ }, (error) => {
260
+ capping = false;
261
+ if (error instanceof ConnectorError && error.code === "offline")
262
+ return; // the status says it
263
+ if (capUntil !== window)
264
+ return applyCap(); // a new start while it went: this failure doesn't speak for that one
265
+ capped = true; // refused or unanswered: not again this window
266
+ note = `lowering the volume to ${cap} failed: ${errorText(error)}`;
267
+ publish();
268
+ });
226
269
  };
227
270
  const handle = (data) => {
228
271
  let raw;
@@ -254,12 +297,17 @@ export function spotify(config = {}) {
254
297
  }
255
298
  const before = state.status;
256
299
  const wasSignedIn = state.loggedIn;
300
+ const wasActive = link === "open" ? undefined : state.active; // the first frames on a socket are not a change
257
301
  state = fold(state, f, asOf(clock));
302
+ if (state.track?.uri !== repairedFor)
303
+ repairedFor = undefined; // one repair per play of a track
304
+ // Going active makes a new stream, at WirePlumber's saved level (live, 2026-11-09): the likeliest moment for a loud start.
305
+ if (wasActive === false && state.active === true)
306
+ ((capUntil = clock.now() + CAP_WINDOW_MS), (capped = false));
258
307
  if (f.type === "auth_state") {
259
308
  // Signed out there is no playback_state to wait for, and a later sign-in must not send the part back to connecting.
260
- if (!heard && f.logged_in !== true)
261
- whole = true;
262
- heard = true;
309
+ if (link === "open" || (link === "heard" && f.logged_in === false))
310
+ link = f.logged_in === true ? "heard" : "whole";
263
311
  if (f.logged_in === false)
264
312
  statusKnown = false;
265
313
  // A first sign-in (someone picked the speaker) sends only auth_state; the docs don't promise a playback_state after it.
@@ -269,14 +317,14 @@ export function spotify(config = {}) {
269
317
  if (f.type === "playback_state") {
270
318
  if (!queueAsked)
271
319
  ((queueAsked = true), query("get_queue", { limit: 10 })); // queue_changed pushes changes only, so ask once per socket
272
- whole = true;
320
+ link = "whole";
273
321
  }
274
322
  if (pending?.answers === f.type)
275
323
  pending.finish();
276
324
  if ((f.type === "playback_state" || f.type === "playback_changed") &&
277
325
  f.status !== undefined) {
278
- // 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).
279
- // Soloist's jump to 100 comes about 100 ms after `playing` (issue #23), so a start through buffering opens the window again then.
326
+ // A start is idle → playing or buffering, or a sign-in whose first status is either: a phone picked the speaker and played.
327
+ // The volume can land just after `playing` (issue #23), so a start through buffering opens the window again then.
280
328
  const started = ((f.status === "playing" || f.status === "buffering") &&
281
329
  (statusKnown ? before === "idle" : signedIn)) ||
282
330
  (f.status === "playing" && startBuffering);
@@ -319,10 +367,8 @@ export function spotify(config = {}) {
319
367
  signal.removeEventListener("abort", stop);
320
368
  if (socket === s)
321
369
  socket = undefined;
322
- const was = open;
323
- open = false;
324
- heard = false;
325
- whole = false;
370
+ const was = isOpen();
371
+ link = "down";
326
372
  queueAsked = false;
327
373
  statusKnown = false;
328
374
  signedIn = false;
@@ -339,17 +385,17 @@ export function spotify(config = {}) {
339
385
  s.onopen = () => {
340
386
  if (ended)
341
387
  return;
342
- open = true;
388
+ link = "open";
343
389
  down = undefined;
344
390
  note = undefined; // a new socket: what failed on the last one is history
345
391
  void clock.sleep(HELLO_MS, quiet.signal).then(() => {
346
392
  if (quiet.signal.aborted)
347
393
  return;
348
- if (!heard)
394
+ if (link === "open")
349
395
  return (end(`Soloist said nothing in ${HELLO_MS / 1000} s`),
350
396
  s.close());
351
- if (!whole)
352
- ((whole = true), publish()); // signed in, no playback_state: connected with what there is
397
+ if (link === "heard")
398
+ ((link = "whole"), publish()); // signed in, no playback_state: connected with what there is
353
399
  });
354
400
  };
355
401
  s.onmessage = (ev) => void (ended || handle(ev.data));
@@ -367,7 +413,7 @@ export function spotify(config = {}) {
367
413
  waiters.delete(check);
368
414
  };
369
415
  /** Not connected or not playing: the reason `duck` and `interrupt` just run `fn`. */
370
- const notPlaying = () => !open
416
+ const notPlaying = () => !isOpen()
371
417
  ? "Soloist is not connected"
372
418
  : state.status === "playing" || state.status === "buffering"
373
419
  ? undefined
@@ -388,7 +434,7 @@ export function spotify(config = {}) {
388
434
  */
389
435
  const aside = (kind, fn, enter, leave) => {
390
436
  const run = async () => {
391
- if (open)
437
+ if (isOpen())
392
438
  marked(kind);
393
439
  try {
394
440
  return await within();
@@ -423,10 +469,20 @@ export function spotify(config = {}) {
423
469
  why: failed(`sent the ${what}; Soloist has not reported it`),
424
470
  };
425
471
  const actions = {
472
+ async activate() {
473
+ if (isOpen() && state.loggedIn !== false && state.active === true)
474
+ return { state, confirmed: true }; // a second activate might make a new stream
475
+ return change("activate", {
476
+ command: "activate",
477
+ took: (s) => s.active === true,
478
+ });
479
+ },
480
+ // A `play` while inactive didn't take live; going active first is untested for this. `resume` worked inactive, so it doesn't.
426
481
  async play(linkOrUri) {
427
482
  if (linkOrUri === undefined)
428
483
  return change("transport", {
429
484
  command: "play",
485
+ activeFirst: true,
430
486
  took: (s) => s.status === "playing",
431
487
  });
432
488
  const uri = toSpotifyUri(linkOrUri);
@@ -435,6 +491,7 @@ export function spotify(config = {}) {
435
491
  return change("transport", {
436
492
  command: "play",
437
493
  fields: { uri },
494
+ activeFirst: true,
438
495
  took: (s) => s.status === "playing" &&
439
496
  (s.track?.uri === uri || s.context?.uri === uri),
440
497
  });
@@ -471,6 +528,8 @@ export function spotify(config = {}) {
471
528
  async setVolume(volume) {
472
529
  if (!Number.isFinite(volume) || volume < 0 || volume > 100)
473
530
  throw invalid(`volume is 0–100, not ${volume}`);
531
+ if (inactive())
532
+ 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" });
474
533
  return volumeTo(Math.round(cap === undefined ? volume : Math.min(volume, cap)));
475
534
  },
476
535
  async queue(track) {
@@ -507,6 +566,9 @@ export function spotify(config = {}) {
507
566
  const no = notPlaying();
508
567
  if (no)
509
568
  return no;
569
+ // "playing" while inactive is the account playing on another speaker (live, 2026-11-10): a pause would stop that one.
570
+ if (inactive())
571
+ return "it is not the active device, so the music playing is on another speaker";
510
572
  track = state.track?.uri;
511
573
  try {
512
574
  shown = (await actions.pause()).confirmed; // a pause that never shows still runs fn: what interrupts matters more
@@ -544,6 +606,8 @@ export function spotify(config = {}) {
544
606
  const no = notPlaying();
545
607
  if (no)
546
608
  return no;
609
+ if (inactive())
610
+ return "it is not the active device, so its volume can't be changed";
547
611
  if (state.volume === undefined)
548
612
  return "its volume is not known";
549
613
  if (state.volume <= to)
@@ -556,7 +620,7 @@ export function spotify(config = {}) {
556
620
  return failed(`lowering the volume for duck failed: ${errorText(error)}`);
557
621
  }
558
622
  }, async () => {
559
- if (state.volume !== Math.round(to))
623
+ if (!near(state.volume, Math.round(to)))
560
624
  return {
561
625
  music: "kept",
562
626
  why: shown
@@ -593,7 +657,7 @@ export function spotify(config = {}) {
593
657
  state = rest;
594
658
  }
595
659
  let seen; // the last check's reported position
596
- let repairedFor; // at most one repair per track
660
+ let repairedFor; // at most one repair per play of a track
597
661
  /** Ask Soloist where it is, waiting for the answer (or the query's timeout). */
598
662
  const fresh = () => write("query-get_state", {
599
663
  command: "get_state",
@@ -614,7 +678,7 @@ export function spotify(config = {}) {
614
678
  a.track === b.track &&
615
679
  b.atMs - a.atMs >= gap &&
616
680
  Math.abs(b.ms - a.ms) < 1000;
617
- const playing = () => open && state.status === "playing" && !state.aside;
681
+ const playing = () => isOpen() && state.status === "playing" && !state.aside;
618
682
  /** One look while playing: a fresh position, then frozen, overrun, recovered or fine (Soloist issue #5). */
619
683
  const checkStall = async () => {
620
684
  if (!playing())
@@ -654,7 +718,7 @@ export function spotify(config = {}) {
654
718
  repairedFor = track;
655
719
  const tried = (what) => state.stalled && stallAs({ ...state.stalled, tried: what });
656
720
  // Still ours to fix: playing this track, stalled, and no duck, interrupt or person in between.
657
- const ours = () => open &&
721
+ const ours = () => isOpen() &&
658
722
  state.status === "playing" &&
659
723
  state.track?.uri === track &&
660
724
  state.stalled?.track === track &&
@@ -698,8 +762,10 @@ export function spotify(config = {}) {
698
762
  /** Ask Soloist for its state again (`get_state`, or `get_auth_state` without a session); while it is unreachable, reconnect now. */
699
763
  refresh() {
700
764
  const s = socket;
701
- if (s && open && s.readyState === OPEN)
702
- return query(state.loggedIn === false || !heard ? "get_auth_state" : "get_state");
765
+ if (s && isOpen() && s.readyState === OPEN)
766
+ return query(state.loggedIn === false || link === "open"
767
+ ? "get_auth_state"
768
+ : "get_state");
703
769
  wake?.abort();
704
770
  },
705
771
  async run(signal, update) {
@@ -752,3 +818,22 @@ export const manifest = {
752
818
  ? [spotifyText(state)]
753
819
  : [],
754
820
  };
821
+ /** The one speaker, once Soloist has said something. Its name is Soloist's `--device-name`, the one phones show. */
822
+ export function spotifyHomeDevices(part) {
823
+ const s = part?.state;
824
+ if (!s || (s.loggedIn === undefined && s.status === undefined))
825
+ return [];
826
+ return deviceRecords(part, [
827
+ {
828
+ connector: "spotify",
829
+ id: "spotify",
830
+ name: s.deviceName ?? "Soloist",
831
+ kind: "speaker",
832
+ available: true,
833
+ state: speakerState(part),
834
+ asOf: s.asOf,
835
+ text: spotifyText(s),
836
+ detail: s,
837
+ },
838
+ ]);
839
+ }
@@ -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;
@@ -141,6 +142,7 @@ export function up(config) {
141
142
  if (error instanceof UpError && error.status === 401)
142
143
  return update({
143
144
  status: "unavailable",
145
+ reason: "auth",
144
146
  detail: "Up rejected the token",
145
147
  state: last,
146
148
  });
@@ -185,6 +185,7 @@ export function bom(options = {}) {
185
185
  if (!options.fetch && base.startsWith("ftp:"))
186
186
  return {
187
187
  stop: "bom: plain fetch cannot read ftp://; pass fetch: ftpFetch() from @khalido/home/weather/ftp",
188
+ stopReason: "config",
188
189
  nextAt: Infinity,
189
190
  };
190
191
  if (force || at >= obsDue) {
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { getJson } from "../../connector.js";
2
+ import { ConnectorError, fetchTimeout } from "../../connector.js";
3
3
  import { RAIN_INTENSITIES } from "./rain.js";
4
4
  import { sunDisagrees } from "./sun.js";
5
5
  import { HOURLY_MINUTES, iso, nextPoll, num, RAIN_HORIZON_MINUTES, USER_AGENT, } from "./source.js";
@@ -273,6 +273,28 @@ export function toRain(minutes, fetchedAt) {
273
273
  */
274
274
  export const MIN_POLL_MINUTES = 15;
275
275
  export const CURRENT_MINUTES = 30;
276
+ /**
277
+ * Why Google refused: the `reason` of its error body's ErrorInfo ("API_KEY_INVALID"), since the status alone doesn't say.
278
+ * A bad key and a bad latitude are both HTTP 400 INVALID_ARGUMENT; only the key's carries a reason (live, 2026-11-10).
279
+ */
280
+ async function refusal(res) {
281
+ try {
282
+ const body = (await res.json());
283
+ const reason = body.error?.details?.find((d) => d.reason)?.reason;
284
+ return typeof reason === "string" ? reason : undefined;
285
+ }
286
+ catch {
287
+ return undefined; // not JSON: the status is all there is
288
+ }
289
+ }
290
+ /** Google's refusal: the HTTP status, and `why`, the reason its error body gave. */
291
+ class GoogleError extends ConnectorError {
292
+ why;
293
+ constructor(message, status, why) {
294
+ super(message, { status });
295
+ this.why = why;
296
+ }
297
+ }
276
298
  /**
277
299
  * Google Weather as a source: modelled `now`, `forecast` (10 days available, 2 read), `sky` without radiation, and `rain` from the
278
300
  * minute forecast (Experimental at Google, so its failure is a `problem` on `rain` and never stops the other slices).
@@ -295,11 +317,20 @@ export function google(options) {
295
317
  },
296
318
  minPollMinutes: MIN_POLL_MINUTES,
297
319
  async read({ place, at, pollMinutes, force, signal, }) {
298
- const q = (extra) => `key=${encodeURIComponent(options.key)}&location.latitude=${place.latitude}&location.longitude=${place.longitude}${extra}`;
299
- const json = (path) => getJson(fetcher, `${base}/${path}`, {
300
- init: { headers: { "user-agent": USER_AGENT } },
301
- signal,
302
- });
320
+ const q = (extra) => `location.latitude=${place.latitude}&location.longitude=${place.longitude}${extra}`;
321
+ // 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).
322
+ const json = async (path) => {
323
+ const res = await fetchTimeout(fetcher, `${base}/${path}`, {
324
+ headers: {
325
+ "user-agent": USER_AGENT,
326
+ "x-goog-api-key": options.key,
327
+ },
328
+ }, { signal });
329
+ if (res.ok)
330
+ return res.json();
331
+ const why = await refusal(res);
332
+ throw new GoogleError(`HTTP ${res.status}${why ? ` ${why}` : ""} on GET /${path.split("?")[0]}`, res.status, why);
333
+ };
303
334
  // Started first and never throws: a failed minute forecast must not sink the rest.
304
335
  const minutes = json(`forecast/minutes:lookup?${q("")}`).then((value) => ({ value }), (error) => ({ error: error }));
305
336
  let problem;
@@ -324,10 +355,22 @@ export function google(options) {
324
355
  }
325
356
  }
326
357
  catch (error) {
327
- const status = error.status;
328
- if (status === 400 || status === 401 || status === 403)
358
+ // Only a GoogleError is Google's answer; a timeout or an abort is a DOMException, with fields of its own.
359
+ const { status, why } = error instanceof GoogleError
360
+ ? error
361
+ : {};
362
+ const said = `HTTP ${status}${why ? ` ${why}` : ""}`;
363
+ if (status === 401 || status === 403 || why?.startsWith("API_KEY_"))
364
+ return {
365
+ stop: `google: the key was rejected (${said})`,
366
+ stopReason: "auth",
367
+ nextAt: Infinity,
368
+ };
369
+ // A 400 with a good key is the request itself, and here that is the location: asking again won't change it.
370
+ if (status === 400)
329
371
  return {
330
- stop: `google: key rejected or request refused (HTTP ${status})`,
372
+ stop: `google: the request was refused (${said}); check the latitude and longitude`,
373
+ stopReason: "config",
331
374
  nextAt: Infinity,
332
375
  };
333
376
  problem = `google: ${error.message}`; // the old slices stay; the minute forecast below is still used
@@ -1,5 +1,5 @@
1
1
  import { type Clock } from "../../clock.ts";
2
- import type { Integration } from "../../home.ts";
2
+ import type { Integration, Reason } from "../../home.ts";
3
3
  import { type Manifest } from "../../manifest.ts";
4
4
  import { type BlockDef, type DayBlocks } from "./blocks.ts";
5
5
  import { type RainSoon } from "./rain.ts";
@@ -38,6 +38,7 @@ export type WeatherState = Slices & {
38
38
  read: boolean;
39
39
  problem?: string;
40
40
  stopped?: string;
41
+ reason?: Exclude<Reason, "crashed">;
41
42
  anomaly?: string;
42
43
  }>;
43
44
  };
@@ -66,6 +66,7 @@ export function weather(config) {
66
66
  read: r !== undefined,
67
67
  problem: r?.problem,
68
68
  stopped: r?.stop,
69
+ reason: r?.stop ? (r.stopReason ?? "config") : undefined,
69
70
  anomaly: r?.anomaly,
70
71
  },
71
72
  ];
@@ -87,6 +88,12 @@ export function weather(config) {
87
88
  : undefined;
88
89
  update({
89
90
  status,
91
+ // Every source has stopped: a rejected key outranks a wrong setting, since it needs a person.
92
+ reason: status !== "unavailable"
93
+ ? undefined
94
+ : Object.values(report).some((r) => r.reason === "auth")
95
+ ? "auth"
96
+ : "config",
90
97
  detail,
91
98
  anomaly: notes("anomaly"),
92
99
  lastGoodAt,
@@ -1,3 +1,4 @@
1
+ import type { Reason } from "../../home.ts";
1
2
  /** Sent on every request, so each service can see who is calling rather than a runtime's default string. */
2
3
  export declare const USER_AGENT = "home (+https://github.com/khalido/home.ts)";
3
4
  export type Place = {
@@ -116,6 +117,7 @@ export type SourceReading = Slices & {
116
117
  problem?: string;
117
118
  anomaly?: string;
118
119
  stop?: string;
120
+ stopReason?: Exclude<Reason, "crashed">;
119
121
  };
120
122
  /**
121
123
  * A weather provider. `owns` names the slices it can fill and how old each may get (by its `at`, beyond one polling
@@ -0,0 +1,27 @@
1
+ import { type CastHomeDevice, castHomeDevices } from "./connectors/cast/index.ts";
2
+ import { type DaikinHomeDevice, daikinHomeDevices } from "./connectors/daikin/index.ts";
3
+ import { type EnphaseHomeDevice, enphaseHomeDevices } from "./connectors/enphase/index.ts";
4
+ import { type HueHomeDevice, hueHomeDevices } from "./connectors/hue/index.ts";
5
+ import { type PrinterHomeDevice, printersHomeDevices } from "./connectors/printers/index.ts";
6
+ import { type SpotifyHomeDevice, spotifyHomeDevices } from "./connectors/spotify/index.ts";
7
+ import type { HomeConfig } from "./config.ts";
8
+ import type { Part } from "./home.ts";
9
+ /** Any device this library reads. `connector`, then `kind` where a connector has more than one, narrows `detail` and `state`. */
10
+ export type HomeDevice = HueHomeDevice | DaikinHomeDevice | EnphaseHomeDevice | CastHomeDevice | PrinterHomeDevice | SpotifyHomeDevice;
11
+ /** Each connector's devices from its part, in the order `home status` lists the connectors. */
12
+ export declare const DEVICES: {
13
+ hue: typeof hueHomeDevices;
14
+ daikin: typeof daikinHomeDevices;
15
+ enphase: typeof enphaseHomeDevices;
16
+ cast: typeof castHomeDevices;
17
+ printers: typeof printersHomeDevices;
18
+ spotify: typeof spotifyHomeDevices;
19
+ };
20
+ /** Connectors that read a service, not a device: nothing to list. */
21
+ export declare const SERVICES: readonly string[];
22
+ /**
23
+ * Every device in the snapshot. With the home `config`, a device takes the `room` its entry gives it, a Hue room or zone
24
+ * (and its lights) the room whose `hueRoom` names it (a light in a room and a zone, the room's), and a device that reports
25
+ * no name of its own (the Daikin, the Enphase gateway) the entry's `name`. A device's own name is never replaced.
26
+ */
27
+ export declare function homeDevices(snapshot: Record<string, Part | undefined>, config?: Pick<HomeConfig, "devices" | "rooms">): HomeDevice[];