@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
@@ -8,12 +8,12 @@ Nest speakers, Nest Hubs, Chromecasts, Google TV and speaker groups, read over C
8
8
  - Per socket: `CONNECT` to `receiver-0`, receiver `GET_STATUS`, multizone `GET_STATUS` (a group's members; on a device's own port, the device alone, by its current name), then whatever the device pushes. A running app with the media namespace gets a `CONNECT` and media `GET_STATUS` on its `transportId`, as Home Assistant does. A device's `PING` is answered with `PONG`; a socket silent for three heartbeats (15 s), or without a receiver status 10 s after connecting, is closed and the detail says which.
9
9
  - A dropped socket reconnects with backoff (1 s doubling to 60 s) at whatever address discovery last gave. The drop starts one shared `resolve()` at once (a group's address changes with its leader); while anything stays down or missing, that one discovery repeats at 1, 5 and 15 minutes, then hourly, as the relocator does, never once per socket. `refresh()` re-asks every open socket and nudges the discovery, which also finds devices when nothing was found at start.
10
10
  - The part is `connecting` until every device has answered or failed, then `connected` while any is up; `detail` names the ones down. A payload of a known type the schema rejects is an `anomaly`, never thrown.
11
- - State (`CastSnapshot`, by mDNS id): app, `idle` (no app, or Backdrop's `isIdleScreen`), `standby`, volume (unrounded), muted, `volumeControl`, player state, title, artist; groups carry `members` and `leader`; devices carry `groups`, and `playingVia` when they show a playing group's media because their own is empty (Home Assistant's model, not yet seen live).
11
+ - State (`CastSnapshot`, by mDNS id): `form` (`speaker`, `display` or `tv`, from `castForm(model)`; none for a model it doesn't know; the address cache keeps the model), app, `idle` (no app, or Backdrop's `isIdleScreen`), `standby`, volume (unrounded), muted, `volumeControl`, player state, title, artist; groups carry `members` and `leader`; devices carry `groups`, and `playingVia` when they show a playing group's media because their own is empty (Home Assistant's model, not yet seen live).
12
12
  - Names: a device's `name` in the snapshot is its own, from discovery's `fn` or its own multizone status (the same text on all six live devices, 2026-10-13), so a rename in the Google Home app shows once the device answers, even when every address came from the cache and discovery never ran; the config's name is only the placeholder until then. A group's multizone status lists its members, not itself, so a group's new name shows at the next discovery.
13
13
  - Writes, on the object `cast()` returns (`CastActions`), over the sockets it already holds, never a second connection. A target is a name (any case) or a castId; volume is 0 to 1, as the snapshot's. `setVolume`, `setMuted` (receiver `SET_VOLUME`; a group's goes to the group's socket; per device, levels and mutes each at most 30 a minute, the newest waiting one winning; one device's lane never waits on another's) and `stop` (receiver `STOP` of the running app; nothing when idle). Each request waits 10 s for its reply and a refusal (`LAUNCH_ERROR`, `LOAD_FAILED`, …) rejects, naming it.
14
- - `announce(target, clip, { volume?, timeoutMs? })`: `clip` is `{ url, type? }` or `{ bytes, type }`, bytes served through `serve` (`serveClip` from `@khalido/home/cast/serve`, passed to `cast()`). It reads the receiver status (and a Default Media Receiver's media status), for a group re-runs discovery first and reconnects if the leader moved, sets the volume if given and unmutes a muted target (as two requests: a `SET_VOLUME` takes a level or a mute, not both), launches the Default Media Receiver (`CC1AD845`), `LOAD`s the clip and waits for its media session to go `IDLE` with `FINISHED` (60 s plus the clip's length when the device reports it, or `timeoutMs`). Then, always: what the Default Media Receiver was playing is loaded again at its `currentTime`, an idle device gets `STOP`, then the volume and mute (a group's member by member, through each member's own socket, from the group's multizone status). Another app (Spotify, YouTube) is interrupted, as Google's own announcements do, and the result names it in `notRestored`. It returns `{ played, why?, restored, notRestored }` and throws only before anything changed (an unknown target, a socket down).
14
+ - `announce(target, clip, { volume?, timeoutMs? })`: `clip` is `{ url, type? }` or `{ bytes, type }`, bytes served through `serve` (`serveClip` from `@khalido/home/cast/serve`, passed to `cast()`). It reads the receiver status (and a Default Media Receiver's media status), for a group re-runs discovery first and reconnects if the leader moved, sets the volume if given and unmutes a muted target (as two requests: a `SET_VOLUME` takes a level or a mute, not both), launches the Default Media Receiver (`CC1AD845`), `LOAD`s the clip and waits for its media session to go `IDLE` with `FINISHED` (60 s plus the clip's length when the device reports it, or `timeoutMs`). Then, always: what the Default Media Receiver was playing is loaded again at its `currentTime`, an idle device gets `STOP`, then the volume and mute (a group's member by member, through each member's own socket, from the group's multizone status). Another app (Spotify, YouTube) is interrupted, as Google's own announcements do, and the result names it in `notRestored`. It returns `{ played, why?, restored, notRestored }` and throws only before anything changed (an unknown target, a socket down). A clip gets 15 s to reach `PLAYING`, then the name of its type goes in the failure, since a device that can't play it took 35-44 s to say so; a receiver error sooner carries its reason and code. `timeoutMs` replaces both waits. `announceAll(targets, clip, options)` does several at once, one result per device, an unreachable target its own `played: false`; a group and one of its members would clash, untried.
15
15
  - `serveClip(bytes, type, { host?, signal? })` (`node:http`, Node-only like `tls.ts`): an address of this machine on the target's network (else the first non-internal IPv4), a random port and an unguessable path, `Content-Type` and `Content-Length`; the whole body to every GET and HEAD (a `Range` probe gets a full 200, which HTTP allows), so a player that probes before it fetches still gets the clip. It closes on `close()`, which `announce` calls once the clip has played, when `signal` aborts, or after 60 s. Not yet seen live: how the Default Media Receiver fetches, and the macOS local-network prompt the first serve may raise.
16
- - CLI: `home cast announce --to <name> (--say "<text>" | --file <path>) [--volume 0-100]`, `home cast volume <name> <0-100>`, `home cast mute <name> on|off`, `home cast stop <name>`. `--say` runs `say -o <tmp>.m4a --file-format=m4af --data-format=aac` (without the data format, m4af holds uncompressed PCM), serves it as `audio/mp4` and deletes the file. The library never runs `say`.
16
+ - CLI: `home cast announce --to <name> [--to <name>…] (--say "<text>" | --file <path>) [--volume 0-100]`, `home cast volume <name> <0-100>`, `home cast mute <name> on|off`, `home cast stop <name>`. `--say` runs `say -o <tmp>.m4a --file-format=m4af --data-format=aac` (without the data format, m4af holds uncompressed PCM), serves it as `audio/mp4` and deletes the file. The library never runs `say`.
17
17
  - Config: one entry per device or group, `{ "connector": "cast", "name": "…", "castId": "…" }`, all folded into one `cast` integration; `home discover --write` fills them and refreshes a renamed entry's `name` (the same `castId` with a new `fn`), printed as `renamed`. No host: discovery gives every address.
18
18
 
19
19
  ## Protocol
@@ -27,6 +27,7 @@ CastV2 is TLS to port 8009 (a group: its own port), carrying length-prefixed pro
27
27
  - **Receiver status**: hubs and the Chromecast Ultra run Backdrop (`E8C28D3C`) with `isIdleScreen: true`; the speakers report no application; the TV Streamer reports `isStandBy: true`, `isActiveInput: false` and a `fixed` volume; the Ultra's volume is `attenuation`.
28
28
  - No device sent a `PING` in 12 s of listening, so our 5 s ping is what keeps the socket's silence check honest.
29
29
  - Media status first seen live 2026-11-09 (Bun 1.4.4): a paused title and artist on a TV.
30
+ - **First announcements, 2026-11-09, from a Pi on 0.1.1.** MP3 played on two audio-only speakers and two displays, 7-8 s each, the clip fetched from the Pi's LAN address, idle and volume restored. A 24 kHz mono WAV played on the displays and failed on both audio-only speakers, "the clip stopped: ERROR" after 35-44 s. One WAV format tried: use MP3 or AAC.
30
31
  - Not seen yet: a group playing, a leader change, member changes, and Node's `tls` (no Node caller yet).
31
32
 
32
33
  ## TLS: verification off, the name checked
@@ -27,7 +27,8 @@ import {
27
27
  } from "./writes.ts";
28
28
 
29
29
  export type { CastConnect } from "./codec.ts";
30
- export type { CastDevice, CastSnapshot, Resolved } from "./state.ts";
30
+ export { castForm } from "./state.ts";
31
+ export type { CastDevice, CastForm, CastSnapshot, Resolved } from "./state.ts";
31
32
  export type {
32
33
  AnnounceResult,
33
34
  CastActions,
@@ -77,11 +78,17 @@ export type CastConfig = {
77
78
  * `host` and `port` (an address cache) an entry connects at once; discovery runs only when an entry has none, or a
78
79
  * cached address fails. A cached port other than 8009 is a group's (devices always listen on 8009).
79
80
  */
80
- expect?: { castId: string; name: string; host?: string; port?: number }[];
81
+ expect?: {
82
+ castId: string;
83
+ name: string;
84
+ model?: string;
85
+ host?: string;
86
+ port?: number;
87
+ }[];
81
88
  /** Each address that connected and passed the certificate check, for an address cache; again with the device's new name when it reports a rename. `name` is the device's own, never a config's. */
82
89
  seen?: (
83
90
  id: string,
84
- address: { host: string; port: number; name?: string },
91
+ address: { host: string; port: number; name?: string; model?: string },
85
92
  ) => void;
86
93
  clock?: Clock;
87
94
  /** Heartbeat interval; a socket silent for three of them is dropped. */
@@ -304,6 +311,7 @@ export function cast(
304
311
  host: d.host!,
305
312
  port: d.port!,
306
313
  name: named.has(id) ? d.name : undefined,
314
+ model: d.model,
307
315
  }); // a config's name never reaches the cache
308
316
  publish();
309
317
  void (async () => {
@@ -468,14 +476,19 @@ export function cast(
468
476
  report = update;
469
477
  running = signal;
470
478
  const expect = config.expect ?? [];
479
+ // A device with no model (a cache from before 0.1.2) goes through discovery once, or it would never get a `form`.
471
480
  const cached = expect.filter(
472
- (e) => e.host !== undefined && e.port !== undefined,
481
+ (e) =>
482
+ e.host !== undefined &&
483
+ e.port !== undefined &&
484
+ (e.port !== 8009 || e.model !== undefined),
473
485
  );
474
486
  snapshot = found(
475
487
  snapshot,
476
488
  cached.map((e) => ({
477
489
  id: e.castId,
478
490
  name: e.name,
491
+ model: e.model,
479
492
  host: e.host!,
480
493
  port: e.port!,
481
494
  group: e.port !== 8009,
@@ -43,6 +43,7 @@ export const MediaStatus = z.looseObject({
43
43
  mediaSessionId: z.number().optional(),
44
44
  playerState: z.string().optional(), // "PLAYING" | "PAUSED" | "BUFFERING" | "IDLE"
45
45
  idleReason: z.string().optional(), // "FINISHED" | "CANCELLED" | "INTERRUPTED" | "ERROR"
46
+ detailedErrorCode: z.number().optional(),
46
47
  currentTime: z.number().optional(), // seconds into the media
47
48
  media: z
48
49
  .looseObject({
@@ -1,5 +1,15 @@
1
1
  import type { CastPayload } from "./schemas.ts";
2
2
 
3
+ /** What a device is for, read from its model name; a model it doesn't know has none. A group is `kind: "group"`. */
4
+ export type CastForm = "speaker" | "display" | "tv";
5
+ export function castForm(model?: string): CastForm | undefined {
6
+ if (!model) return undefined;
7
+ if (/hub|tablet|display/i.test(model)) return "display";
8
+ if (/audio|speaker|mini|google home/i.test(model)) return "speaker";
9
+ if (/chromecast|streamer|\btv\b/i.test(model)) return "tv";
10
+ return undefined;
11
+ }
12
+
3
13
  /** A Cast device or group as found by discovery: what `resolve` returns for each. */
4
14
  export type Resolved = {
5
15
  id: string;
@@ -18,6 +28,7 @@ export type CastDevice = {
18
28
  id: string;
19
29
  name: string;
20
30
  model?: string;
31
+ form?: CastForm; // devices: speaker, display or tv, when the model is one it knows
21
32
  kind: "device" | "group";
22
33
  host?: string;
23
34
  port?: number;
@@ -63,6 +74,7 @@ export function found(
63
74
  id,
64
75
  name: e.name,
65
76
  model: e.model,
77
+ form: kind === "device" ? castForm(e.model) : undefined,
66
78
  kind,
67
79
  host: e.host,
68
80
  port: e.port,
@@ -19,6 +19,8 @@ export const DEFAULT_MEDIA_RECEIVER = "CC1AD845";
19
19
  export const ANSWER_MS = 10_000;
20
20
  /** How long an announcement waits for its clip to finish, beyond the clip's own length when the device reports it. */
21
21
  export const PLAY_MS = 60_000;
22
+ /** 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. */
23
+ export const START_MS = 15_000;
22
24
 
23
25
  const RECEIVER = "receiver-0";
24
26
  /** 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. */
@@ -64,6 +66,12 @@ export type CastActions = {
64
66
  clip: CastClip,
65
67
  options?: { volume?: number; timeoutMs?: number },
66
68
  ): Promise<AnnounceResult>;
69
+ /** `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. */
70
+ announceAll(
71
+ targets: string[],
72
+ clip: CastClip,
73
+ options?: { volume?: number; timeoutMs?: number },
74
+ ): Promise<(AnnounceResult & { target: string })[]>;
67
75
  };
68
76
 
69
77
  /** A payload as it came, before any schema. */
@@ -115,10 +123,11 @@ function waitFor<T>(
115
123
  clock: Clock,
116
124
  s: CastSession,
117
125
  ms: number | (() => number),
118
- what: string,
126
+ what: string | (() => string),
119
127
  pick: (raw: RawPayload) => T | undefined,
120
128
  signal?: AbortSignal,
121
129
  ): Promise<T> {
130
+ const name = () => (typeof what === "string" ? what : what());
122
131
  return new Promise<T>((resolve, reject) => {
123
132
  const done = new AbortController();
124
133
  const finish = (settle: () => void) =>
@@ -128,11 +137,11 @@ function waitFor<T>(
128
137
  done.abort(),
129
138
  settle());
130
139
  const cancel = () =>
131
- finish(() => reject(new Error(`stopped waiting for ${what}`)));
140
+ finish(() => reject(new Error(`stopped waiting for ${name()}`)));
132
141
  const off = s.listen((raw) => {
133
142
  if (raw === undefined)
134
143
  return finish(() =>
135
- reject(new Error(`the socket closed waiting for ${what}`)),
144
+ reject(new Error(`the socket closed waiting for ${name()}`)),
136
145
  );
137
146
  try {
138
147
  const value = pick(raw);
@@ -152,7 +161,7 @@ function waitFor<T>(
152
161
  )
153
162
  await clock.sleep(left, done.signal);
154
163
  finish(() =>
155
- reject(new Error(`no ${what} in ${Math.round(limit() / 1000)} s`)),
164
+ reject(new Error(`no ${name()} in ${Math.round(limit() / 1000)} s`)),
156
165
  );
157
166
  })();
158
167
  });
@@ -318,18 +327,22 @@ export function castActions(ctx: WriteContext): CastActions {
318
327
  const play = async (
319
328
  s: CastSession,
320
329
  transport: string,
321
- media: object,
330
+ media: { contentId: string; contentType: string; streamType: string },
322
331
  timeoutMs?: number,
323
332
  ): Promise<boolean> => {
324
333
  const loadId = s.nextId();
325
334
  let session: number | "any" | undefined;
326
335
  let durationS = 0;
336
+ let started = false; // it has reached PLAYING: BUFFERING is where a clip it can't play sits
327
337
  const cancel = new AbortController();
328
338
  const finished = waitFor(
329
339
  clock,
330
340
  s,
331
- () => timeoutMs ?? PLAY_MS + durationS * 1000,
332
- "end of the clip",
341
+ () => timeoutMs ?? (started ? PLAY_MS + durationS * 1000 : START_MS),
342
+ () =>
343
+ started
344
+ ? "end of the clip"
345
+ : `start of the clip (${media.contentType}, which this device may not play)`,
333
346
  (raw) => {
334
347
  const r = MediaStatus.safeParse(raw);
335
348
  if (!r.success) return undefined;
@@ -345,9 +358,12 @@ export function castActions(ctx: WriteContext): CastActions {
345
358
  continue;
346
359
  if (typeof m.media?.duration === "number")
347
360
  durationS = m.media.duration;
361
+ if (m.playerState === "PLAYING") started = true;
348
362
  if (m.playerState !== "IDLE" || !m.idleReason) continue;
349
363
  if (m.idleReason === "FINISHED") return true;
350
- throw new Error(`the clip stopped: ${m.idleReason}`);
364
+ throw new Error(
365
+ `the clip stopped: ${m.idleReason}${m.detailedErrorCode === undefined ? "" : ` (detailed error ${m.detailedErrorCode})`}${m.idleReason === "ERROR" ? `; this device may not play ${media.contentType}` : ""}`,
366
+ );
351
367
  }
352
368
  return undefined;
353
369
  },
@@ -390,7 +406,7 @@ export function castActions(ctx: WriteContext): CastActions {
390
406
  );
391
407
  };
392
408
 
393
- return {
409
+ const actions: CastActions = {
394
410
  async setVolume(target, level) {
395
411
  checkLevel(level);
396
412
  await setVolume(target, { level });
@@ -568,5 +584,38 @@ export function castActions(ctx: WriteContext): CastActions {
568
584
  }
569
585
  }
570
586
  },
587
+
588
+ async announceAll(targets, clip, options = {}) {
589
+ // A caller's mistake is one error, not a failure per speaker.
590
+ if (options.volume !== undefined) checkLevel(options.volume);
591
+ if (!("url" in clip) && !ctx.serve)
592
+ throw new Error(
593
+ "a clip of bytes needs serve: serveClip from @khalido/home/cast/serve",
594
+ );
595
+ const id = (t: string) => {
596
+ try {
597
+ return find(t).id;
598
+ } catch {
599
+ return t; // unknown: announce says so, as this target's own failure
600
+ }
601
+ };
602
+ const once = targets.filter(
603
+ (t, i) => targets.findIndex((o) => id(o) === id(t)) === i,
604
+ ); // one device twice would save the first's volume as the one to restore
605
+ return Promise.all(
606
+ once.map(async (target) => ({
607
+ target,
608
+ ...(await actions
609
+ .announce(target, clip, options)
610
+ .catch((error): AnnounceResult => ({
611
+ played: false,
612
+ why: errorText(error),
613
+ restored: [],
614
+ notRestored: [],
615
+ }))),
616
+ })),
617
+ );
618
+ },
571
619
  };
620
+ return actions;
572
621
  }
@@ -1,6 +1,6 @@
1
1
  # Spotify (Soloist)
2
2
 
3
- What a [Spotify Soloist](https://developer.spotify.com/documentation/soloist) speaker is playing (start with its [basic integration](https://developer.spotify.com/documentation/soloist/howtos/basic-integration)), and control over it, over Soloist's local WebSocket. `iot_class`: **local_push**. Reads seen live on a real Soloist (2026-11-09); writes tested against a fake socket only.
3
+ What a [Spotify Soloist](https://developer.spotify.com/documentation/soloist) speaker is playing (start with its [basic integration](https://developer.spotify.com/documentation/soloist/howtos/basic-integration)), and control over it, over Soloist's local WebSocket. `iot_class`: **local_push**. Reads and most writes seen live on a real Soloist (0.1.1, 2026-11-09); what 0.1.2 changed since is tested against a fake socket only.
4
4
 
5
5
  Soloist is Spotify's own headless Spotify Connect client (Linux arm64, armv7, x86_64, Apple Silicon, Windows x86_64). The machine running it is a speaker in every Spotify app on the network; this connector is how a program on the same machine sees and steers it. It doesn't search Spotify or read a library: Soloist plays URIs and reports its own session. Search belongs to the consumer (KO, 2026-11-08; `docs/ideas.md`).
6
6
 
@@ -14,15 +14,17 @@ Soloist is Spotify's own headless Spotify Connect client (Linux arm64, armv7, x8
14
14
 
15
15
  **State.** Each frame passes a zod schema, every field optional, and folds into `SpotifyState`: `loggedIn`, `deviceName`, `active`, `status`, `track` (`uri`, `type`, `name`, `artists`, `album`, cover, `durationMs`), `context`, `position` (Soloist's anchor `ms`, `atMs`, `speed`; `positionMs(state, now)` moves it on, not clamped), `volume`, `shuffle`, `repeat`, `upNext` (from `queue_changed`, plus one `get_queue` with `limit: 10` per socket; an unanswered one is quiet, as an empty queue may send nothing). An empty item `uri` is nothing loaded: no `track`. Unknown frame types are skipped. The library adds `aside` while its own `duck`, `interrupt` or stall repair runs, and `stalled`. Odd readings pass through into `anomaly`: an unknown status or repeat, a volume outside 0–100, a negative speed, a position past the track's end, a frame the schema rejects (which leaves the state as it was).
16
16
 
17
- **Writes**, on the integration: `play(linkOrUri?)`, `pause`, `resume`, `next`, `prev`, `seek(ms)`, `setVolume(0–100)`, `queue(track)`, `setShuffle(on)`, `setRepeat("off" | "context" | "track")`. A link is an `open.spotify.com` URL or a `spotify:` URI; `spotify.link` short links are refused.
17
+ **Writes**, on the integration: `activate()`, `play(linkOrUri?)`, `pause`, `resume`, `next`, `prev`, `seek(ms)`, `setVolume(0–100)`, `queue(track)`, `setShuffle(on)`, `setRepeat("off" | "context" | "track")`. A link is an `open.spotify.com` URL or a `spotify:` URI; `spotify.link` short links are refused.
18
18
 
19
19
  - Each resolves `{ state, confirmed }`: after Soloist accepts (`command_result`), it waits up to 2 s for the event that shows _this caller's_ change (the volume sent, `paused` or `playing`, a new track URI, the context or track played, a position near the seek, the shuffle or repeat set, the track in `upNext`), then gives the state. No such event: `confirmed: false`, the state as it stands. A write merged into a later one confirms only if its own change shows; a write of what is already so confirms at once; a drop ends the wait. `next` also confirms on idle (end of queue) or a restart, `prev` on a restart from 5 s or more in.
20
- - Errors are `ConnectorError` with `code`: `offline`, `waiting`, `refused` (Soloist's `error`), `timeout` (3 s), `invalid` (bad input, nothing sent).
20
+ - A volume reported within 1 of the request confirms, since Soloist rounds it (phone steps are sixteenths).
21
+ - **While inactive** (`state.active: false`), Soloist accepts `set_volume` and drops it, three times live. `setVolume` is refused unsent with code `inactive`; `duck` runs `fn` over the music with `music: "untouched"`. `activate()` fixes it, but makes a new audio stream, so the volume can jump to the level last saved (62 to 40, live). `play` while inactive goes active first (a `play` alone didn't take live; going active first is untested). `resume` doesn't: it worked inactive, and `interrupt` relies on that.
22
+ - Errors are `ConnectorError` with `code`: `offline`, `waiting`, `inactive`, `refused` (Soloist's `error`), `timeout` (3 s), `invalid` (bad input, nothing sent).
21
23
  - One write lane, 100 ms apart (a guess). `volume`, `seek`, `shuffle`, `repeat` merge to the last; `play`, `pause`, `resume` share a slot, so the last wins; each `next`, `prev`, `queue` is its own. Queries go through the lane too, at most 1 s each, since Soloist's `error` names no command.
22
- - `setRepeat` is two commands in the docs' order (clear the other mode, then set this one); if the second is refused, repeat may be left off.
24
+ - `setRepeat` is two commands in the docs' order (clear the other mode, then set this one), 600 ms apart: back to back, `off` from `track` stayed on `track` live, and 600 ms apart worked. If the second is refused, repeat may be left off.
23
25
  - A failed write the library made itself stays in `detail` until a write works or the socket reconnects.
24
26
 
25
- **`maxVolume`** holds requests (rounded to a whole percent). Within 10 s of a start from idle (to playing or buffering, or the first such status after a sign-in; a start through buffering opens the window again at `playing`), a reported volume above it is lowered once and `anomaly` says so until the speaker goes idle: [#23](https://github.com/spotify/soloist/issues/23), the first play on PipeWire starts at 100%. `--initial-volume` is ignored there. Not on resuming from pause, not on connecting to a song already playing, and a person turning it up again keeps it.
27
+ **`maxVolume`** holds requests (rounded to a whole percent). For 10 s after a start signal, a reported volume above it is lowered: a start from idle (to playing or buffering), the first such status after a sign-in, `playing` after a start through buffering, or the speaker going active. Event order differs between runs, so no one pattern is relied on. It waits until the speaker is active, retries until Soloist reports the lower volume, and only then says so in `anomaly`, until the speaker goes idle; if it never shows or Soloist refuses it, `detail` says so. A reported volume within 1 of the cap is left alone. Not on resuming from pause while active, not on connecting to a song already playing, and a person turning it up after the lowering keeps it (before Soloist has reported the lowering, a person's raise looks like the start's own jump and is lowered too). `--initial-volume` is ignored on PipeWire ([#23](https://github.com/spotify/soloist/issues/23)).
26
28
 
27
29
  **`duck(fn, { to })` and `interrupt(fn)`** turn the music down, or pause it, around `fn`, then put it back. Both resolve `{ value, music, why? }`, `music` one of:
28
30
 
@@ -33,7 +35,7 @@ Soloist is Spotify's own headless Spotify Connect client (Linux arm64, armv7, x8
33
35
 
34
36
  The restore runs even if `fn` throws: the error is `fn`'s, and a failed restore goes to `detail`. `duck` restores the earlier volume even above `maxVolume`. One aside at a time: never call one inside another's `fn` (it waits forever), and put two announcements in one `fn`. With Cast: `speaker.duck(() => cast.announce("Kitchen", clip), { to: 20 })`. Cast's own `announce` restores what a Cast device was playing; these are for Soloist's output.
35
37
 
36
- **Reading it.** `speakerState` takes the Part; `spotifyText` and `positionMs` take its `state`. `speakerState(part)`: `offline`, `connecting`, `waiting`, then Soloist's status (`idle`, `playing`, `paused`, `buffering`, or its own string); signed in with no status yet is `idle`. `spotifyChanges(before, after)`, for `onChange`, gives events in this order: `offline`, `online`, `waiting`, `picked`, `started` (from idle), `resumed` (from paused), `paused`, `stopped`, `track`, `stalled`, `recovered`. Buffering counts as playing. Nothing on the first report, so a restart announces nothing; no volume or queue events. Playback events during an aside carry `own: true`; connection, sign-in and stall events never do.
38
+ **Reading it.** `speakerState` takes the Part; `spotifyText` and `positionMs` take its `state`. `speakerState(part)`: `offline`, `connecting`, `waiting`, then Soloist's status (`idle`, `playing`, `paused`, `buffering`, or its own string); signed in with no status yet is `idle`. `spotifyChanges(before, after)`, for `onChange`, gives events in this order: `offline`, `online`, `waiting`, `picked`, `started` (from idle), `resumed` (from paused), `paused`, `stopped`, `track`, `stalled`, `recovered`. Buffering counts as playing. Nothing on the first report, so a restart announces nothing; no volume or queue events. Playback events during an aside carry `own: true`; connection, sign-in and stall events never do. `track` waits for the track's name, which Soloist can send a few seconds after the track (seen live); one that never gets a name never fires. One press on a phone can fire track, resumed, stopped, started and track over 3 s, sometimes with a 0.8 s `waiting`, and the order varies: anything announcing these should wait a few seconds. `state.volume` isn't the level that will play until `active` (it reads `--initial-volume`).
37
39
 
38
40
  **Stalls** ([#5](https://github.com/spotify/soloist/issues/5): playback stops mid-track, often the first after idle, while Soloist still says `playing`). While playing, one `get_state` every 20 s (`stallCheckMs`, code only; 0 turns it off).
39
41
 
@@ -52,7 +54,7 @@ JSON text frames on a plain WebSocket: no path, subprotocol, auth, TLS or origin
52
54
  - On connect: `auth_state`, then `playback_state` only if signed in. A later sign-in is promised only `auth_state`, so the connector asks `get_state`.
53
55
  - If `--ws` can't bind, Soloist runs without it and only logs a warning: `disconnected` while the daemon looks healthy. `--ws ADDR:0` writes the port to the data dir; this connector takes a fixed `url`.
54
56
 
55
- Not built: `activate`/`deactivate`, `available_actions`. An ad (`entity_type: "ad"`) shows as a `track`.
57
+ Not built: `deactivate`, `available_actions`. An ad (`entity_type: "ad"`) shows as a `track`.
56
58
 
57
59
  ## Unverified
58
60
 
@@ -60,11 +62,21 @@ Writes through this connector, and what a live read hasn't shown. In particular:
60
62
 
61
63
  - **Stalls:** whether the reported position freezes in one (if not, only an overrun is caught), whether `get_state` re-anchors the position, and whether a seek or a pause and play restarts it.
62
64
  - **Writes:** that each confirming event arrives within 2 s; which events a `play` of an album or playlist sends; `play` of a new URI with no phone around; seek on playback a phone started.
63
- - **The cap:** the order of statuses at a start. Soloist's docs list `buffering` but not when it comes; #23 reports the 100% about 100 ms after `playing`, on the first play after the process starts.
65
+ - **The cap in 0.1.2:** on a real start, and whether a real burst is audible before it lands.
66
+ - **`activate`:** whether `play(uri)` with no phone around works after it.
64
67
  - **The rest:** `get_queue` answered by `queue_changed`; `play` with nothing loaded; whole-number volumes; the order of `auth_state` and `playback_state`; the 100% first play and its lowering; Family-plan members picking the speaker; whether the session survives a Soloist restart; a USB speaker's standby dropping it mid-play.
65
68
 
66
69
  ## Live findings
67
70
 
71
+ **2026-11-09, Raspberry Pi 5, Bun 1.4.4, Soloist 1.3.10.51, PipeWire and WirePlumber 0.5.8, on 0.1.1** (chota-bot's session; writes with KO's OK).
72
+
73
+ - Confirmed: pause, resume, next, prev, seek, shuffle, repeat to context and track, queue, all under 900 ms. `interrupt` restored the music.
74
+ - `set_volume` while inactive: `command_result`, no `volume_changed`, every time. After `activate`, at once. The docs say `command_result` only means accepted.
75
+ - **Why the first volume is what it is:** WirePlumber saves each stream's level by role and restores it, and Soloist adopts it (100 and 56 in two runs, each the last level). Config can't force a fixed start: with `restore-props` off, the default volume is skipped too (`state-stream.lua`). So the cap is the way.
76
+ - A Music-role stream played at a set level (`pw-play --volume 0.38`) leaves that level saved for Spotify's next stream. `pw-play -P '{ state.restore-props = false }'` avoids it.
77
+ - A session survives a Soloist restart: same track, paused, signed in. `deactivate` while already inactive changed nothing visible.
78
+ - Setup that works: speaker knob and sink at 100, Spotify's level only through Soloist, other sounds at their own unsaved levels.
79
+
68
80
  **2026-11-09, Raspberry Pi 5, Bun 1.4.4, through this connector** (chota-bot's session, reads only). `home spotify` took 140 ms. In code: connected at 11 ms, second report at 55 ms, 51 MB RSS, a clean exit after abort. Track, volume and `paused` read correctly, with no anomaly. `spotifyChanges` gave nothing on the first report.
69
81
 
70
82
  **2026-11-08, Raspberry Pi 5, Pi OS Lite Trixie arm64, via `soloist ctl`** (chota-bot's session). `soloist 1.3.10.51 build 1793988108 (20261106)` as a systemd user unit with `-w 127.0.0.1:9091`. It logs the days left on the build (88), the WebSocket listening, then "waiting for login". Builds expire 90 days after they're made (exit code 10); chota-bot's units refresh weekly.
@@ -62,7 +62,7 @@ const PLAYING = new Set(["playing", "buffering"]);
62
62
  /**
63
63
  * The events between two reports, for `onChange`'s `before` and `after`: `offline` and `online`; `waiting` (signed out) and
64
64
  * `picked` (someone picked the speaker); `started` (from idle), `resumed` (from paused), `paused`, `stopped` (to idle), then
65
- * `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
65
+ * `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
66
66
  * 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.
67
67
  */
68
68
  export function spotifyChanges(
@@ -96,7 +96,9 @@ export function spotifyChanges(
96
96
  if (to === "paused" && from !== "paused") add({ type: "paused" });
97
97
  if (to === "idle" && from !== undefined && from !== "idle")
98
98
  add({ type: "stopped" });
99
- if (a?.track && a.track.uri !== b?.track?.uri)
99
+ // Soloist's first report of a new track can have no name yet (seen live): the event waits for it.
100
+ const named = (p?: SpotifyState) => !!p?.track?.name;
101
+ if (a?.track && named(a) && (a.track.uri !== b?.track?.uri || !named(b)))
100
102
  add({ type: "track", track: a.track });
101
103
  if (a?.stalled && !b?.stalled)
102
104
  events.push({ type: "stalled", stall: a.stalled });
@@ -185,7 +185,7 @@ export type SpotifyState = {
185
185
  /** The playlist, album or show playing from. */
186
186
  context?: { uri: string; type?: string; name?: string };
187
187
  position?: PositionAnchor;
188
- /** 0–100, as Soloist reports it. */
188
+ /** 0–100, as Soloist reports it, rounded. Until `active` it isn't the level that will play (it reads `--initial-volume`, seen live). */
189
189
  volume?: number;
190
190
  shuffle?: boolean;
191
191
  /** off, context or track. */