@khalido/home 0.1.1 → 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 (41) hide show
  1. package/CHANGELOG.md +28 -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 +7 -1
  6. package/dist/connectors/cast/schemas.d.ts +2 -0
  7. package/dist/connectors/cast/schemas.js +1 -0
  8. package/dist/connectors/cast/state.d.ts +4 -0
  9. package/dist/connectors/cast/state.js +12 -0
  10. package/dist/connectors/cast/writes.d.ts +9 -0
  11. package/dist/connectors/cast/writes.js +42 -6
  12. package/dist/connectors/spotify/changes.d.ts +1 -1
  13. package/dist/connectors/spotify/changes.js +4 -2
  14. package/dist/connectors/spotify/client.d.ts +1 -1
  15. package/dist/connectors/spotify/index.d.ts +3 -1
  16. package/dist/connectors/spotify/index.js +104 -44
  17. package/dist/connectors/up/index.d.ts +4 -2
  18. package/dist/connectors/up/index.js +2 -1
  19. package/dist/discover.d.ts +2 -0
  20. package/dist/discover.js +4 -1
  21. package/dist/lane.js +24 -20
  22. package/docs/cast.md +1 -1
  23. package/docs/cli.md +2 -2
  24. package/docs/decisions.md +1 -0
  25. package/package.json +1 -1
  26. package/src/cli/cast.ts +19 -11
  27. package/src/cli/spotify.ts +2 -1
  28. package/src/config.ts +3 -1
  29. package/src/connectors/cast/README.md +4 -3
  30. package/src/connectors/cast/index.ts +17 -4
  31. package/src/connectors/cast/schemas.ts +1 -0
  32. package/src/connectors/cast/state.ts +12 -0
  33. package/src/connectors/cast/writes.ts +58 -9
  34. package/src/connectors/spotify/README.md +20 -8
  35. package/src/connectors/spotify/changes.ts +4 -2
  36. package/src/connectors/spotify/client.ts +1 -1
  37. package/src/connectors/spotify/index.ts +111 -47
  38. package/src/connectors/up/README.md +1 -1
  39. package/src/connectors/up/index.ts +5 -2
  40. package/src/discover.ts +6 -2
  41. package/src/lane.ts +23 -20
@@ -33,10 +33,12 @@ const COMMAND_TIMEOUT_MS = 3000;
33
33
  const QUERY_TIMEOUT_MS = 1000;
34
34
  /** A socket that has said nothing this long after opening is closed: Soloist sends `auth_state` at once on connect. */
35
35
  const HELLO_MS = 10_000;
36
- /** After an idle → playing change, a volume above the cap is lowered for this long: the volume can come before or after the status (issue #23). */
36
+ /** After a start signal, a volume above the cap is lowered for this long: the volume can come before or after the status (issue #23). */
37
37
  const CAP_WINDOW_MS = 10_000;
38
38
  /** How long a write waits for Soloist to report the change it asked for. Loopback events arrive in milliseconds; a stated guess. */
39
39
  const CONFIRM_MS = 2000;
40
+ /** Between the two halves of `setRepeat`: back to back left repeat on `track`, 600 ms apart worked (live, 2026-11-09). */
41
+ const REPEAT_GAP_MS = 600;
40
42
  /** Between two commands. Soloist publishes no limit; it is on loopback; a stated guess. */
41
43
  const WRITE_GAP_MS = 100;
42
44
  /** While playing, a stall check this often: one `get_state`. Soloist issue #5 reports stalls 71–150 s into a track; a stated guess. */
@@ -45,6 +47,8 @@ const STALL_CHECK_MS = 20_000;
45
47
  const OVERRUN_MS = 15_000;
46
48
  /** After a repair's seek, how long before looking whether the position moves again. */
47
49
  const REPAIR_WAIT_MS = 5000;
50
+ /** A reported volume is rounded (phone steps are sixteenths: 43.75 shows as 44), so "it took" allows this much. */
51
+ const VOLUME_SLACK = 1;
48
52
  const MIN_BACKOFF_MS = 1000;
49
53
  const MAX_BACKOFF_MS = 60_000;
50
54
  /** Signed out is Soloist's normal state until someone picks the speaker (seen live 2026-11-08); a bad key looks the same (issue #18). */
