@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
package/CHANGELOG.md CHANGED
@@ -4,6 +4,32 @@ All notable changes to home.ts (`@khalido/home` on npm), in [Keep a Changelog](h
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.1.2] - 2026-11-09
8
+
9
+ From chota-bot's live writes on a Raspberry Pi. `setVolume` on an inactive speaker now rejects, and `track` events can come later than before.
10
+
11
+ ### Added
12
+
13
+ - **spotify:** `activate()`, and `home spotify activate`.
14
+ - **cast:** `announceAll(targets, clip, options)`, one result per device, and repeated `--to` on `home cast announce` (`--json` is an array with several).
15
+ - **cast:** `form` on a device whose model it knows (`speaker`, `display`, `tv`, from `castForm(model)`), so "all speakers" can skip TVs. The address cache keeps the model; a cached device without one goes through discovery once.
16
+ - **up:** the integration has `client`, for a consumer's writes.
17
+
18
+ ### Changed
19
+
20
+ - **spotify:** `setVolume` while the speaker isn't the active device rejects with code `inactive`, instead of `confirmed: false`: Soloist accepts it and drops it. `duck` then runs `fn` with `music: "untouched"`.
21
+ - **spotify:** `play` while inactive goes active first.
22
+ - **spotify:** the `track` event waits for the track's name.
23
+ - **cast:** a clip that doesn't start playing fails after 15 s (or `timeoutMs`), naming its type. An error the receiver reports sooner gives its reason, and its code if it has one.
24
+
25
+ ### Fixed
26
+
27
+ - **spotify:** `maxVolume` waits until the speaker is active, retries until Soloist reports the lower volume, and says "lowered it" only then; if it never shows, `detail` says so. It said "lowered it" at 56 with a cap of 40. The window also opens when the speaker goes active.
28
+ - **spotify:** `setRepeat("off")` from `track` stayed on `track`: the two commands now go 600 ms apart.
29
+ - **spotify:** a volume within 1 of the request confirms, since Soloist rounds it.
30
+ - **spotify:** a stall repair can run again when a track comes back after another.
31
+ - **`home cast announce`** exits 1 when a clip didn't play.
32
+
7
33
  ## [0.1.1] - 2026-11-09
8
34
 
9
35
  Fixes from chota-bot's first live reads, on a Raspberry Pi and a Mac. No breaking changes.
@@ -42,6 +68,7 @@ First release. Nine connectors: Hue, Daikin, Enphase, Google Cast, AirPrint prin
42
68
  - **Agent skills** in `skills/`: `use-home` (reading safely, connecting, the rules for writes) and `draw-home` (an SVG plan of a home and its rooms). `llms.txt` points agents at the docs.
43
69
  - **Node.** Hue and Enphase take the fetches in `@khalido/home/node`. The library runs on Bun 1.4+ and Node 24+; the `home` command needs Bun.
44
70
 
45
- [Unreleased]: https://github.com/khalido/home.ts/compare/v0.1.1...HEAD
71
+ [Unreleased]: https://github.com/khalido/home.ts/compare/v0.1.2...HEAD
72
+ [0.1.2]: https://github.com/khalido/home.ts/compare/v0.1.1...v0.1.2
46
73
  [0.1.1]: https://github.com/khalido/home.ts/compare/v0.1.0...v0.1.1
47
74
  [0.1.0]: https://github.com/khalido/home.ts/releases/tag/v0.1.0
package/README.md CHANGED
@@ -50,17 +50,17 @@ The library never reads your environment or a file: you pass addresses, keys and
50
50
 
51
51
  ## Connectors
52
52
 
