@khalido/home 0.1.0 → 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.
- package/CHANGELOG.md +50 -1
- package/README.md +11 -11
- package/dist/config.js +3 -1
- package/dist/connectors/cast/index.d.ts +4 -1
- package/dist/connectors/cast/index.js +10 -2
- package/dist/connectors/cast/schemas.d.ts +2 -0
- package/dist/connectors/cast/schemas.js +1 -0
- package/dist/connectors/cast/serve.d.ts +8 -1
- package/dist/connectors/cast/state.d.ts +4 -0
- package/dist/connectors/cast/state.js +12 -0
- package/dist/connectors/cast/tls.js +1 -1
- package/dist/connectors/cast/writes.d.ts +9 -0
- package/dist/connectors/cast/writes.js +42 -6
- package/dist/connectors/spotify/changes.d.ts +1 -1
- package/dist/connectors/spotify/changes.js +4 -2
- package/dist/connectors/spotify/client.d.ts +1 -1
- package/dist/connectors/spotify/index.d.ts +3 -1
- package/dist/connectors/spotify/index.js +114 -45
- package/dist/connectors/up/index.d.ts +4 -2
- package/dist/connectors/up/index.js +2 -1
- package/dist/connectors/weather/index.js +16 -3
- package/dist/discover.d.ts +5 -1
- package/dist/discover.js +12 -3
- package/dist/energy.js +1 -1
- package/dist/finders.d.ts +3 -0
- package/dist/finders.js +6 -3
- package/dist/lane.js +24 -20
- package/docs/cast.md +1 -1
- package/docs/cli.md +5 -3
- package/docs/config.md +2 -2
- package/docs/decisions.md +1 -0
- package/docs/google.md +1 -1
- package/package.json +2 -2
- package/skills/draw-home/README.md +5 -6
- package/skills/draw-home/SKILL.md +3 -5
- package/src/cli/cast.ts +19 -11
- package/src/cli/discover.ts +8 -1
- package/src/cli/spotify.ts +2 -1
- package/src/config.ts +3 -1
- package/src/connectors/cast/README.md +7 -5
- package/src/connectors/cast/index.ts +19 -5
- package/src/connectors/cast/schemas.ts +1 -0
- package/src/connectors/cast/serve.ts +11 -1
- package/src/connectors/cast/state.ts +12 -0
- package/src/connectors/cast/tls.ts +1 -1
- package/src/connectors/cast/writes.ts +58 -9
- package/src/connectors/daikin/README.md +1 -1
- package/src/connectors/spotify/README.md +23 -8
- package/src/connectors/spotify/changes.ts +4 -2
- package/src/connectors/spotify/client.ts +1 -1
- package/src/connectors/spotify/index.ts +120 -48
- package/src/connectors/up/README.md +1 -1
- package/src/connectors/up/index.ts +5 -2
- package/src/connectors/weather/README.md +1 -1
- package/src/connectors/weather/index.ts +19 -3
- package/src/discover.ts +14 -6
- package/src/energy.ts +1 -1
- package/src/finders.ts +8 -5
- package/src/lane.ts +23 -20
|
@@ -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 ${
|
|
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 ${
|
|
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 ${
|
|
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:
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
}
|
|
@@ -4,7 +4,7 @@ Reads one Daikin AC over its local JSON `dsiot` API. `iot_class`: **local_pollin
|
|
|
4
4
|
|
|
5
5
|
- `client.ts`: `daikinClient({ host, fetch }).read()` returns `DaikinState` (power, mode, the selected mode's setpoint and fan speed, room °C, humidity, outside °C, `anomaly`). `setPower`, `setMode`, `setTarget`, `setFan` build write requests; `client.write(request)` sends one, through a write lane: 1 s apart and at most 20 a minute (guesses), a waiting write merging the next one field by field (`mergeWrites`). Every request times out at 10 s.
|
|
6
6
|
- `index.ts`: `daikin({ host, fetch, clock })` reads now, then every **120 s** (Home Assistant's `update_interval`). A failed poll is `disconnected` and keeps the last reading with `asOf`; a 404 on `/dsiot/multireq` is a failed read too, saying "not a dsiot Daikin": the address may now be another device's, so discovery looks for the unit. There is no key, so nothing is terminal. Writes go through `daikin().client.write(…)`: one client for the integration's life, at the unit's current address.
|
|
7
|
-
- `home status` reads `DAIKIN_HOST`,
|
|
7
|
+
- `home status` reads `DAIKIN_HOST`, or a home config's address, with discovery by `mac` if the unit moves. There is no CLI command that writes.
|
|
8
8
|
|
|
9
9
|
## The protocol
|
|
10
10
|
|
|
@@ -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**.
|
|
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
|
-
-
|
|
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)
|
|
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).
|
|
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(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,18 +54,31 @@ 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: `
|
|
57
|
+
Not built: `deactivate`, `available_actions`. An ad (`entity_type: "ad"`) shows as a `track`.
|
|
56
58
|
|
|
57
59
|
## Unverified
|
|
58
60
|
|
|
59
|
-
|
|
61
|
+
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.
|
|
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.
|
|
63
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.
|
|
64
68
|
|
|
65
69
|
## Live findings
|
|
66
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
|
+
|
|
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.
|
|
81
|
+
|
|
67
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.
|
|
68
83
|
|
|
69
84
|
- Not logged in until a phone picked the speaker: the normal waiting state. After: logged in, active device.
|
|
@@ -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,
|
|
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
|
-
|
|
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. */
|