@@ -78,15 +82,16 @@ export function spotify(config = {}) {
78
82
  const checkMs = config.stallCheckMs ?? STALL_CHECK_MS;
79
83
  let state = { asOf: asOf(clock) };
80
84
  let socket;
81
- let open = false;
82
- let heard = false; // an auth_state on this socket: until then the part stays connecting
83
- let whole = false; // signed in, the first playback_state has come too (or HELLO_MS passed): a one-shot read sees the track
85
+ // This socket's progress. "heard": signed in, the first playback_state still to come. "whole": a one-shot read sees the track (or HELLO_MS passed).
86
+ let link = "down";
87
+ const isOpen = () => link !== "down";
84
88
  let down; // why the last socket ended, while there is none open
85
89
  let statusKnown = false; // Soloist has told this socket its status: only then is idle → playing an edge
86
90
  let queueAsked = false; // get_queue sent on this socket
87
91
  let signedIn = false; // signed out → in on this socket: the first status after it counts as a start if it is playing
88
92
  let capUntil = -Infinity;
89
- let capped = false; // the cap has been sent in this window
93
+ let capped = false; // the cap took in this window
94
+ let capping = false; // a lowering is on its way
90
95
  let startBuffering = false; // a start from idle is still buffering
91
96
  let capNote; // the last first-play lowering, in `anomaly` until the speaker goes idle
92
97
  let frameNote; // a frame the schema rejected, or an error nothing was waiting for
@@ -99,10 +104,10 @@ export function spotify(config = {}) {
99
104
  const publish = () => {
100
105
  let status;
101
106
  let detail;
102
- if (!open)
107
+ if (link === "down")
103
108
  ((status = reported || down ? "disconnected" : "connecting"),
104
109
  (detail = down));
105
- else if (!heard || (state.loggedIn === true && !whole))
110
+ else if (link !== "whole")
106
111
  status = "connecting";
107
112
  else
108
113
  ((status = "connected"),
@@ -128,7 +133,7 @@ export function spotify(config = {}) {
128
133
  */
129
134
  const command = (name, fields = {}, answers) => new Promise((resolve, reject) => {
130
135
  const s = socket;
131
- if (!s || !open || s.readyState !== OPEN)
136
+ if (!s || !isOpen() || s.readyState !== OPEN)
132
137
  return reject(offline());
133
138
  if (state.loggedIn === false && !answers)
134
139
  return reject(new ConnectorError(WAITING, { code: "waiting" }));
@@ -157,11 +162,17 @@ export function spotify(config = {}) {
157
162
  p.finish(new ConnectorError(`Soloist did not answer ${name} in ${ms / 1000} s`, { code: "timeout" })));
158
163
  });
159
164
  const send = async (body) => {
165
+ if (body.activeFirst && inactive()) {
166
+ await command("activate").catch(() => { }); // best effort: the write itself says how it went
167
+ await waitFor(() => state.active === true || !isOpen(), CONFIRM_MS);
168
+ }
160
169
  const before = state;
161
170
  const at = clock.now();
162
171
  await command(body.command, body.fields, body.answers);
163
- if (body.then)
172
+ if (body.then) {
173
+ await clock.sleep(REPEAT_GAP_MS);
164
174
  await command(body.then.command, body.then.fields); // repeat's second half, still this write's turn
175
+ }
165
176
  return { before, at };
166
177
  };
167
178
  let once = 0; // skips and queued tracks never merge: two nexts are two skips
@@ -174,7 +185,7 @@ export function spotify(config = {}) {
174
185
  const change = async (slot, body) => {
175
186
  const sent = await write(slot, body);
176
187
  const took = () => body.took?.(state, sent.before, sent.at) ?? true;
177
- await waitFor(() => took() || !open, CONFIRM_MS);
188
+ await waitFor(() => took() || !isOpen(), CONFIRM_MS);
178
189
  return { state, confirmed: took() };
179
190
  };
180
191
  /** The track restarted: a new anchor near 0 when it had been 5 s or more in (a prev past the first seconds, a next on repeat-track). */
@@ -183,11 +194,14 @@ export function spotify(config = {}) {
183
194
  (positionMs(b, at) ?? 0) >= 5000;
184
195
  const newTrack = (s, b) => !!s.track && s.track.uri !== b.track?.uri;
185
196
  const invalid = (message) => new ConnectorError(message, { code: "invalid" });
197
+ const near = (volume, v) => volume !== undefined && Math.abs(volume - v) <= VOLUME_SLACK;
186
198
  const volumeTo = (v) => change("volume", {
187
199
  command: "set_volume",
188
200
  fields: { volume: v },
189
- took: (s) => s.volume === v,
201
+ took: (s) => near(s.volume, v),
190
202
  });
203
+ /** Soloist accepts a `set_volume` while it isn't the active device and drops it (three times live, 2026-11-09). */
204
+ const inactive = () => isOpen() && state.loggedIn !== false && state.active === false;
191
205
  /** Ask Soloist for something, through the lane; the same query twice merges. A refusal is an anomaly, a drop is the status's to say. */
192
206
  const ANSWERS = {
193
207
  get_state: "playback_state",
@@ -207,22 +221,48 @@ export function spotify(config = {}) {
207
221
  frameNote = `${name} failed: ${errorText(error)}`;
208
222
  publish();
209
223
  });
210
- /** Within the window after a start from idle, a volume above the cap is lowered to it, once (issue #23). */
224
+ /**
225
+ * Within the window after a start signal, a volume above the cap is lowered to it (issue #23). Only while active, since
226
+ * Soloist drops it otherwise, and retried until Soloist reports it. A volume raised after that is someone's choice;
227
+ * one raised before it is reported can't be told from the start's own jump, so it is lowered too.
228
+ */
211
229
  const applyCap = () => {
212
230
  if (cap === undefined ||
213
231
  capped ||
232
+ capping ||
214
233
  state.volume === undefined ||
215
- clock.now() > capUntil ||
216
- state.volume <= cap)
234
+ state.volume <= cap + VOLUME_SLACK ||
235
+ state.active === false ||
236
+ clock.now() > capUntil)
217
237
  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()));
238
+ capping = true;
239
+ const from = state.volume;
240
+ const window = capUntil;
241
+ volumeTo(cap).then(({ confirmed }) => {
242
+ capping = false;
243
+ if (capUntil !== window)
244
+ return applyCap(); // a new start while it went: this lowering doesn't speak for that one
245
+ if (confirmed) {
246
+ capped = true;
247
+ if (state.status === "idle")
248
+ return;
249
+ capNote = `started at volume ${from}, above maxVolume ${cap}; lowered it (Soloist issue #23)`;
250
+ note = undefined;
251
+ return publish();
252
+ }
253
+ if (clock.now() > capUntil) {
254
+ note = `could not lower the volume to ${cap}: Soloist has not reported it`;
255
+ return publish();
256
+ }
257
+ applyCap();
258
+ }, (error) => {
259
+ capping = false;
260
+ if (error instanceof ConnectorError && error.code === "offline")
261
+ return; // the status says it
262
+ capped = true; // refused or unanswered: not again this window
263
+ note = `lowering the volume to ${cap} failed: ${errorText(error)}`;
264
+ publish();
265
+ });
226
266
  };
227
267
  const handle = (data) => {
228
268
  let raw;
@@ -254,12 +294,17 @@ export function spotify(config = {}) {
254
294
  }
255
295
  const before = state.status;
256
296
  const wasSignedIn = state.loggedIn;
297
+ const wasActive = link === "open" ? undefined : state.active; // the first frames on a socket are not a change
257
298
  state = fold(state, f, asOf(clock));
299
+ if (state.track?.uri !== repairedFor)
300
+ repairedFor = undefined; // one repair per play of a track
301
+ // Going active makes a new stream, at WirePlumber's saved level (live, 2026-11-09): the likeliest moment for a loud start.
302
+ if (wasActive === false && state.active === true)
303
+ ((capUntil = clock.now() + CAP_WINDOW_MS), (capped = false));
258
304
  if (f.type === "auth_state") {
259
305
  // 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;
306
+ if (link === "open" || (link === "heard" && f.logged_in === false))
307
+ link = f.logged_in === true ? "heard" : "whole";
263
308
  if (f.logged_in === false)
264
309
  statusKnown = false;
265
310
  // A first sign-in (someone picked the speaker) sends only auth_state; the docs don't promise a playback_state after it.
@@ -269,14 +314,14 @@ export function spotify(config = {}) {
269
314
  if (f.type === "playback_state") {
270
315
  if (!queueAsked)
271
316
  ((queueAsked = true), query("get_queue", { limit: 10 })); // queue_changed pushes changes only, so ask once per socket
272
- whole = true;
317
+ link = "whole";
273
318
  }
274
319
  if (pending?.answers === f.type)
275
320
  pending.finish();
276
321
  if ((f.type === "playback_state" || f.type === "playback_changed") &&
277
322
  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.
323
+ // A start is idle → playing or buffering, or a sign-in whose first status is either: a phone picked the speaker and played.
324
+ // The volume can land just after `playing` (issue #23), so a start through buffering opens the window again then.
280
325
  const started = ((f.status === "playing" || f.status === "buffering") &&
281
326
  (statusKnown ? before === "idle" : signedIn)) ||
282
327
  (f.status === "playing" && startBuffering);
@@ -319,10 +364,8 @@ export function spotify(config = {}) {
319
364
  signal.removeEventListener("abort", stop);
320
365
  if (socket === s)
321
366
  socket = undefined;
322
- const was = open;
323
- open = false;
324
- heard = false;
325
- whole = false;
367
+ const was = isOpen();
368
+ link = "down";
326
369
  queueAsked = false;
327
370
  statusKnown = false;
328
371
  signedIn = false;
@@ -339,17 +382,17 @@ export function spotify(config = {}) {
339
382
  s.onopen = () => {
340
383
  if (ended)
341
384
  return;
342
- open = true;
385
+ link = "open";
343
386
  down = undefined;
344
387
  note = undefined; // a new socket: what failed on the last one is history
345
388
  void clock.sleep(HELLO_MS, quiet.signal).then(() => {
346
389
  if (quiet.signal.aborted)
347
390
  return;
348
- if (!heard)
391
+ if (link === "open")
349
392
  return (end(`Soloist said nothing in ${HELLO_MS / 1000} s`),
350
393
  s.close());
351
- if (!whole)
352
- ((whole = true), publish()); // signed in, no playback_state: connected with what there is
394
+ if (link === "heard")
395
+ ((link = "whole"), publish()); // signed in, no playback_state: connected with what there is
353
396
  });
354
397
  };
355
398
  s.onmessage = (ev) => void (ended || handle(ev.data));
@@ -367,7 +410,7 @@ export function spotify(config = {}) {
367
410
  waiters.delete(check);
368
411
  };
369
412
  /** Not connected or not playing: the reason `duck` and `interrupt` just run `fn`. */
370
- const notPlaying = () => !open
413
+ const notPlaying = () => !isOpen()
371
414
  ? "Soloist is not connected"
372
415
  : state.status === "playing" || state.status === "buffering"
373
416
  ? undefined
@@ -388,7 +431,7 @@ export function spotify(config = {}) {
388
431
  */
389
432
  const aside = (kind, fn, enter, leave) => {
390
433
  const run = async () => {
391
- if (open)
434
+ if (isOpen())
392
435
  marked(kind);
393
436
  try {
394
437
  return await within();
@@ -423,10 +466,20 @@ export function spotify(config = {}) {
423
466
  why: failed(`sent the ${what}; Soloist has not reported it`),
424
467
  };
425
468
  const actions = {
469
+ async activate() {
470
+ if (isOpen() && state.loggedIn !== false && state.active === true)
471
+ return { state, confirmed: true }; // a second activate might make a new stream
472
+ return change("activate", {
473
+ command: "activate",
474
+ took: (s) => s.active === true,
475
+ });
476
+ },
477
+ // A `play` while inactive didn't take live; going active first is untested for this. `resume` worked inactive, so it doesn't.
426
478
  async play(linkOrUri) {
427
479
  if (linkOrUri === undefined)
428
480
  return change("transport", {
429
481
  command: "play",
482
+ activeFirst: true,
430
483
  took: (s) => s.status === "playing",
431
484
  });
432
485
  const uri = toSpotifyUri(linkOrUri);
@@ -435,6 +488,7 @@ export function spotify(config = {}) {
435
488
  return change("transport", {
436
489
  command: "play",
437
490
  fields: { uri },
491
+ activeFirst: true,
438
492
  took: (s) => s.status === "playing" &&
439
493
  (s.track?.uri === uri || s.context?.uri === uri),
440
494
  });
@@ -471,6 +525,8 @@ export function spotify(config = {}) {
471
525
  async setVolume(volume) {
472
526
  if (!Number.isFinite(volume) || volume < 0 || volume > 100)
473
527
  throw invalid(`volume is 0–100, not ${volume}`);
528
+ if (inactive())
529
+ throw new ConnectorError("the speaker is not the active Spotify device, so Soloist would drop this; activate it first (`home spotify activate`; its volume may jump to the level last saved)", { code: "inactive" });
474
530
  return volumeTo(Math.round(cap === undefined ? volume : Math.min(volume, cap)));
475
531
  },
476
532
  async queue(track) {
@@ -544,6 +600,8 @@ export function spotify(config = {}) {
544
600
  const no = notPlaying();
545
601
  if (no)
546
602
  return no;
603
+ if (inactive())
604
+ return "it is not the active device, so its volume can't be changed";
547
605
  if (state.volume === undefined)
548
606
  return "its volume is not known";
549
607
  if (state.volume <= to)
@@ -556,7 +614,7 @@ export function spotify(config = {}) {
556
614
  return failed(`lowering the volume for duck failed: ${errorText(error)}`);
557
615
  }
558
616
  }, async () => {
559
- if (state.volume !== Math.round(to))
617
+ if (!near(state.volume, Math.round(to)))
560
618
  return {
561
619
  music: "kept",
562
620
  why: shown
@@ -593,7 +651,7 @@ export function spotify(config = {}) {
593
651
  state = rest;
594
652
  }
595
653
  let seen; // the last check's reported position
596
- let repairedFor; // at most one repair per track
654
+ let repairedFor; // at most one repair per play of a track
597
655
  /** Ask Soloist where it is, waiting for the answer (or the query's timeout). */
598
656
  const fresh = () => write("query-get_state", {
599
657
  command: "get_state",
@@ -614,7 +672,7 @@ export function spotify(config = {}) {
614
672
  a.track === b.track &&
615
673
  b.atMs - a.atMs >= gap &&
616
674
  Math.abs(b.ms - a.ms) < 1000;
617
- const playing = () => open && state.status === "playing" && !state.aside;
675
+ const playing = () => isOpen() && state.status === "playing" && !state.aside;
618
676
  /** One look while playing: a fresh position, then frozen, overrun, recovered or fine (Soloist issue #5). */
619
677
  const checkStall = async () => {
620
678
  if (!playing())
@@ -654,7 +712,7 @@ export function spotify(config = {}) {
654
712
  repairedFor = track;
655
713
  const tried = (what) => state.stalled && stallAs({ ...state.stalled, tried: what });
656
714
  // Still ours to fix: playing this track, stalled, and no duck, interrupt or person in between.
657
- const ours = () => open &&
715
+ const ours = () => isOpen() &&
658
716
  state.status === "playing" &&
659
717
  state.track?.uri === track &&
660
718
  state.stalled?.track === track &&
@@ -698,8 +756,10 @@ export function spotify(config = {}) {
698
756
  /** Ask Soloist for its state again (`get_state`, or `get_auth_state` without a session); while it is unreachable, reconnect now. */
699
757
  refresh() {
700
758
  const s = socket;
701
- if (s && open && s.readyState === OPEN)
702
- return query(state.loggedIn === false || !heard ? "get_auth_state" : "get_state");
759
+ if (s && isOpen() && s.readyState === OPEN)
760
+ return query(state.loggedIn === false || link === "open"
761
+ ? "get_auth_state"
762
+ : "get_state");
703
763
  wake?.abort();
704
764
  },
705
765
  async run(signal, update) {
@@ -82,8 +82,10 @@ export declare function toSnapshot(txs: Transaction[], categories: Category[], a
82
82
  truncated: boolean;
83
83
  household?: Household;
84
84
  }): UpSnapshot;
85
- /** Up as a home integration: two months of transactions now, then every 15 minutes. Reads only; it never writes. */
86
- export declare function up(config: UpConfig): Integration<UpSnapshot, "up">;
85
+ /** Up as a home integration: two months of transactions now, then every 15 minutes. The integration only reads; `client` is there for a consumer's writes. */
86
+ export declare function up(config: UpConfig): Integration<UpSnapshot, "up"> & {
87
+ client: UpClient;
88
+ };
87
89
  /** "Oct so far $2,140 · Sep $1,980 · 3 to categorise": this month's spend against last month's. */
88
90
  export declare function upText(s: UpSnapshot): string;
89
91
  /** One line per parent category this month, largest five: "home $1,030: groceries $620, utilities $410". */
@@ -97,13 +97,14 @@ export function toSnapshot(txs, categories, at) {
97
97
  truncated: at.truncated,
98
98
  };
99
99
  }
100
- /** Up as a home integration: two months of transactions now, then every 15 minutes. Reads only; it never writes. */
100
+ /** Up as a home integration: two months of transactions now, then every 15 minutes. The integration only reads; `client` is there for a consumer's writes. */
101
101
  export function up(config) {
102
102
  const client = upClient(config);
103
103
  const clock = config.clock ?? realClock;
104
104
  const poll = poller(clock);
105
105
  return {
106
106
  name: "up",
107
+ client,
107
108
  refresh: poll.nudge,
108
109
  async run(signal, update) {
109
110
  let last;
@@ -85,6 +85,7 @@ export type CachedAddress = {
85
85
  host: string;
86
86
  port?: number;
87
87
  name?: string;
88
+ model?: string;
88
89
  seenAt: string;
89
90
  };
90
91
  /**
@@ -111,6 +112,7 @@ export declare function addressBook(cache: AddressCache | undefined, options?: {
111
112
  host: string;
112
113
  port?: number;
113
114
  name?: string;
115
+ model?: string;
114
116
  }): void;
115
117
  entries: () => {
116
118
  [x: string]: CachedAddress;
package/dist/discover.js CHANGED
@@ -241,13 +241,16 @@ export function addressBook(cache, options = {}) {
241
241
  c.host === address.host &&
242
242
  c.port === address.port &&
243
243
  isFresh(c) &&
244
- (!address.name || address.name === c.name))
244
+ (!address.name || address.name === c.name) &&
245
+ (!address.model || address.model === c.model))
245
246
  return;
246
247
  const name = address.name || c?.name;
248
+ const model = address.model || c?.model;
247
249
  entries[key] = {
248
250
  host: address.host,
249
251
  ...(address.port === undefined ? {} : { port: address.port }),
250
252
  ...(name ? { name } : {}),
253
+ ...(model ? { model } : {}),
251
254
  seenAt: new Date(now()).toISOString(),
252
255
  };
253
256
  cache.write({ ...entries });
package/dist/lane.js CHANGED
@@ -24,28 +24,32 @@ export function writeLane(options) {
24
24
  }
25
25
  async function run() {
26
26
  running = true;
27
- while (queue.length) {
28
- const now = clock.now();
29
- const next = queue.find((p) => readyAt(p.slot) <= now);
30
- if (!next) {
31
- wake = new AbortController();
32
- await clock.sleep(Math.min(...queue.map((p) => readyAt(p.slot))) - now, wake.signal);
33
- continue;
34
- }
35
- queue.splice(queue.indexOf(next), 1);
36
- lastAt = now;
37
- sent.set(next.slot, [...recent(next.slot), now]);
38
- try {
39
- const result = await next.write(next.body);
40
- for (const w of next.waiters)
41
- w.resolve(result);
42
- }
43
- catch (error) {
44
- for (const w of next.waiters)
45
- w.reject(error);
27
+ try {
28
+ while (queue.length) {
29
+ const now = clock.now();
30
+ const next = queue.find((p) => readyAt(p.slot) <= now);
31
+ if (!next) {
32
+ wake = new AbortController();
33
+ await clock.sleep(Math.min(...queue.map((p) => readyAt(p.slot))) - now, wake.signal);
34
+ continue;
35
+ }
36
+ queue.splice(queue.indexOf(next), 1);
37
+ lastAt = now;
38
+ sent.set(next.slot, [...recent(next.slot), now]);
39
+ try {
40
+ const result = await next.write(next.body);
41
+ for (const w of next.waiters)
42
+ w.resolve(result);
43
+ }
44
+ catch (error) {
45
+ for (const w of next.waiters)
46
+ w.reject(error);
47
+ }
46
48
  }
47
49
  }
48
- running = false;
50
+ finally {
51
+ running = false; // a throw here must not leave the lane stuck
52
+ }
49
53
  }
50
54
  return {
51
55
  send(slot, body, write, merge = (_, newer) => newer) {
package/docs/cast.md CHANGED
@@ -109,7 +109,7 @@ No host: a group's host changes with its leader. All entries fold into one `cast
109
109
  - `announce(target, clip, { volume?, timeoutMs? })`, target a device or group, `clip` a URL or bytes with their type (KO's own dictation as recorded, or TTS output; the library never synthesises) served by `serveClip`. The result names each member it could not restore. Read status, set volume, launch Default Media Receiver (`CC1AD845`), `LOAD` the clip, wait for `IDLE`/`FINISHED` (with a timeout), then restore: a prior Default Media Receiver `contentId` is re-loaded at its `currentTime`, an idle device gets `STOP`, then the volume. Spotify, YouTube and other apps cannot be resumed generically (our launch ends their session); the result says so.
110
110
  - `castUrl(name, url)` launches DashCast (`84912283`, pychromecast's list) with the URL. HA casts its dashboard with its own registered receiver (`A078F6B0`), a hint at DashCast's limits. KO's own receiver costs a one-off Cast SDK fee and must be published (unpublished ones run only on serial-registered devices). Try DashCast first.
111
111
 
112
- **TTS.** The library plays a URL, never synthesises. The speaker fetches it, so it must be HTTP on the LAN, not loopback, and in a format Default Media Receiver plays (MP3, AAC, WAV; not AIFF): `say -o msg.m4a --file-format=m4af --data-format=aac "…"` (m4af alone holds uncompressed PCM). Smallest server: a ~25-line `serveClip(bytes, type)` over `node:http` beside `tls.ts`, bound to the Mac's LAN address, random port, unguessable path, closed when the clip has played or after a minute (not after the first fetch, so a probe before it does no harm). The consumer runs `say`; `serveClip` picks the address on the target's network; the sidecar will raise macOS's local-network prompt once. A cloud voice later is MP3 bytes served the same way.
112
+ **TTS.** The library plays a URL, never synthesises. The speaker fetches it, so it must be HTTP on the LAN, not loopback, and in a format Default Media Receiver plays (MP3 or AAC; not AIFF; a 24 kHz mono WAV failed on audio-only Nest speakers, live 2026-11-09): `say -o msg.m4a --file-format=m4af --data-format=aac "…"` (m4af alone holds uncompressed PCM). Smallest server: a ~25-line `serveClip(bytes, type)` over `node:http` beside `tls.ts`, bound to the Mac's LAN address, random port, unguessable path, closed when the clip has played or after a minute (not after the first fetch, so a probe before it does no harm). The consumer runs `say`; `serveClip` picks the address on the target's network; the sidecar will raise macOS's local-network prompt once. A cloud voice later is MP3 bytes served the same way.
113
113
 
114
114
  **CLI.** `home status`: `cast 7 · Display A idle 47% · TV B standby · Whole house (3, led by Display A) idle`. `home probe`: this probe.
115
115
 
package/docs/cli.md CHANGED
@@ -15,11 +15,11 @@ home config check [path] # validate a home config: what it would set up, se
15
15
  home discover --write [path] # with a config (the path, else --config or HOME_CONFIG): match what answers by identity, add new devices, update moved hosts and renamed Cast names in place
16
16
  home probe # read-only, no keys: Hue's public config, Daikin by UDP broadcast, the IQ Gateway's /info, each weather source, each Cast device's and printer's status
17
17
  home locate "<street address>" [--write [path]] # latitude and longitude from OpenStreetMap's Nominatim; --write sets them in the config's location (and timezone, when absent, to this machine's)
18
- home cast announce --to <name> (--say "<text>" | --file <path>) [--volume 0-100] # play a clip, then put back what was playing and the volume
18
+ home cast announce --to <name> [--to <name>…] (--say "<text>" | --file <path>) [--volume 0-100] # exit 1 if any didn't play; play a clip, then put back what was playing and the volume
19
19
  home cast volume <name> <0-100>
20
20
  home cast mute <name> on|off
21
21
  home cast stop <name> # end the running app; the device returns to its idle screen
22
- home spotify pause | play [link] | next | volume <0-100> # the Soloist speaker at SOLOIST_WS; volume held to SOLOIST_MAX_VOLUME; prints what Soloist then reported
22
+ home spotify activate | pause | play [link] | next | volume <0-100> # the Soloist speaker at SOLOIST_WS; volume held to SOLOIST_MAX_VOLUME; prints what Soloist then reported
23
23
  ```
24
24
 
25
25
  ## Output
package/docs/decisions.md CHANGED
@@ -148,3 +148,4 @@ KO's decisions on this package, newest block last. They override every other doc
148
148
  - **Signed out reads as waiting**, everywhere: `speakerState` says `waiting`, the detail says "waiting for someone to pick it", the write error's code is `waiting`.
149
149
  - **Stalls (Soloist issue #5) are noticed by default and repaired only when asked** (`repairStalls: true`): the repair is the library's first write nobody asked for, so a consumer opts in. Two signals, since the issue doesn't say whether the reported position freezes: a frozen position across fresh reports, and still playing well past the track's end, the symptom actually reported. One repair per track, never a loop. The nudges (seek, then pause and play; a skip past the end) are guesses until a stall is seen live.
150
150
  - **The project is home.ts; the npm package stays `@khalido/home`** (KO, 2026-11-08), so imports stay short (`@khalido/home/spotify`). It is home automation for TypeScript, starting with the devices in KO's house, and is meant to grow into more of the basics.
151
+ - **Spotify: inactive is refused, and activating is the caller's choice** (KO, 2026-11-09). Soloist accepts and drops `set_volume` while it isn't the active device. `setVolume` refuses with code `inactive`, and `activate()` is separate, because activating makes a new stream and can jump the volume to the level last saved. `duck` runs `fn` anyway, with `music: "untouched"`, because the announcement matters more. `play` goes active itself, since someone asking for music wants it to start; `resume` doesn't, since it worked inactive. The `maxVolume` cap says "lowered" only once Soloist reports it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@khalido/home",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "home.ts: small, typed home automation for TypeScript: Hue, Daikin, Enphase, Google Cast, AirPrint printers and a Spotify Soloist speaker on the local network; Amber prices, Up Bank spending and the weather from the cloud.",
5
5
  "license": "MIT",
6
6
  "author": "Khalid Omar",
package/src/cli/cast.ts CHANGED
@@ -34,6 +34,7 @@ export function castNow() {
34
34
  {
35
35
  castId: key.slice(5),
36
36
  name: c.name ?? key.slice(5),
37
+ model: c.model,
37
38
  host: c.host,
38
39
  port: c.port,
39
40
  },
@@ -71,7 +72,7 @@ async function withCast<T>(
71
72
  }
72
73
 
73
74
  export const CAST_USAGE =
74
- 'usage: home cast announce --to <name> (--say "<text>" | --file <path>) [--volume 0-100] | volume <name> <0-100> | mute <name> on|off | stop <name>';
75
+ 'usage: home cast announce --to <name> [--to <name>…] (--say "<text>" | --file <path>) [--volume 0-100] | volume <name> <0-100> | mute <name> on|off | stop <name>';
75
76
 
76
77
  /** A 0–100 argument as the 0–1 a Cast device takes. */
77
78
  function percentLevel(value: string | undefined): number {
@@ -96,7 +97,7 @@ const AUDIO_TYPES: Record<string, string> = {
96
97
  export const CAST_OPTIONS: [string, string, { type: unknown[] }?][] = [
97
98
  [
98
99
  "--to <name>",
99
- "announce: the device or group, by name or castId",
100
+ "announce: the device or group, by name or castId; repeat for several",
100
101
  { type: [String] },
101
102
  ],
102
103
  ["--say <text>", "announce: words spoken by macOS `say`", { type: [String] }],
@@ -119,7 +120,8 @@ export const castVerbs: Verbs = {
119
120
  await withCast((c) => c.stop(name!));
120
121
  },
121
122
  async announce(_, o) {
122
- const [to, say, file] = [text(o.to), text(o.say), text(o.file)];
123
+ const [say, file] = [text(o.say), text(o.file)];
124
+ const to = [o.to].flat().filter(Boolean).map(String); // --to can repeat
123
125
  // cac gives "true" for a flag with no value: `--say` alone must not speak the word "true", so each flag needs a value after it.
124
126
  const bare = ["--to", "--say", "--file"].some((f) =>
125
127
  process.argv.some(
@@ -130,7 +132,7 @@ export const castVerbs: Verbs = {
130
132
  ),
131
133
  );
132
134
  if (bare) usage(CAST_USAGE);
133
- if (!to || !say === !file) usage(CAST_USAGE);
135
+ if (!to.length || !say === !file) usage(CAST_USAGE);
134
136
  const volume =
135
137
  o.volume === undefined ? undefined : percentLevel(text(o.volume));
136
138
  let type: string;
@@ -160,13 +162,19 @@ export const castVerbs: Verbs = {
160
162
  }
161
163
  type = "audio/mp4";
162
164
  }
163
- const r = await withCast((c) =>
164
- c.announce(to!, { bytes, type }, { volume }),
165
+ const results = await withCast((c) =>
166
+ c.announceAll(to, { bytes, type }, { volume }),
165
167
  );
166
- if (o.json) return printJson(r);
167
- console.log(r.played ? "played" : `not played: ${r.why}`);
168
- if (r.restored.length) console.log(`restored: ${r.restored.join(", ")}`);
169
- for (const n of r.notRestored)
170
- console.log(`not restored: ${n.name}: ${n.why}`);
168
+ if (results.some((r) => !r.played)) process.exitCode = 1; // docs/cli.md: 1 is a failure, so a script can tell
169
+ if (o.json) return printJson(to.length === 1 ? results[0] : results);
170
+ const named = results.length > 1;
171
+ for (const r of results) {
172
+ const who = named ? `${r.target}: ` : "";
173
+ console.log(`${who}${r.played ? "played" : `not played: ${r.why}`}`);
174
+ if (r.restored.length)
175
+ console.log(`${who}restored: ${r.restored.join(", ")}`);
176
+ for (const n of r.notRestored)
177
+ console.log(`${who}not restored: ${n.name}: ${n.why}`);
178
+ }
171
179
  },
172
180
  };
@@ -31,7 +31,7 @@ export const spotifyFromEnv = () =>
31
31
  });
32
32
 
33
33
  export const SPOTIFY_USAGE =
34
- "usage: home spotify pause | play [link] | next | volume <0-100>";
34
+ "usage: home spotify activate | pause | play [link] | next | volume <0-100>";
35
35
 
36
36
  async function withSpotify<T>(
37
37
  act: (s: ReturnType<typeof spotify>) => Promise<T>,
@@ -64,6 +64,7 @@ const said = ({ state, confirmed }: Applied) =>
64
64
  );
65
65
 
66
66
  export const spotifyVerbs: Verbs = {
67
+ activate: () => withSpotify((s) => s.activate()).then(said),
67
68
  pause: () => withSpotify((s) => s.pause()).then(said),
68
69
  next: () => withSpotify((s) => s.next()).then(said),
69
70
  play: ([link]) => withSpotify((s) => s.play(link)).then(said),
package/src/config.ts CHANGED
@@ -509,7 +509,9 @@ export function loadHome(
509
509
  {
510
510
  castId: x.castId,
511
511
  name: x.name,
512
- ...(c?.port === undefined ? {} : { host: c.host, port: c.port }),
512
+ ...(c?.port === undefined
513
+ ? {}
514
+ : { host: c.host, port: c.port, model: c.model }),
513
515
  },
514
516
  ];
515
517
  });