53
- | Connector | Reads | Writes |
54
- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
55
- | [hue](src/connectors/hue/README.md) | lights, rooms, scenes, over the bridge's event stream | lights, rooms, scenes |
56
- | [daikin](src/connectors/daikin/README.md) | the AC, over its local API | power, mode, setpoint, fan |
57
- | [enphase](src/connectors/enphase/README.md) | solar production and consumption from the IQ Gateway | none |
58
- | [cast](src/connectors/cast/README.md) | every Cast device and speaker group | volume, mute, stop, announcements |
59
- | [printers](src/connectors/printers/README.md) | AirPrint printers over IPP: state, toner, paper | none |
60
- | [spotify](src/connectors/spotify/README.md) | a Spotify Soloist speaker: what is playing, volume, shuffle, over its local WebSocket | play a link, pause, resume, skip, seek, volume (capped), queue, shuffle, repeat, each confirmed by what the speaker reports; turn down or pause around an announcement; a stalled stream noticed and, if asked, restarted |
61
- | [amber](src/connectors/amber/README.md) | live import and feed-in prices | none |
62
- | [up](src/connectors/up/README.md) | household spending by category, what still needs one | a category or tag on a transaction |
63
- | [weather](src/connectors/weather/README.md) | now, forecast by the hour and by part of the day, rain soon, the sun, from BOM, Open-Meteo and Google | none |
53
+ | Connector | Reads | Writes |
54
+ | --------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
55
+ | [hue](src/connectors/hue/README.md) | lights, rooms, scenes, over the bridge's event stream | lights, rooms, scenes |
56
+ | [daikin](src/connectors/daikin/README.md) | the AC, over its local API | power, mode, setpoint, fan |
57
+ | [enphase](src/connectors/enphase/README.md) | solar production and consumption from the IQ Gateway | none |
58
+ | [cast](src/connectors/cast/README.md) | every Cast device and speaker group | volume, mute, stop, announcements |
59
+ | [printers](src/connectors/printers/README.md) | AirPrint printers over IPP: state, toner, paper | none |
60
+ | [spotify](src/connectors/spotify/README.md) | a Spotify Soloist speaker: what is playing, volume, shuffle, over its local WebSocket | activate, play a link, pause, resume, skip, seek, volume (capped), queue, shuffle, repeat, each confirmed by what the speaker reports; turn down or pause around an announcement; a stalled stream noticed and, if asked, restarted |
61
+ | [amber](src/connectors/amber/README.md) | live import and feed-in prices | none |
62
+ | [up](src/connectors/up/README.md) | household spending by category, what still needs one | a category or tag on a transaction |
63
+ | [weather](src/connectors/weather/README.md) | now, forecast by the hour and by part of the day, rain soon, the sun, from BOM, Open-Meteo and Google | none |
64
64
 
65
65
  Each connector's README says what has run against a real device and what has not. This covers only the devices I own; [Home Assistant](https://www.home-assistant.io/) is the inspiration and the reference for every protocol.
66
66
 
package/dist/config.js CHANGED
@@ -331,7 +331,9 @@ export function loadHome(input, options) {
331
331
  {
332
332
  castId: x.castId,
333
333
  name: x.name,
334
- ...(c?.port === undefined ? {} : { host: c.host, port: c.port }),
334
+ ...(c?.port === undefined
335
+ ? {}
336
+ : { host: c.host, port: c.port, model: c.model }),
335
337
  },
336
338
  ];
337
339
  });
@@ -7,7 +7,8 @@ import { type CastConnect } from "./codec.ts";
7
7
  import { type CastDevice, type CastSnapshot, type Resolved } from "./state.ts";
8
8
  import { type CastActions, type ServeClip } from "./writes.ts";
9
9
  export type { CastConnect } from "./codec.ts";
10
- export type { CastDevice, CastSnapshot, Resolved } from "./state.ts";
10
+ export { castForm } from "./state.ts";
11
+ export type { CastDevice, CastForm, CastSnapshot, Resolved } from "./state.ts";
11
12
  export type { AnnounceResult, CastActions, CastClip, ServeClip, } from "./writes.ts";
12
13
  /** A Cast device or speaker group in a home config. No host: a group's moves with its leader, so every address comes from discovery. */
