@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.
Files changed (88) hide show
  1. package/CHANGELOG.md +60 -2
  2. package/README.md +12 -12
  3. package/dist/clock.js +2 -0
  4. package/dist/config.js +3 -1
  5. package/dist/connector.d.ts +9 -2
  6. package/dist/connectors/amber/index.js +1 -0
  7. package/dist/connectors/cast/index.d.ts +10 -5
  8. package/dist/connectors/cast/index.js +36 -4
  9. package/dist/connectors/cast/schemas.d.ts +2 -0
  10. package/dist/connectors/cast/schemas.js +1 -0
  11. package/dist/connectors/cast/state.d.ts +56 -0
  12. package/dist/connectors/cast/state.js +134 -0
  13. package/dist/connectors/cast/writes.d.ts +9 -2
  14. package/dist/connectors/cast/writes.js +67 -13
  15. package/dist/connectors/daikin/index.d.ts +6 -2
  16. package/dist/connectors/daikin/index.js +21 -1
  17. package/dist/connectors/enphase/index.d.ts +5 -1
  18. package/dist/connectors/enphase/index.js +34 -2
  19. package/dist/connectors/hue/index.d.ts +6 -2
  20. package/dist/connectors/hue/index.js +39 -6
  21. package/dist/connectors/printers/index.d.ts +6 -2
  22. package/dist/connectors/printers/index.js +23 -0
  23. package/dist/connectors/spotify/changes.d.ts +1 -1
  24. package/dist/connectors/spotify/changes.js +4 -2
  25. package/dist/connectors/spotify/client.d.ts +1 -1
  26. package/dist/connectors/spotify/index.d.ts +9 -2
  27. package/dist/connectors/spotify/index.js +130 -45
  28. package/dist/connectors/up/index.d.ts +4 -2
  29. package/dist/connectors/up/index.js +3 -1
  30. package/dist/connectors/weather/bom.js +1 -0
  31. package/dist/connectors/weather/google.js +52 -9
  32. package/dist/connectors/weather/index.d.ts +2 -1
  33. package/dist/connectors/weather/index.js +7 -0
  34. package/dist/connectors/weather/source.d.ts +2 -0
  35. package/dist/devices.d.ts +27 -0
  36. package/dist/devices.js +61 -0
  37. package/dist/discover.d.ts +2 -0
  38. package/dist/discover.js +4 -1
  39. package/dist/health.d.ts +2 -1
  40. package/dist/health.js +2 -1
  41. package/dist/home.d.ts +41 -3
  42. package/dist/home.js +24 -7
  43. package/dist/index.d.ts +3 -2
  44. package/dist/index.js +2 -1
  45. package/dist/lane.js +24 -20
  46. package/docs/cast.md +1 -1
  47. package/docs/cli.md +3 -3
  48. package/docs/config.md +8 -3
  49. package/docs/decisions.md +5 -0
  50. package/docs/using.md +17 -0
  51. package/package.json +1 -1
  52. package/skills/use-home/SKILL.md +7 -7
  53. package/src/cli/cast.ts +19 -11
  54. package/src/cli/enphase.ts +5 -1
  55. package/src/cli/spotify.ts +2 -1
  56. package/src/cli/status.ts +26 -3
  57. package/src/clock.ts +1 -0
  58. package/src/config.ts +3 -1
  59. package/src/connector.ts +18 -2
  60. package/src/connectors/amber/index.ts +1 -0
  61. package/src/connectors/cast/README.md +10 -5
  62. package/src/connectors/cast/index.ts +74 -9
  63. package/src/connectors/cast/schemas.ts +1 -0
  64. package/src/connectors/cast/state.ts +189 -0
  65. package/src/connectors/cast/writes.ts +87 -17
  66. package/src/connectors/daikin/README.md +4 -0
  67. package/src/connectors/daikin/index.ts +41 -2
  68. package/src/connectors/enphase/index.ts +49 -2
  69. package/src/connectors/hue/index.ts +57 -8
  70. package/src/connectors/printers/README.md +4 -2
  71. package/src/connectors/printers/index.ts +43 -1
  72. package/src/connectors/spotify/README.md +33 -9
  73. package/src/connectors/spotify/changes.ts +4 -2
  74. package/src/connectors/spotify/client.ts +1 -1
  75. package/src/connectors/spotify/index.ts +157 -49
  76. package/src/connectors/up/README.md +1 -1
  77. package/src/connectors/up/index.ts +6 -2
  78. package/src/connectors/weather/README.md +1 -1
  79. package/src/connectors/weather/bom.ts +1 -0
  80. package/src/connectors/weather/google.ts +66 -10
  81. package/src/connectors/weather/index.ts +16 -2
  82. package/src/connectors/weather/source.ts +2 -0
  83. package/src/devices.ts +102 -0
  84. package/src/discover.ts +6 -2
  85. package/src/health.ts +4 -2
  86. package/src/home.ts +75 -8
  87. package/src/index.ts +5 -1
  88. package/src/lane.ts +23 -20
@@ -8,17 +8,22 @@ import {
8
8
  MultizoneStatus,
9
9
  ReceiverStatus,
10
10
  } from "./schemas.ts";
11
- import { type CastDevice, castKey } from "./state.ts";
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; tried on a scripted fake socket, not yet live.
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 ${what}`)));
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 ${what}`)),
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 ${what} in ${Math.round(limit() / 1000)} s`)),
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: object,
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
- "end of the clip",
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(`the clip stopped: ${m.idleReason}`);
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
- return {
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 type { Integration } from "../../home.ts";
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 { daikinClient, DaikinError, type DaikinState } from "./client.ts";
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 { filePath, type Integration } from "../../home.ts";
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
- const expiresAt = tokenExpiry(config.token); // a bad token fails this integration only
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, rooms } = view(state);
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; **untried on a live printer** (the Brother KO has did not answer mDNS on 2026-10-14, see Live findings).
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
- None yet. 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).
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 type { Integration, Part, Status } from "../../home.ts";
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); 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,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
- - 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` 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); 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
 
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: `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,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:** 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-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, 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 });