@khalido/home 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +60 -2
- package/README.md +12 -12
- package/dist/clock.js +2 -0
- package/dist/config.js +3 -1
- package/dist/connector.d.ts +9 -2
- package/dist/connectors/amber/index.js +1 -0
- package/dist/connectors/cast/index.d.ts +10 -5
- package/dist/connectors/cast/index.js +36 -4
- package/dist/connectors/cast/schemas.d.ts +2 -0
- package/dist/connectors/cast/schemas.js +1 -0
- package/dist/connectors/cast/state.d.ts +56 -0
- package/dist/connectors/cast/state.js +134 -0
- package/dist/connectors/cast/writes.d.ts +9 -2
- package/dist/connectors/cast/writes.js +67 -13
- package/dist/connectors/daikin/index.d.ts +6 -2
- package/dist/connectors/daikin/index.js +21 -1
- package/dist/connectors/enphase/index.d.ts +5 -1
- package/dist/connectors/enphase/index.js +34 -2
- package/dist/connectors/hue/index.d.ts +6 -2
- package/dist/connectors/hue/index.js +39 -6
- package/dist/connectors/printers/index.d.ts +6 -2
- package/dist/connectors/printers/index.js +23 -0
- 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 +9 -2
- package/dist/connectors/spotify/index.js +130 -45
- package/dist/connectors/up/index.d.ts +4 -2
- package/dist/connectors/up/index.js +3 -1
- package/dist/connectors/weather/bom.js +1 -0
- package/dist/connectors/weather/google.js +52 -9
- package/dist/connectors/weather/index.d.ts +2 -1
- package/dist/connectors/weather/index.js +7 -0
- package/dist/connectors/weather/source.d.ts +2 -0
- package/dist/devices.d.ts +27 -0
- package/dist/devices.js +61 -0
- package/dist/discover.d.ts +2 -0
- package/dist/discover.js +4 -1
- package/dist/health.d.ts +2 -1
- package/dist/health.js +2 -1
- package/dist/home.d.ts +41 -3
- package/dist/home.js +24 -7
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/lane.js +24 -20
- package/docs/cast.md +1 -1
- package/docs/cli.md +3 -3
- package/docs/config.md +8 -3
- package/docs/decisions.md +5 -0
- package/docs/using.md +17 -0
- package/package.json +1 -1
- package/skills/use-home/SKILL.md +7 -7
- package/src/cli/cast.ts +19 -11
- package/src/cli/enphase.ts +5 -1
- package/src/cli/spotify.ts +2 -1
- package/src/cli/status.ts +26 -3
- package/src/clock.ts +1 -0
- package/src/config.ts +3 -1
- package/src/connector.ts +18 -2
- package/src/connectors/amber/index.ts +1 -0
- package/src/connectors/cast/README.md +10 -5
- package/src/connectors/cast/index.ts +74 -9
- package/src/connectors/cast/schemas.ts +1 -0
- package/src/connectors/cast/state.ts +189 -0
- package/src/connectors/cast/writes.ts +87 -17
- package/src/connectors/daikin/README.md +4 -0
- package/src/connectors/daikin/index.ts +41 -2
- package/src/connectors/enphase/index.ts +49 -2
- package/src/connectors/hue/index.ts +57 -8
- package/src/connectors/printers/README.md +4 -2
- package/src/connectors/printers/index.ts +43 -1
- package/src/connectors/spotify/README.md +33 -9
- package/src/connectors/spotify/changes.ts +4 -2
- package/src/connectors/spotify/client.ts +1 -1
- package/src/connectors/spotify/index.ts +157 -49
- package/src/connectors/up/README.md +1 -1
- package/src/connectors/up/index.ts +6 -2
- package/src/connectors/weather/README.md +1 -1
- package/src/connectors/weather/bom.ts +1 -0
- package/src/connectors/weather/google.ts +66 -10
- package/src/connectors/weather/index.ts +16 -2
- package/src/connectors/weather/source.ts +2 -0
- package/src/devices.ts +102 -0
- package/src/discover.ts +6 -2
- package/src/health.ts +4 -2
- package/src/home.ts +75 -8
- package/src/index.ts +5 -1
- package/src/lane.ts +23 -20
|
@@ -8,17 +8,22 @@ import {
|
|
|
8
8
|
MultizoneStatus,
|
|
9
9
|
ReceiverStatus,
|
|
10
10
|
} from "./schemas.ts";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
type CastDevice,
|
|
13
|
+
castKey,
|
|
14
|
+
DEFAULT_MEDIA_RECEIVER,
|
|
15
|
+
findCast,
|
|
16
|
+
} from "./state.ts";
|
|
12
17
|
|
|
13
18
|
// Writes over the sockets `cast()` already holds: volume, mute, stop, and an announcement that plays a clip and puts
|
|
14
|
-
// the device back as it was. Never called by the integration itself;
|
|
19
|
+
// the device back as it was. Never called by the integration itself; announce, volume and mute have run on real devices (the README has the dates).
|
|
15
20
|
|
|
16
|
-
/** Google's own player for a URL of audio or video. */
|
|
17
|
-
export const DEFAULT_MEDIA_RECEIVER = "CC1AD845";
|
|
18
21
|
/** Each request (a status, a volume, LAUNCH, LOAD, STOP) gets this long to answer. */
|
|
19
22
|
export const ANSWER_MS = 10_000;
|
|
20
23
|
/** How long an announcement waits for its clip to finish, beyond the clip's own length when the device reports it. */
|
|
21
24
|
export const PLAY_MS = 60_000;
|
|
25
|
+
/** 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. */
|
|
26
|
+
export const START_MS = 15_000;
|
|
22
27
|
|
|
23
28
|
const RECEIVER = "receiver-0";
|
|
24
29
|
/** 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 +69,12 @@ export type CastActions = {
|
|
|
64
69
|
clip: CastClip,
|
|
65
70
|
options?: { volume?: number; timeoutMs?: number },
|
|
66
71
|
): Promise<AnnounceResult>;
|
|
72
|
+
/** `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. */
|
|
73
|
+
announceAll(
|
|
74
|
+
targets: string[],
|
|
75
|
+
clip: CastClip,
|
|
76
|
+
options?: { volume?: number; timeoutMs?: number },
|
|
77
|
+
): Promise<(AnnounceResult & { target: string })[]>;
|
|
67
78
|
};
|
|
68
79
|
|
|
69
80
|
/** A payload as it came, before any schema. */
|
|
@@ -115,10 +126,11 @@ function waitFor<T>(
|
|
|
115
126
|
clock: Clock,
|
|
116
127
|
s: CastSession,
|
|
117
128
|
ms: number | (() => number),
|
|
118
|
-
what: string,
|
|
129
|
+
what: string | (() => string),
|
|
119
130
|
pick: (raw: RawPayload) => T | undefined,
|
|
120
131
|
signal?: AbortSignal,
|
|
121
132
|
): Promise<T> {
|
|
133
|
+
const name = () => (typeof what === "string" ? what : what());
|
|
122
134
|
return new Promise<T>((resolve, reject) => {
|
|
123
135
|
const done = new AbortController();
|
|
124
136
|
const finish = (settle: () => void) =>
|
|
@@ -128,11 +140,11 @@ function waitFor<T>(
|
|
|
128
140
|
done.abort(),
|
|
129
141
|
settle());
|
|
130
142
|
const cancel = () =>
|
|
131
|
-
finish(() => reject(new Error(`stopped waiting for ${
|
|
143
|
+
finish(() => reject(new Error(`stopped waiting for ${name()}`)));
|
|
132
144
|
const off = s.listen((raw) => {
|
|
133
145
|
if (raw === undefined)
|
|
134
146
|
return finish(() =>
|
|
135
|
-
reject(new Error(`the socket closed waiting for ${
|
|
147
|
+
reject(new Error(`the socket closed waiting for ${name()}`)),
|
|
136
148
|
);
|
|
137
149
|
try {
|
|
138
150
|
const value = pick(raw);
|
|
@@ -152,7 +164,7 @@ function waitFor<T>(
|
|
|
152
164
|
)
|
|
153
165
|
await clock.sleep(left, done.signal);
|
|
154
166
|
finish(() =>
|
|
155
|
-
reject(new Error(`no ${
|
|
167
|
+
reject(new Error(`no ${name()} in ${Math.round(limit() / 1000)} s`)),
|
|
156
168
|
);
|
|
157
169
|
})();
|
|
158
170
|
});
|
|
@@ -191,9 +203,7 @@ export function castActions(ctx: WriteContext): CastActions {
|
|
|
191
203
|
|
|
192
204
|
const find = (target: string): CastDevice => {
|
|
193
205
|
const all = Object.values(ctx.devices());
|
|
194
|
-
const d =
|
|
195
|
-
all.find((x) => x.id === castKey(target)) ??
|
|
196
|
-
all.find((x) => x.name.toLowerCase() === target.toLowerCase());
|
|
206
|
+
const d = findCast(ctx.devices(), target);
|
|
197
207
|
if (!d)
|
|
198
208
|
throw new Error(
|
|
199
209
|
`no Cast device or group "${target}"; have: ${
|
|
@@ -318,18 +328,22 @@ export function castActions(ctx: WriteContext): CastActions {
|
|
|
318
328
|
const play = async (
|
|
319
329
|
s: CastSession,
|
|
320
330
|
transport: string,
|
|
321
|
-
media:
|
|
331
|
+
media: { contentId: string; contentType: string; streamType: string },
|
|
322
332
|
timeoutMs?: number,
|
|
323
333
|
): Promise<boolean> => {
|
|
324
334
|
const loadId = s.nextId();
|
|
325
335
|
let session: number | "any" | undefined;
|
|
326
336
|
let durationS = 0;
|
|
337
|
+
let started = false; // it has reached PLAYING: BUFFERING is where a clip it can't play sits
|
|
327
338
|
const cancel = new AbortController();
|
|
328
339
|
const finished = waitFor(
|
|
329
340
|
clock,
|
|
330
341
|
s,
|
|
331
|
-
() => timeoutMs ?? PLAY_MS + durationS * 1000,
|
|
332
|
-
|
|
342
|
+
() => timeoutMs ?? (started ? PLAY_MS + durationS * 1000 : START_MS),
|
|
343
|
+
() =>
|
|
344
|
+
started
|
|
345
|
+
? "end of the clip"
|
|
346
|
+
: `start of the clip (${media.contentType}, which this device may not play)`,
|
|
333
347
|
(raw) => {
|
|
334
348
|
const r = MediaStatus.safeParse(raw);
|
|
335
349
|
if (!r.success) return undefined;
|
|
@@ -345,9 +359,12 @@ export function castActions(ctx: WriteContext): CastActions {
|
|
|
345
359
|
continue;
|
|
346
360
|
if (typeof m.media?.duration === "number")
|
|
347
361
|
durationS = m.media.duration;
|
|
362
|
+
if (m.playerState === "PLAYING") started = true;
|
|
348
363
|
if (m.playerState !== "IDLE" || !m.idleReason) continue;
|
|
349
364
|
if (m.idleReason === "FINISHED") return true;
|
|
350
|
-
throw new Error(
|
|
365
|
+
throw new Error(
|
|
366
|
+
`the clip stopped: ${m.idleReason}${m.detailedErrorCode === undefined ? "" : ` (detailed error ${m.detailedErrorCode})`}${m.idleReason === "ERROR" ? `; this device may not play ${media.contentType}` : ""}`,
|
|
367
|
+
);
|
|
351
368
|
}
|
|
352
369
|
return undefined;
|
|
353
370
|
},
|
|
@@ -390,7 +407,7 @@ export function castActions(ctx: WriteContext): CastActions {
|
|
|
390
407
|
);
|
|
391
408
|
};
|
|
392
409
|
|
|
393
|
-
|
|
410
|
+
const actions: CastActions = {
|
|
394
411
|
async setVolume(target, level) {
|
|
395
412
|
checkLevel(level);
|
|
396
413
|
await setVolume(target, { level });
|
|
@@ -411,7 +428,7 @@ export function castActions(ctx: WriteContext): CastActions {
|
|
|
411
428
|
/**
|
|
412
429
|
* Read the target's state, set the volume if asked and unmute a muted target, launch the Default Media Receiver, play the clip to its end, then
|
|
413
430
|
* put back what it can, always: what the Default Media Receiver was playing is loaded again where it was, an idle
|
|
414
|
-
* device gets STOP, then the volume (a group's, member by member through each member's own socket). Another app
|
|
431
|
+
* device gets STOP, then the volume (a group's own first, then member by member through each member's own socket). Another app
|
|
415
432
|
* (Spotify, YouTube) is interrupted and cannot be resumed from here; the result says so. Throws only before anything changed.
|
|
416
433
|
*/
|
|
417
434
|
async announce(target, clip, options = {}) {
|
|
@@ -533,6 +550,26 @@ export function castActions(ctx: WriteContext): CastActions {
|
|
|
533
550
|
muted: before.volume?.muted,
|
|
534
551
|
},
|
|
535
552
|
];
|
|
553
|
+
// A group keeps a level of its own, which its members' levels don't bring back (live, 2026-11-10: it stayed at the
|
|
554
|
+
// announcement's 30 with every member restored). First, so whatever it does to the members, their own levels land last.
|
|
555
|
+
if (d.kind === "group" && options.volume !== undefined) {
|
|
556
|
+
const level = before.volume?.level;
|
|
557
|
+
if (level === undefined)
|
|
558
|
+
result.notRestored.push({
|
|
559
|
+
name: d.name,
|
|
560
|
+
why: "its volume before was not reported",
|
|
561
|
+
});
|
|
562
|
+
else
|
|
563
|
+
try {
|
|
564
|
+
await receiver(s, { type: "SET_VOLUME", volume: { level } });
|
|
565
|
+
result.restored.push(`${d.name}: volume`);
|
|
566
|
+
} catch (error) {
|
|
567
|
+
result.notRestored.push({
|
|
568
|
+
name: d.name,
|
|
569
|
+
why: `volume not restored: ${errorText(error)}`,
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
}
|
|
536
573
|
for (const m of each) {
|
|
537
574
|
const ms = m.id === d.id ? s : ctx.session(m.id);
|
|
538
575
|
if (m.level === undefined)
|
|
@@ -568,5 +605,38 @@ export function castActions(ctx: WriteContext): CastActions {
|
|
|
568
605
|
}
|
|
569
606
|
}
|
|
570
607
|
},
|
|
608
|
+
|
|
609
|
+
async announceAll(targets, clip, options = {}) {
|
|
610
|
+
// A caller's mistake is one error, not a failure per speaker.
|
|
611
|
+
if (options.volume !== undefined) checkLevel(options.volume);
|
|
612
|
+
if (!("url" in clip) && !ctx.serve)
|
|
613
|
+
throw new Error(
|
|
614
|
+
"a clip of bytes needs serve: serveClip from @khalido/home/cast/serve",
|
|
615
|
+
);
|
|
616
|
+
const id = (t: string) => {
|
|
617
|
+
try {
|
|
618
|
+
return find(t).id;
|
|
619
|
+
} catch {
|
|
620
|
+
return t; // unknown: announce says so, as this target's own failure
|
|
621
|
+
}
|
|
622
|
+
};
|
|
623
|
+
const once = targets.filter(
|
|
624
|
+
(t, i) => targets.findIndex((o) => id(o) === id(t)) === i,
|
|
625
|
+
); // one device twice would save the first's volume as the one to restore
|
|
626
|
+
return Promise.all(
|
|
627
|
+
once.map(async (target) => ({
|
|
628
|
+
target,
|
|
629
|
+
...(await actions
|
|
630
|
+
.announce(target, clip, options)
|
|
631
|
+
.catch((error): AnnounceResult => ({
|
|
632
|
+
played: false,
|
|
633
|
+
why: errorText(error),
|
|
634
|
+
restored: [],
|
|
635
|
+
notRestored: [],
|
|
636
|
+
}))),
|
|
637
|
+
})),
|
|
638
|
+
);
|
|
639
|
+
},
|
|
571
640
|
};
|
|
641
|
+
return actions;
|
|
572
642
|
}
|
|
@@ -63,6 +63,10 @@ All setpoint and fan fields, read from `e_3001` with `md` (setpoint is one byte,
|
|
|
63
63
|
|
|
64
64
|
Unmapped and unread: `p_27` (`0A00`, like auto), `p_34` (one signed byte, `00`, `mi FA`, `mx 00`), and the `p_05`–`p_08`, `p_20`–`p_25` fields (3- and 4-byte values, mostly `0F…`, likely schedules or limits). Meaning unknown.
|
|
65
65
|
|
|
66
|
+
## Read from a Pi (2026-11-10)
|
|
67
|
+
|
|
68
|
+
`home daikin` on 0.1.2 (Bun 1.4.4; chota-bot's session): "connected · off · dry · room 26 °C · outdoor unit 28 °C". `home discover` found the unit by UDP. No write was sent.
|
|
69
|
+
|
|
66
70
|
## Sources
|
|
67
71
|
|
|
68
72
|
Both Daikin protocol sources are GPL-3.0, so they are read for facts only (endpoints, field paths, value maps) and no code is copied; that keeps this package MIT.
|
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
import { asOf, type Clock, realClock } from "../../clock.ts";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import
|
|
3
|
+
import {
|
|
4
|
+
type DeviceRecord,
|
|
5
|
+
deviceRecords,
|
|
6
|
+
type Integration,
|
|
7
|
+
type Part,
|
|
8
|
+
} from "../../home.ts";
|
|
4
9
|
import { type Finder, lostDetail, relocator } from "../../discover.ts";
|
|
5
10
|
import type { Manifest } from "../../manifest.ts";
|
|
6
11
|
import { poller } from "../../poll.ts";
|
|
7
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
daikinClient,
|
|
14
|
+
DaikinError,
|
|
15
|
+
type DaikinState,
|
|
16
|
+
type Mode,
|
|
17
|
+
} from "./client.ts";
|
|
8
18
|
|
|
9
19
|
export {
|
|
10
20
|
DaikinError,
|
|
@@ -143,3 +153,32 @@ export const manifest: Manifest<DaikinSnapshot> = {
|
|
|
143
153
|
],
|
|
144
154
|
lines: ({ state }) => (state ? [acText(state)] : []),
|
|
145
155
|
};
|
|
156
|
+
|
|
157
|
+
/** The AC as a home device: `state` is `off`, its mode while on, or `on` when it hasn't said which; `detail` its snapshot. */
|
|
158
|
+
export type DaikinHomeDevice = DeviceRecord<
|
|
159
|
+
"daikin",
|
|
160
|
+
"ac",
|
|
161
|
+
"off" | "on" | Mode,
|
|
162
|
+
DaikinSnapshot
|
|
163
|
+
>;
|
|
164
|
+
|
|
165
|
+
/** The one unit, once it has been read. It reports no name or id of its own: a home config's entry names and places it. */
|
|
166
|
+
export function daikinHomeDevices(
|
|
167
|
+
part: Part<DaikinSnapshot> | undefined,
|
|
168
|
+
): DaikinHomeDevice[] {
|
|
169
|
+
const s = part?.state;
|
|
170
|
+
if (!s) return [];
|
|
171
|
+
return deviceRecords<DaikinHomeDevice>(part, [
|
|
172
|
+
{
|
|
173
|
+
connector: "daikin",
|
|
174
|
+
id: "daikin",
|
|
175
|
+
name: manifest.title,
|
|
176
|
+
kind: "ac",
|
|
177
|
+
available: true,
|
|
178
|
+
...(s.on === undefined ? {} : { state: s.on ? (s.mode ?? "on") : "off" }),
|
|
179
|
+
asOf: s.asOf,
|
|
180
|
+
text: `${manifest.title}: ${acText(s)}`,
|
|
181
|
+
detail: s,
|
|
182
|
+
},
|
|
183
|
+
]);
|
|
184
|
+
}
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { type Clock, localDay, realClock } from "../../clock.ts";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
type DeviceRecord,
|
|
5
|
+
deviceRecords,
|
|
6
|
+
filePath,
|
|
7
|
+
type Integration,
|
|
8
|
+
type Part,
|
|
9
|
+
} from "../../home.ts";
|
|
4
10
|
import { type Finder, lostDetail, relocator } from "../../discover.ts";
|
|
5
11
|
import { kw, type Manifest } from "../../manifest.ts";
|
|
6
12
|
import { poller } from "../../poll.ts";
|
|
@@ -102,7 +108,16 @@ export function enphase(
|
|
|
102
108
|
name: "enphase",
|
|
103
109
|
refresh: () => (where.reset(), poll.nudge()),
|
|
104
110
|
async run(signal, update) {
|
|
105
|
-
|
|
111
|
+
let expiresAt: Date;
|
|
112
|
+
try {
|
|
113
|
+
expiresAt = tokenExpiry(config.token); // a bad token fails this integration only
|
|
114
|
+
} catch {
|
|
115
|
+
return update({
|
|
116
|
+
status: "unavailable",
|
|
117
|
+
reason: "auth",
|
|
118
|
+
detail: "the token is unreadable; run `home enphase login`",
|
|
119
|
+
});
|
|
120
|
+
}
|
|
106
121
|
let last: EnphaseSnapshot | undefined;
|
|
107
122
|
let day:
|
|
108
123
|
| {
|
|
@@ -168,6 +183,7 @@ export function enphase(
|
|
|
168
183
|
if (error instanceof EnphaseError && error.status === 401)
|
|
169
184
|
return update({
|
|
170
185
|
status: "unavailable",
|
|
186
|
+
reason: "auth",
|
|
171
187
|
detail:
|
|
172
188
|
"the gateway rejected the token; run `home enphase login`",
|
|
173
189
|
state: last,
|
|
@@ -239,3 +255,34 @@ export const manifest: Manifest<EnphaseSnapshot> = {
|
|
|
239
255
|
files: ["token"],
|
|
240
256
|
lines: ({ state }) => (state ? [enphaseText(state)] : []),
|
|
241
257
|
};
|
|
258
|
+
|
|
259
|
+
/** The gateway as a home device: `state` is `producing` while the solar makes power, else `idle`; `detail` its snapshot. */
|
|
260
|
+
export type EnphaseHomeDevice = DeviceRecord<
|
|
261
|
+
"enphase",
|
|
262
|
+
"solar",
|
|
263
|
+
"producing" | "idle",
|
|
264
|
+
EnphaseSnapshot
|
|
265
|
+
>;
|
|
266
|
+
|
|
267
|
+
/** The one gateway, once it has been read. */
|
|
268
|
+
export function enphaseHomeDevices(
|
|
269
|
+
part: Part<EnphaseSnapshot> | undefined,
|
|
270
|
+
): EnphaseHomeDevice[] {
|
|
271
|
+
const s = part?.state;
|
|
272
|
+
if (!s) return [];
|
|
273
|
+
return deviceRecords<EnphaseHomeDevice>(part, [
|
|
274
|
+
{
|
|
275
|
+
connector: "enphase",
|
|
276
|
+
id: "enphase",
|
|
277
|
+
name: manifest.title,
|
|
278
|
+
kind: "solar",
|
|
279
|
+
available: true,
|
|
280
|
+
...(s.productionW === undefined
|
|
281
|
+
? {}
|
|
282
|
+
: { state: s.productionW > 0 ? "producing" : "idle" }),
|
|
283
|
+
asOf: s.asOf,
|
|
284
|
+
text: `${manifest.title}: ${enphaseText(s)}`,
|
|
285
|
+
detail: s,
|
|
286
|
+
},
|
|
287
|
+
]);
|
|
288
|
+
}
|
|
@@ -5,6 +5,8 @@ import { type Finder, lostDetail, relocator } from "../../discover.ts";
|
|
|
5
5
|
import {
|
|
6
6
|
envName,
|
|
7
7
|
filePath,
|
|
8
|
+
type DeviceRecord,
|
|
9
|
+
deviceRecords,
|
|
8
10
|
type Integration,
|
|
9
11
|
type Part,
|
|
10
12
|
type Status,
|
|
@@ -14,8 +16,10 @@ import { bunPinnedFetch, type HueClient, hueClient } from "./client.ts";
|
|
|
14
16
|
import { hueEvents, isRejectedKey } from "./events.ts";
|
|
15
17
|
import type { HueEvent } from "./schemas.ts";
|
|
16
18
|
import {
|
|
19
|
+
type GroupView,
|
|
17
20
|
groups,
|
|
18
21
|
type HueSnapshot,
|
|
22
|
+
type LightView,
|
|
19
23
|
lights,
|
|
20
24
|
reduce,
|
|
21
25
|
snapshotFrom,
|
|
@@ -189,6 +193,7 @@ export function hue(
|
|
|
189
193
|
if (isRejectedKey(error))
|
|
190
194
|
return update({
|
|
191
195
|
status: "unavailable",
|
|
196
|
+
reason: "auth",
|
|
192
197
|
detail: "the bridge rejected the key; pair again",
|
|
193
198
|
state: snapshot,
|
|
194
199
|
});
|
|
@@ -215,6 +220,56 @@ const view = (s: HueSnapshot) => ({
|
|
|
215
220
|
})),
|
|
216
221
|
});
|
|
217
222
|
|
|
223
|
+
/** "Kitchen: on · 40%", "Kitchen: off", or the name alone when the bridge hasn't said. */
|
|
224
|
+
const onText = (v: {
|
|
225
|
+
name?: string;
|
|
226
|
+
id: string;
|
|
227
|
+
on?: boolean;
|
|
228
|
+
brightness?: number;
|
|
229
|
+
}) =>
|
|
230
|
+
`${v.name ?? v.id}${v.on === undefined ? "" : `: ${v.on ? `on${v.brightness === undefined ? "" : ` · ${Math.round(v.brightness)}%`}` : "off"}`}`;
|
|
231
|
+
|
|
232
|
+
/** A Hue light, or a room or zone, as a home device: `detail` is its `LightView` or `GroupView`. */
|
|
233
|
+
export type HueHomeDevice =
|
|
234
|
+
| DeviceRecord<"hue", "light", "on" | "off", LightView>
|
|
235
|
+
| DeviceRecord<"hue", "group", "on" | "off", GroupView>;
|
|
236
|
+
|
|
237
|
+
/** Every light, then every room and zone. A light's `available` is its Zigbee link; a group's `members` are its lights. */
|
|
238
|
+
export function hueHomeDevices(
|
|
239
|
+
part: Part<HueSnapshot> | undefined,
|
|
240
|
+
): HueHomeDevice[] {
|
|
241
|
+
const s = part?.state;
|
|
242
|
+
if (!s) return [];
|
|
243
|
+
const on = (v: { on?: boolean }) =>
|
|
244
|
+
v.on === undefined
|
|
245
|
+
? {}
|
|
246
|
+
: { state: v.on ? ("on" as const) : ("off" as const) };
|
|
247
|
+
return deviceRecords<HueHomeDevice>(part, [
|
|
248
|
+
...lights(s).map((l): HueHomeDevice => ({
|
|
249
|
+
connector: "hue",
|
|
250
|
+
id: l.id,
|
|
251
|
+
name: l.name ?? l.id,
|
|
252
|
+
kind: "light",
|
|
253
|
+
...(l.reachable === undefined ? {} : { available: l.reachable }),
|
|
254
|
+
...on(l),
|
|
255
|
+
asOf: s.asOf,
|
|
256
|
+
text: onText(l),
|
|
257
|
+
detail: l,
|
|
258
|
+
})),
|
|
259
|
+
...groups(s).map((g): HueHomeDevice => ({
|
|
260
|
+
connector: "hue",
|
|
261
|
+
id: g.id,
|
|
262
|
+
name: g.name ?? g.id,
|
|
263
|
+
kind: "group",
|
|
264
|
+
...on(g),
|
|
265
|
+
asOf: s.asOf,
|
|
266
|
+
text: onText(g),
|
|
267
|
+
members: g.lightIds,
|
|
268
|
+
detail: g,
|
|
269
|
+
})),
|
|
270
|
+
]);
|
|
271
|
+
}
|
|
272
|
+
|
|
218
273
|
export const manifest: Manifest<HueSnapshot> = {
|
|
219
274
|
name: "hue",
|
|
220
275
|
title: "Philips Hue",
|
|
@@ -248,14 +303,8 @@ export const manifest: Manifest<HueSnapshot> = {
|
|
|
248
303
|
/** "3 lights on", then "Kitchen: on · 40%" per room and zone. */
|
|
249
304
|
lines({ state }) {
|
|
250
305
|
if (!state) return [];
|
|
251
|
-
const { lightsOn
|
|
252
|
-
return [
|
|
253
|
-
`${lightsOn} lights on`,
|
|
254
|
-
...rooms.map(
|
|
255
|
-
(r) =>
|
|
256
|
-
`${r.name}: ${r.on ? `on${r.brightness === undefined ? "" : ` · ${Math.round(r.brightness)}%`}` : "off"}`,
|
|
257
|
-
),
|
|
258
|
-
];
|
|
306
|
+
const { lightsOn } = view(state);
|
|
307
|
+
return [`${lightsOn} lights on`, ...groups(state).map(onText)];
|
|
259
308
|
},
|
|
260
309
|
record: ({ state }) => (state ? view(state) : {}),
|
|
261
310
|
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Printers
|
|
2
2
|
|
|
3
|
-
AirPrint printers, found by mDNS and read over IPP on the local network. `iot_class`: **local_polling** (one Get-Printer-Attributes per printer every 120 s). Built read-only;
|
|
3
|
+
AirPrint printers, found by mDNS and read over IPP on the local network. `iot_class`: **local_polling** (one Get-Printer-Attributes per printer every 120 s). Built read-only; first read of a live printer 2026-11-10 (see Live findings).
|
|
4
4
|
|
|
5
5
|
Try it: `home printers`
|
|
6
6
|
|
|
@@ -34,7 +34,9 @@ Everything, until a real printer answers: the request bytes (the codec is checke
|
|
|
34
34
|
|
|
35
35
|
## Live findings
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
**2026-11-10, from a Pi (Bun 1.4.4, 0.1.2; chota-bot's session).** `home discover` found the Brother by mDNS and `home printers` read it: "Brother HL-L2350DW series: idle · BK 80% · A4 ready". So discovery, the request, the default path, a real `marker-levels` and `media-ready` work on this model. Still unverified from the list above: `printer-uuid` against TXT `UUID`, a sleeping printer waking, `printer-alert`, and the macOS local-network prompt.
|
|
38
|
+
|
|
39
|
+
2026-10-14 (Bun 1.4.3): `home discover` (two passes) and `home printers` found no `_ipp._tcp` or `_ipps._tcp` instance on the network, so the Brother was not advertising (off, asleep, or on another network).
|
|
38
40
|
|
|
39
41
|
## Sources
|
|
40
42
|
|
|
@@ -11,7 +11,13 @@ import {
|
|
|
11
11
|
lostDetail,
|
|
12
12
|
rescanDelay,
|
|
13
13
|
} from "../../discover.ts";
|
|
14
|
-
import
|
|
14
|
+
import {
|
|
15
|
+
type DeviceRecord,
|
|
16
|
+
deviceRecords,
|
|
17
|
+
type Integration,
|
|
18
|
+
type Part,
|
|
19
|
+
type Status,
|
|
20
|
+
} from "../../home.ts";
|
|
15
21
|
import type { Manifest } from "../../manifest.ts";
|
|
16
22
|
import { poller } from "../../poll.ts";
|
|
17
23
|
import { getPrinterAttributes, printerUrl } from "./client.ts";
|
|
@@ -19,6 +25,7 @@ import { attributesOf } from "./codec.ts";
|
|
|
19
25
|
import {
|
|
20
26
|
type Printer,
|
|
21
27
|
printerKey,
|
|
28
|
+
type PrinterStatus,
|
|
22
29
|
printerText,
|
|
23
30
|
type PrintersState,
|
|
24
31
|
type Reading,
|
|
@@ -383,3 +390,38 @@ export const manifest: Manifest<PrintersState> = {
|
|
|
383
390
|
];
|
|
384
391
|
},
|
|
385
392
|
};
|
|
393
|
+
|
|
394
|
+
/** A printer as a home device: `state` is the printer's own (`idle`, `printing`, `stopped`, `offline`); `detail` its `Printer`. */
|
|
395
|
+
export type PrinterHomeDevice = DeviceRecord<
|
|
396
|
+
"printers",
|
|
397
|
+
"printer",
|
|
398
|
+
PrinterStatus,
|
|
399
|
+
Printer
|
|
400
|
+
>;
|
|
401
|
+
|
|
402
|
+
/** Every printer, by name. One that has stopped answering is not `available`, and keeps its last values. */
|
|
403
|
+
export function printersHomeDevices(
|
|
404
|
+
part: Part<PrintersState> | undefined,
|
|
405
|
+
): PrinterHomeDevice[] {
|
|
406
|
+
if (!part?.state) return [];
|
|
407
|
+
return deviceRecords(
|
|
408
|
+
part,
|
|
409
|
+
printerList(part.state).map((p): PrinterHomeDevice => ({
|
|
410
|
+
connector: "printers",
|
|
411
|
+
id: p.uuid,
|
|
412
|
+
name: p.name,
|
|
413
|
+
kind: "printer",
|
|
414
|
+
...(p.state === undefined ? {} : { available: p.state !== "offline" }),
|
|
415
|
+
...(p.state === undefined ? {} : { state: p.state }),
|
|
416
|
+
// An offline printer's values are from when it last answered, not from this check.
|
|
417
|
+
...(p.state === "offline"
|
|
418
|
+
? p.lastSeen === undefined
|
|
419
|
+
? {}
|
|
420
|
+
: { asOf: p.lastSeen }
|
|
421
|
+
: { asOf: p.asOf }),
|
|
422
|
+
text: printerText(p),
|
|
423
|
+
...(p.room ? { room: p.room } : {}),
|
|
424
|
+
detail: p,
|
|
425
|
+
})),
|
|
426
|
+
);
|
|
427
|
+
}
|
|
@@ -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);
|
|
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,26 +14,28 @@ 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` and `interrupt` run `fn` over the music with `music: "untouched"`: "playing" while inactive is the account playing on another speaker, which a pause would stop. `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
|
|
|
29
|
-
- `untouched`: not playing or buffering, not connected, (duck) the volume unknown or already at `to` or below, or the lowering or pause failed, so `fn` ran over it.
|
|
31
|
+
- `untouched`: not playing or buffering, not connected, not the active device, (duck) the volume unknown or already at `to` or below, or the lowering or pause failed, so `fn` ran over it.
|
|
30
32
|
- `restored`: put back as it was.
|
|
31
33
|
- `kept`: someone changed it meanwhile (resumed, skipped, set the volume), or the pause or lower volume never showed. Theirs stays.
|
|
32
34
|
- `failed`: the restore failed or was never reported; it may be left quiet or paused.
|
|
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: `
|
|
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,33 @@ 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
|
|
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-10, Raspberry Pi 5, Bun 1.4.4, on 0.1.2, through this connector** (chota-bot's session, KO listening).
|
|
72
|
+
|
|
73
|
+
- Confirmed with `maxVolume: 60`, the speaker active and playing: `activate`, `setVolume(50)`, `setVolume(90)` (came back 60), `seek`, `setShuffle`, `setRepeat("context")` and `("off")` (about 2.1 s each), `queue` (first in `upNext`), `play` of a playlist URI. `duck(fn, { to: 20 })` around a Cast announce: 20 inside, `music: "restored"`, 60 after, 5.2 s in all.
|
|
74
|
+
- **The level can jump above the cap on a pause, not only on a start.** One-shot CLI, cap 60: `volume 45` read 45, `play` 45, then `pause` read 62 and the next `play` 62 (62 was the level before the test). Nothing lowered it: a pause is not a start signal, and each CLI command is a new process.
|
|
75
|
+
- **That jump did not repeat, and it is not WirePlumber.** A second pass the same day, the speaker already active: 60, `volume 45`, `pause`, `play`, `volume 60`. Soloist's number and the stream's live level (`wpctl get-volume`) matched at every step (0.60, 0.45, 0.45, 0.45, 0.60), WirePlumber 0.5.8 never wrote a saved entry for the stream in `stream-properties`, and the stream kept its id across pause and play. The run that jumped had just been made active with `activate`, minutes after the account was casting from a phone, which points at the account side. One case each way.
|
|
76
|
+
- **`active: false` with `status: "playing"`:** the account playing on another speaker. `speakerState` says `playing`, `spotifyText` prints it as this speaker's, and `spotifyChanges` fired `resumed`, with the box silent.
|
|
77
|
+
- Through chota-bot's wrapper, later the same day: `play` of a playlist confirmed in 241 ms, `next` in 134 ms, `pause` in 17 ms; `duck` to 15 and `interrupt`, 2.5 s each, both `restored`. `play()` with the music paused on a Cast speaker: confirmed in 1.3 s, playing on the box at the same position.
|
|
78
|
+
- **`interrupt` paused music that was on another speaker** (found in chota's switch): it checked `playing` and not `active`. Fixed after 0.1.2.
|
|
79
|
+
- `positionMs` around a duck and an interrupt read 2 s, 2 s, 3 s, 5 s, then 4 s after a pause: it can step back a second.
|
|
80
|
+
- A consumer's test with a fake socket: a write goes out behind the connector's own queries (`get_state`, `get_queue`), up to about 1 s on a fresh socket, so "the command was sent" can't be checked straight after the call.
|
|
81
|
+
- Not run: the first-play cap from idle (the box never went active), stall events.
|
|
82
|
+
|
|
83
|
+
**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).
|
|
84
|
+
|
|
85
|
+
- Confirmed: pause, resume, next, prev, seek, shuffle, repeat to context and track, queue, all under 900 ms. `interrupt` restored the music.
|
|
86
|
+
- `set_volume` while inactive: `command_result`, no `volume_changed`, every time. After `activate`, at once. The docs say `command_result` only means accepted.
|
|
87
|
+
- **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.
|
|
88
|
+
- 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.
|
|
89
|
+
- A session survives a Soloist restart: same track, paused, signed in. `deactivate` while already inactive changed nothing visible.
|
|
90
|
+
- Setup that works: speaker knob and sink at 100, Spotify's level only through Soloist, other sounds at their own unsaved levels.
|
|
91
|
+
|
|
68
92
|
**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
93
|
|
|
70
94
|
**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,
|
|
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 });
|