13
14
  export declare const configSchema: z.ZodObject<{
@@ -30,6 +31,7 @@ export type CastConfig = {
30
31
  expect?: {
31
32
  castId: string;
32
33
  name: string;
34
+ model?: string;
33
35
  host?: string;
34
36
  port?: number;
35
37
  }[];
@@ -38,6 +40,7 @@ export type CastConfig = {
38
40
  host: string;
39
41
  port: number;
40
42
  name?: string;
43
+ model?: string;
41
44
  }) => void;
42
45
  clock?: Clock;
43
46
  /** Heartbeat interval; a socket silent for three of them is dropped. */
@@ -7,6 +7,7 @@ import { NS } from "./codec.js";
7
7
  import { CastPayload, KNOWN_TYPES } from "./schemas.js";
8
8
  import { castKey, connection, found, mediaApp, reduce, } from "./state.js";
9
9
  import { castActions, } from "./writes.js";
10
+ export { castForm } from "./state.js";
10
11
  /** A Cast device or speaker group in a home config. No host: a group's moves with its leader, so every address comes from discovery. */
11
12
  export const configSchema = z.looseObject({
12
13
  connector: z.literal("cast"),
@@ -205,6 +206,7 @@ export function cast(config) {
205
206
  host: d.host,
206
207
  port: d.port,
207
208
  name: named.has(id) ? d.name : undefined,
209
+ model: d.model,
208
210
  }); // a config's name never reaches the cache
209
211
  publish();
210
212
  void (async () => {
@@ -359,10 +361,14 @@ export function cast(config) {
359
361
  report = update;
360
362
  running = signal;
361
363
  const expect = config.expect ?? [];
362
- const cached = expect.filter((e) => e.host !== undefined && e.port !== undefined);
364
+ // A device with no model (a cache from before 0.1.2) goes through discovery once, or it would never get a `form`.
365
+ const cached = expect.filter((e) => e.host !== undefined &&
366
+ e.port !== undefined &&
367
+ (e.port !== 8009 || e.model !== undefined));
363
368
  snapshot = found(snapshot, cached.map((e) => ({
364
369
  id: e.castId,
365
370
  name: e.name,
371
+ model: e.model,
366
372
  host: e.host,
367
373
  port: e.port,
368
374
  group: e.port !== 8009,
@@ -41,6 +41,7 @@ export declare const MediaStatus: z.ZodObject<{
41
41
  mediaSessionId: z.ZodOptional<z.ZodNumber>;
42
42
  playerState: z.ZodOptional<z.ZodString>;
43
43
  idleReason: z.ZodOptional<z.ZodString>;
44
+ detailedErrorCode: z.ZodOptional<z.ZodNumber>;
44
45
  currentTime: z.ZodOptional<z.ZodNumber>;
45
46
  media: z.ZodOptional<z.ZodObject<{
46
47
  contentId: z.ZodOptional<z.ZodString>;
@@ -118,6 +119,7 @@ export declare const CastPayload: z.ZodDiscriminatedUnion<[z.ZodObject<{
118
119
  mediaSessionId: z.ZodOptional<z.ZodNumber>;
119
120
  playerState: z.ZodOptional<z.ZodString>;
120
121
  idleReason: z.ZodOptional<z.ZodString>;
122
+ detailedErrorCode: z.ZodOptional<z.ZodNumber>;
121
123
  currentTime: z.ZodOptional<z.ZodNumber>;
122
124
  media: z.ZodOptional<z.ZodObject<{
123
125
  contentId: z.ZodOptional<z.ZodString>;
@@ -36,6 +36,7 @@ export const MediaStatus = z.looseObject({
36
36
  mediaSessionId: z.number().optional(),
37
37
  playerState: z.string().optional(), // "PLAYING" | "PAUSED" | "BUFFERING" | "IDLE"
38
38
  idleReason: z.string().optional(), // "FINISHED" | "CANCELLED" | "INTERRUPTED" | "ERROR"
39
+ detailedErrorCode: z.number().optional(),
39
40
  currentTime: z.number().optional(), // seconds into the media
40
41
  media: z
41
42
  .looseObject({
@@ -1,4 +1,7 @@
1
1
  import type { CastPayload } from "./schemas.ts";
2
+ /** What a device is for, read from its model name; a model it doesn't know has none. A group is `kind: "group"`. */
3
+ export type CastForm = "speaker" | "display" | "tv";
4
+ export declare function castForm(model?: string): CastForm | undefined;
2
5
  /** A Cast device or group as found by discovery: what `resolve` returns for each. */
3
6
  export type Resolved = {
4
7
  id: string;
@@ -16,6 +19,7 @@ export type CastDevice = {
16
19
  id: string;
17
20
  name: string;
18
21
  model?: string;
22
+ form?: CastForm;
19
23
  kind: "device" | "group";
20
24
  host?: string;
21
25
  port?: number;
@@ -1,3 +1,14 @@
1
+ export function castForm(model) {
2
+ if (!model)
3
+ return undefined;
4
+ if (/hub|tablet|display/i.test(model))
5
+ return "display";
6
+ if (/audio|speaker|mini|google home/i.test(model))
7
+ return "speaker";
8
+ if (/chromecast|streamer|\btv\b/i.test(model))
9
+ return "tv";
10
+ return undefined;
11
+ }
1
12
  /** An id as the snapshot keys it: the mDNS id (32 hex) and the certificate's and multizone's dashed UUID are one key. */
2
13
  export const castKey = (id) => id.toLowerCase().replace(/-/g, "");
3
14
  /** Add or update what discovery found; devices it did not find this time are kept as they were. */
@@ -11,6 +22,7 @@ export function found(s, entries, asOf) {
11
22
  id,
12
23
  name: e.name,
13
24
  model: e.model,
25
+ form: kind === "device" ? castForm(e.model) : undefined,
14
26
  kind,
15
27
  host: e.host,
16
28
  port: e.port,
@@ -6,6 +6,8 @@ export declare const DEFAULT_MEDIA_RECEIVER = "CC1AD845";
6
6
  export declare const ANSWER_MS = 10000;
7
7
  /** How long an announcement waits for its clip to finish, beyond the clip's own length when the device reports it. */
8
8
  export declare const PLAY_MS = 60000;
9
+ /** How long a clip gets to start playing. A clip a speaker can't play took 35–44 s to say so (live, 2026-11-09); good ones start within a few seconds. A stated guess. */
10
+ export declare const START_MS = 15000;
9
11
  /** Level writes, and mute writes, per device or group in any minute; more wait their turn, the newest winning. A guess: Cast publishes no limit. */
10
12
  export declare const VOLUME_PER_MINUTE = 30;
11
13
  /** What to play: a URL the device can fetch (type defaults to audio/mpeg), or bytes for `serve` to put on the LAN. */
@@ -44,6 +46,13 @@ export type CastActions = {
44
46
  volume?: number;
45
47
  timeoutMs?: number;
46
48
  }): Promise<AnnounceResult>;
49
+ /** `announce` to several targets at once, one result per device, in the order given. A target that can't be reached is its own `played: false`. A group and one of its own members are not told apart. */
50
+ announceAll(targets: string[], clip: CastClip, options?: {
51
+ volume?: number;
52
+ timeoutMs?: number;
53
+ }): Promise<(AnnounceResult & {
54
+ target: string;
55
+ })[]>;
47
56
  };
48
57
  /** A payload as it came, before any schema. */
49
58
  export type RawPayload = {
@@ -11,6 +11,8 @@ export const DEFAULT_MEDIA_RECEIVER = "CC1AD845";
11
11
  export const ANSWER_MS = 10_000;
12
12
  /** How long an announcement waits for its clip to finish, beyond the clip's own length when the device reports it. */
13
13
  export const PLAY_MS = 60_000;
14
+ /** How long a clip gets to start playing. A clip a speaker can't play took 35–44 s to say so (live, 2026-11-09); good ones start within a few seconds. A stated guess. */
15
+ export const START_MS = 15_000;
14
16
  const RECEIVER = "receiver-0";
15
17
  /** Level writes, and mute writes, per device or group in any minute; more wait their turn, the newest winning. A guess: Cast publishes no limit. */
16
18
  export const VOLUME_PER_MINUTE = 30;
@@ -33,6 +35,7 @@ function checkLevel(level) {
33
35
  * aborts, or after `ms` (a function is read again each time it would expire, so a deadline can grow).
34
36
  */
35
37
  function waitFor(clock, s, ms, what, pick, signal) {
38
+ const name = () => (typeof what === "string" ? what : what());
36
39
  return new Promise((resolve, reject) => {
37
40
  const done = new AbortController();
38
41
  const finish = (settle) => done.signal.aborted ||
@@ -40,10 +43,10 @@ function waitFor(clock, s, ms, what, pick, signal) {
40
43
  signal?.removeEventListener("abort", cancel),
41
44
  done.abort(),
42
45
  settle());
43
- const cancel = () => finish(() => reject(new Error(`stopped waiting for ${what}`)));
46
+ const cancel = () => finish(() => reject(new Error(`stopped waiting for ${name()}`)));
44
47
  const off = s.listen((raw) => {
45
48
  if (raw === undefined)
46
- return finish(() => reject(new Error(`the socket closed waiting for ${what}`)));
49
+ return finish(() => reject(new Error(`the socket closed waiting for ${name()}`)));
47
50
  try {
48
51
  const value = pick(raw);
49
52
  if (value !== undefined)
@@ -59,7 +62,7 @@ function waitFor(clock, s, ms, what, pick, signal) {
59
62
  void (async () => {
60
63
  for (let left = limit(); left > 0 && !done.signal.aborted; left = start + limit() - clock.now())
61
64
  await clock.sleep(left, done.signal);
62
- finish(() => reject(new Error(`no ${what} in ${Math.round(limit() / 1000)} s`)));
65
+ finish(() => reject(new Error(`no ${name()} in ${Math.round(limit() / 1000)} s`)));
63
66
  })();
64
67
  });
65
68
  }
@@ -171,8 +174,11 @@ export function castActions(ctx) {
171
174
  const loadId = s.nextId();
172
175
  let session;
173
176
  let durationS = 0;
177
+ let started = false; // it has reached PLAYING: BUFFERING is where a clip it can't play sits
174
178
  const cancel = new AbortController();
175
- const finished = waitFor(clock, s, () => timeoutMs ?? PLAY_MS + durationS * 1000, "end of the clip", (raw) => {
179
+ const finished = waitFor(clock, s, () => timeoutMs ?? (started ? PLAY_MS + durationS * 1000 : START_MS), () => started
180
+ ? "end of the clip"
181
+ : `start of the clip (${media.contentType}, which this device may not play)`, (raw) => {
176
182
  const r = MediaStatus.safeParse(raw);
177
183
  if (!r.success)
178
184
  return undefined;
@@ -187,11 +193,13 @@ export function castActions(ctx) {
187
193
  continue;
188
194
  if (typeof m.media?.duration === "number")
189
195
  durationS = m.media.duration;
196
+ if (m.playerState === "PLAYING")
197
+ started = true;
190
198
  if (m.playerState !== "IDLE" || !m.idleReason)
191
199
  continue;
192
200
  if (m.idleReason === "FINISHED")
193
201
  return true;
194
- throw new Error(`the clip stopped: ${m.idleReason}`);
202
+ throw new Error(`the clip stopped: ${m.idleReason}${m.detailedErrorCode === undefined ? "" : ` (detailed error ${m.detailedErrorCode})`}${m.idleReason === "ERROR" ? `; this device may not play ${media.contentType}` : ""}`);
195
203
  }
196
204
  return undefined;
197
205
  }, cancel.signal);
@@ -216,7 +224,7 @@ export function castActions(ctx) {
216
224
  .get(id)
217
225
  .send("level" in volume ? "level" : "muted", volume, (v) => receiver(live(find(target)), { type: "SET_VOLUME", volume: v }));
218
226
  };
219
- return {
227
+ const actions = {
220
228
  async setVolume(target, level) {
221
229
  checkLevel(level);
222
230
  await setVolume(target, { level });
@@ -386,5 +394,33 @@ export function castActions(ctx) {
386
394
  }
387
395
  }
388
396
  },
397
+ async announceAll(targets, clip, options = {}) {
398
+ // A caller's mistake is one error, not a failure per speaker.
399
+ if (options.volume !== undefined)
400
+ checkLevel(options.volume);
401
+ if (!("url" in clip) && !ctx.serve)
402
+ throw new Error("a clip of bytes needs serve: serveClip from @khalido/home/cast/serve");
403
+ const id = (t) => {
404
+ try {
405
+ return find(t).id;
406
+ }
407
+ catch {
408
+ return t; // unknown: announce says so, as this target's own failure
409
+ }
410
+ };
411
+ const once = targets.filter((t, i) => targets.findIndex((o) => id(o) === id(t)) === i); // one device twice would save the first's volume as the one to restore
412
+ return Promise.all(once.map(async (target) => ({
413
+ target,
414
+ ...(await actions
415
+ .announce(target, clip, options)
416
+ .catch((error) => ({
417
+ played: false,
418
+ why: errorText(error),
419
+ restored: [],
420
+ notRestored: [],
421
+ }))),
422
+ })));
423
+ },
389
424
  };
425
+ return actions;
390
426
  }
@@ -24,7 +24,7 @@ export type SpotifyEvent = {
24
24
  /**
25
25
  * The events between two reports, for `onChange`'s `before` and `after`: `offline` and `online`; `waiting` (signed out) and
26
26
  * `picked` (someone picked the speaker); `started` (from idle), `resumed` (from paused), `paused`, `stopped` (to idle), then
27
- * `track` for a new track, so "started" and the track come as a pair, then `stalled` and `recovered` (Soloist issue #5). Buffering counts as playing. No `before`, or one
27
+ * `track` for a new track, once it has a name, then `stalled` and `recovered` (Soloist issue #5). Buffering counts as playing. No `before`, or one
28
28
  * with no state (createHome's placeholder before the first report, as at a restart): nothing, since nothing was seen to change. No volume or queue events: read the state.
29
29
  */
30
30
  export declare function spotifyChanges(before: Part<SpotifyState> | undefined, after: Part<SpotifyState> | undefined): SpotifyEvent[];
@@ -22,7 +22,7 @@ const PLAYING = new Set(["playing", "buffering"]);
22
22
  /**
23
23
  * The events between two reports, for `onChange`'s `before` and `after`: `offline` and `online`; `waiting` (signed out) and
24
24
  * `picked` (someone picked the speaker); `started` (from idle), `resumed` (from paused), `paused`, `stopped` (to idle), then
25
- * `track` for a new track, so "started" and the track come as a pair, then `stalled` and `recovered` (Soloist issue #5). Buffering counts as playing. No `before`, or one
25
+ * `track` for a new track, once it has a name, then `stalled` and `recovered` (Soloist issue #5). Buffering counts as playing. No `before`, or one
26
26
  * with no state (createHome's placeholder before the first report, as at a restart): nothing, since nothing was seen to change. No volume or queue events: read the state.
27
27
  */
28
28
  export function spotifyChanges(before, after) {
@@ -55,7 +55,9 @@ export function spotifyChanges(before, after) {
55
55
  add({ type: "paused" });
56
56
  if (to === "idle" && from !== undefined && from !== "idle")
57
57
  add({ type: "stopped" });
58
- if (a?.track && a.track.uri !== b?.track?.uri)
58
+ // Soloist's first report of a new track can have no name yet (seen live): the event waits for it.
59
+ const named = (p) => !!p?.track?.name;
60
+ if (a?.track && named(a) && (a.track.uri !== b?.track?.uri || !named(b)))
59
61
  add({ type: "track", track: a.track });
60
62
  if (a?.stalled && !b?.stalled)
61
63
  events.push({ type: "stalled", stall: a.stalled });
@@ -158,7 +158,7 @@ export type SpotifyState = {
158
158
  name?: string;
159
159
  };
160
160
  position?: PositionAnchor;
161
- /** 0–100, as Soloist reports it. */
161
+ /** 0–100, as Soloist reports it, rounded. Until `active` it isn't the level that will play (it reads `--initial-volume`, seen live). */
162
162
  volume?: number;
163
163
  shuffle?: boolean;
164
164
  /** off, context or track. */
@@ -49,9 +49,11 @@ export type Aside<T> = {
49
49
  /**
50
50
  * The writes, on the object `spotify()` returns. Each resolves once Soloist reports the change it asked for, with the state
51
51
  * then (`confirmed: true`), or after `CONFIRM_MS` with the state as it stands (`confirmed: false`). Each rejects with a
52
- * `ConnectorError` whose `code` is `offline`, `waiting` (nobody has picked the speaker), `refused`, `timeout` or `invalid`.
52
+ * `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`.
53
53
  */
54
54
  export type SpotifyActions = {
55
+ /** 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. */
56
+ activate(): Promise<Applied>;
55
57
  /** Play a Spotify link or URI (track, album, playlist, artist, episode, show), or resume what is loaded when none is given. */
56
58
  play(linkOrUri?: string): Promise<Applied>;
57
59
  pause(): Promise<Applied>;