@khalido/home 0.1.2 → 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 (68) hide show
  1. package/CHANGELOG.md +33 -2
  2. package/README.md +1 -1
  3. package/dist/clock.js +2 -0
  4. package/dist/connector.d.ts +9 -2
  5. package/dist/connectors/amber/index.js +1 -0
  6. package/dist/connectors/cast/index.d.ts +8 -6
  7. package/dist/connectors/cast/index.js +30 -4
  8. package/dist/connectors/cast/state.d.ts +52 -0
  9. package/dist/connectors/cast/state.js +122 -0
  10. package/dist/connectors/cast/writes.d.ts +0 -2
  11. package/dist/connectors/cast/writes.js +25 -7
  12. package/dist/connectors/daikin/index.d.ts +6 -2
  13. package/dist/connectors/daikin/index.js +21 -1
  14. package/dist/connectors/enphase/index.d.ts +5 -1
  15. package/dist/connectors/enphase/index.js +34 -2
  16. package/dist/connectors/hue/index.d.ts +6 -2
  17. package/dist/connectors/hue/index.js +39 -6
  18. package/dist/connectors/printers/index.d.ts +6 -2
  19. package/dist/connectors/printers/index.js +23 -0
  20. package/dist/connectors/spotify/index.d.ts +6 -1
  21. package/dist/connectors/spotify/index.js +26 -1
  22. package/dist/connectors/up/index.js +1 -0
  23. package/dist/connectors/weather/bom.js +1 -0
  24. package/dist/connectors/weather/google.js +52 -9
  25. package/dist/connectors/weather/index.d.ts +2 -1
  26. package/dist/connectors/weather/index.js +7 -0
  27. package/dist/connectors/weather/source.d.ts +2 -0
  28. package/dist/devices.d.ts +27 -0
  29. package/dist/devices.js +61 -0
  30. package/dist/health.d.ts +2 -1
  31. package/dist/health.js +2 -1
  32. package/dist/home.d.ts +41 -3
  33. package/dist/home.js +24 -7
  34. package/dist/index.d.ts +3 -2
  35. package/dist/index.js +2 -1
  36. package/docs/cli.md +1 -1
  37. package/docs/config.md +8 -3
  38. package/docs/decisions.md +4 -0
  39. package/docs/using.md +17 -0
  40. package/package.json +1 -1
  41. package/skills/use-home/SKILL.md +7 -7
  42. package/src/cli/enphase.ts +5 -1
  43. package/src/cli/status.ts +26 -3
  44. package/src/clock.ts +1 -0
  45. package/src/connector.ts +18 -2
  46. package/src/connectors/amber/index.ts +1 -0
  47. package/src/connectors/cast/README.md +7 -3
  48. package/src/connectors/cast/index.ts +59 -7
  49. package/src/connectors/cast/state.ts +177 -0
  50. package/src/connectors/cast/writes.ts +29 -8
  51. package/src/connectors/daikin/README.md +4 -0
  52. package/src/connectors/daikin/index.ts +41 -2
  53. package/src/connectors/enphase/index.ts +49 -2
  54. package/src/connectors/hue/index.ts +57 -8
  55. package/src/connectors/printers/README.md +4 -2
  56. package/src/connectors/printers/index.ts +43 -1
  57. package/src/connectors/spotify/README.md +14 -2
  58. package/src/connectors/spotify/index.ts +46 -2
  59. package/src/connectors/up/index.ts +1 -0
  60. package/src/connectors/weather/README.md +1 -1
  61. package/src/connectors/weather/bom.ts +1 -0
  62. package/src/connectors/weather/google.ts +66 -10
  63. package/src/connectors/weather/index.ts +16 -2
  64. package/src/connectors/weather/source.ts +2 -0
  65. package/src/devices.ts +102 -0
  66. package/src/health.ts +4 -2
  67. package/src/home.ts +75 -8
  68. package/src/index.ts +5 -1
@@ -8,13 +8,16 @@ 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. */
@@ -200,9 +203,7 @@ export function castActions(ctx: WriteContext): CastActions {
200
203
 
201
204
  const find = (target: string): CastDevice => {
202
205
  const all = Object.values(ctx.devices());
203
- const d =
204
- all.find((x) => x.id === castKey(target)) ??
205
- all.find((x) => x.name.toLowerCase() === target.toLowerCase());
206
+ const d = findCast(ctx.devices(), target);
206
207
  if (!d)
207
208
  throw new Error(
208
209
  `no Cast device or group "${target}"; have: ${
@@ -427,7 +428,7 @@ export function castActions(ctx: WriteContext): CastActions {
427
428
  /**
428
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
429
430
  * put back what it can, always: what the Default Media Receiver was playing is loaded again where it was, an idle
430
- * 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
431
432
  * (Spotify, YouTube) is interrupted and cannot be resumed from here; the result says so. Throws only before anything changed.
432
433
  */
433
434
  async announce(target, clip, options = {}) {
@@ -549,6 +550,26 @@ export function castActions(ctx: WriteContext): CastActions {
549
550
  muted: before.volume?.muted,
550
551
  },
551
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
+ }
552
573
  for (const m of each) {
553
574
  const ms = m.id === d.id ? s : ctx.session(m.id);
554
575
  if (m.level === undefined)
@@ -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
+ }
@@ -18,7 +18,7 @@ Soloist is Spotify's own headless Spotify Connect client (Linux arm64, armv7, x8
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.
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
22
  - Errors are `ConnectorError` with `code`: `offline`, `waiting`, `inactive`, `refused` (Soloist's `error`), `timeout` (3 s), `invalid` (bad input, nothing sent).
23
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.
24
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.
@@ -28,7 +28,7 @@ Soloist is Spotify's own headless Spotify Connect client (Linux arm64, armv7, x8
28
28
 
29
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:
30
30
 
31
- - `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.
32
32
  - `restored`: put back as it was.
33
33
  - `kept`: someone changed it meanwhile (resumed, skipped, set the volume), or the pause or lower volume never showed. Theirs stays.
34
34
  - `failed`: the restore failed or was never reported; it may be left quiet or paused.
@@ -68,6 +68,18 @@ Writes through this connector, and what a live read hasn't shown. In particular:
68
68
 
69
69
  ## Live findings
70
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
+
71
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).
72
84
 
73
85
  - Confirmed: pause, resume, next, prev, seek, shuffle, repeat to context and track, queue, all under 900 ms. `interrupt` restored the music.
@@ -1,10 +1,21 @@
1
1
  import { z } from "zod";
2
2
  import { asOf, type Clock, realClock } from "../../clock.ts";
3
3
  import { ConnectorError, errorText } from "../../connector.ts";
4
- import type { Integration, Part, Status } from "../../home.ts";
4
+ import {
5
+ type DeviceRecord,
6
+ deviceRecords,
7
+ type Integration,
8
+ type Part,
9
+ type Status,
10
+ } from "../../home.ts";
5
11
  import { writeLane } from "../../lane.ts";
6
12
  import type { Manifest } from "../../manifest.ts";
7
- import { spotifyText, stallText } from "./changes.ts";
13
+ import {
14
+ type SpeakerState,
15
+ speakerState,
16
+ spotifyText,
17
+ stallText,
18
+ } from "./changes.ts";
8
19
  import {
9
20
  commandFrame,
10
21
  fold,
@@ -411,6 +422,7 @@ export function spotify(
411
422
  (error) => {
412
423
  capping = false;
413
424
  if (error instanceof ConnectorError && error.code === "offline") return; // the status says it
425
+ if (capUntil !== window) return applyCap(); // a new start while it went: this failure doesn't speak for that one
414
426
  capped = true; // refused or unanswered: not again this window
415
427
  note = `lowering the volume to ${cap} failed: ${errorText(error)}`;
416
428
  publish();
@@ -733,6 +745,9 @@ export function spotify(
733
745
  async () => {
734
746
  const no = notPlaying();
735
747
  if (no) return no;
748
+ // "playing" while inactive is the account playing on another speaker (live, 2026-11-10): a pause would stop that one.
749
+ if (inactive())
750
+ return "it is not the active device, so the music playing is on another speaker";
736
751
  track = state.track?.uri;
737
752
  try {
738
753
  shown = (await actions.pause()).confirmed; // a pause that never shows still runs fn: what interrupts matters more
@@ -999,3 +1014,32 @@ export const manifest: Manifest<SpotifyState> = {
999
1014
  ? [spotifyText(state)]
1000
1015
  : [],
1001
1016
  };
1017
+
1018
+ /** The Soloist speaker as a home device: `state` is `speakerState`'s word; `detail` its `SpotifyState`. */
1019
+ export type SpotifyHomeDevice = DeviceRecord<
1020
+ "spotify",
1021
+ "speaker",
1022
+ SpeakerState,
1023
+ SpotifyState
1024
+ >;
1025
+
1026
+ /** The one speaker, once Soloist has said something. Its name is Soloist's `--device-name`, the one phones show. */
1027
+ export function spotifyHomeDevices(
1028
+ part: Part<SpotifyState> | undefined,
1029
+ ): SpotifyHomeDevice[] {
1030
+ const s = part?.state;
1031
+ if (!s || (s.loggedIn === undefined && s.status === undefined)) return [];
1032
+ return deviceRecords<SpotifyHomeDevice>(part, [
1033
+ {
1034
+ connector: "spotify",
1035
+ id: "spotify",
1036
+ name: s.deviceName ?? "Soloist",
1037
+ kind: "speaker",
1038
+ available: true,
1039
+ state: speakerState(part),
1040
+ asOf: s.asOf,
1041
+ text: spotifyText(s),
1042
+ detail: s,
1043
+ },
1044
+ ]);
1045
+ }
@@ -291,6 +291,7 @@ export function up(config: UpConfig): Integration<UpSnapshot, "up"> & {
291
291
  if (error instanceof UpError && error.status === 401)
292
292
  return update({
293
293
  status: "unavailable",
294
+ reason: "auth",
294
295
  detail: "Up rejected the token",
295
296
  state: last,
296
297
  });
@@ -62,7 +62,7 @@ Plain `fetch` cannot read `ftp://` (Bun 1.4.2: "protocol must be http:, https: o
62
62
 
63
63
  **Open-Meteo** (`open-meteo.ts`, [docs](https://open-meteo.com/en/docs)). One keyless JSON call per poll: `current` is 15-minutely model data (`interval: 900`), so it is read every poll, and the daily and hourly parts, from models that update hourly at best (6-hourly for IFS), are added to one call an hour. That call reads 48 hours from today's local midnight (`forecast_days=2`) for `hourly`; `sky.hours` is the 6 from the hour it was read. Open-Meteo gives local times and one `utc_offset_seconds`, and applies that offset to every time, across a daylight-saving change too (it returned 02:00 on the night Sydney skipped it; live, 2026-10-16), so `wall − offset` is right for all of them. `dayBlocks` labels each UTC hour in the real zone. 96 calls a day at the default, against 10,000 free. No `models` parameter: [Open-Meteo's BOM page](https://open-meteo.com/en/docs/bom-api) says BOM's open-data delivery is still suspended, and its `bom_access_global` answers only nulls. Radiation is the mean over the preceding interval. `precipitation` is the last 15 minutes, so `now.rainMm` stays empty rather than pretending to be since 9am. 1.3 s a call from Sydney, nearly all of it the TLS handshake with a server in Germany. Plain `fetch` of the JSON API, not the `openmeteo` SDK: the SDK forces FlatBuffers, reads variables by position, calls the global `fetch` (no injection for tests) and adds about 700 KB.
64
64
 
65
- **Google Weather** (`google.ts`, [docs](https://developers.google.com/maps/documentation/weather)). Built from the docs' example responses (`test/fixtures/weather/google-*.json`, the complete objects only; the docs elide the rest) and checked live against Sydney on 2026-10-13: `now`, `forecast` and `sky` in 1.19 s. `google({ key })` reads `forecast/minutes:lookup` every 15 minutes (its floor), `currentConditions:lookup` every 30 and `forecast/days:lookup?days=2` and `forecast/hours:lookup?hours=6` hourly, since Google documents nothing that changes faster. That is 192 calls a day, about 5,800–5,950 a month. Google bills each call as one Weather Usage event: 10,000 free a month, then $0.15 per 1,000 ([pricing](https://developers.google.com/maps/billing-and-pricing/pricing)). Any other use of the same key shares that quota. Whether the minute forecast bills as the same event is unverified: its docs page shows no price. Every slice is `modelled: true`: the docs never say it is observed. A day's rain chance is the higher of its day and night halves, its amount their sum; `currentConditionsHistory.qpf` is a rolling 24 hours, so it is not used as rain since 9am. A 400, 401 or 403 stops the source (`stop`), with no retry loop. The key goes in the query string and never into a message.
65
+ **Google Weather** (`google.ts`, [docs](https://developers.google.com/maps/documentation/weather)). Built from the docs' example responses (`test/fixtures/weather/google-*.json`, the complete objects only; the docs elide the rest) and checked live against Sydney on 2026-10-13: `now`, `forecast` and `sky` in 1.19 s. `google({ key })` reads `forecast/minutes:lookup` every 15 minutes (its floor), `currentConditions:lookup` every 30 and `forecast/days:lookup?days=2` and `forecast/hours:lookup?hours=6` hourly, since Google documents nothing that changes faster. That is 192 calls a day, about 5,800–5,950 a month. Google bills each call as one Weather Usage event: 10,000 free a month, then $0.15 per 1,000 ([pricing](https://developers.google.com/maps/billing-and-pricing/pricing)). Any other use of the same key shares that quota. The key goes in the `X-Goog-Api-Key` header: all four lookups answered 200 that way, live against a public Sydney point on 2026-11-10 (Bun 1.4.4). A bad key and a bad latitude both come back HTTP 400 `INVALID_ARGUMENT`; only the key's error body carries `API_KEY_INVALID`, which is how the source tells `auth` from `config`, and no key at all is 403. Other key failures (expired, the API not enabled) are assumed to be 401, 403 or an `API_KEY_…` reason: unverified. Whether the minute forecast bills as the same event is unverified: its docs page shows no price. Every slice is `modelled: true`: the docs never say it is observed. A day's rain chance is the higher of its day and night halves, its amount their sum; `currentConditionsHistory.qpf` is a rolling 24 hours, so it is not used as rain since 9am. A rejected key or a refused request stops the source (`stop`, with `stopReason`), with no retry loop.
66
66
 
67
67
  **Google minute forecast** (`toRain` in `google.ts`, [docs](https://developers.google.com/maps/documentation/weather/minute-forecast)). `GET /v1/forecast/minutes:lookup` for a point returns `overallPredictionTimeframe` and 24 `segments` (6 hours) of 15 minutes, each with `type` (`NONE`, `RAIN`, `SNOW`, `HAIL`), `probability` 0–100, `qpf` in mm and `intensity`. Probed for Sydney CBD on 2026-10-15 (curl): HTTP 200 in 1.0 s, every segment dry; the first segment began 12 minutes before the forecast's start. Google calls it Experimental, so its failure is a `problem` on `rain` and never stops the other slices. The docs do not list its coverage by country, say nothing of how it is made, and allow keeping minute values for one hour at most: `rain` owns 15 minutes of age on top of one poll, so a stale slice is dropped, not served (with a `pollMinutes` above 45 the slice outlives that hour: keep it at 15), and the test fixture is invented values in the real shape. We keep the segments that start within 2 hours (`RAIN_HORIZON_MINUTES`). **`rainSoon(rain, at, windowMin = 120)` is the owner**: when the first wet stretch starts and ends, its chance, amount and weight, and `coveredMin`, how much of the window the forecast covers. A segment with no `type` is unknown, not dry. Next: if the minute forecast fails, fall back to the hourly rain chance (`forecast/hours:lookup` already carries `precipitation`; not read yet).
68
68
 
@@ -284,6 +284,7 @@ export function bom(options: BomOptions = {}): WeatherSource {
284
284
  if (!options.fetch && base.startsWith("ftp:"))
285
285
  return {
286
286
  stop: "bom: plain fetch cannot read ftp://; pass fetch: ftpFetch() from @khalido/home/weather/ftp",
287
+ stopReason: "config",
287
288
  nextAt: Infinity,
288
289
  };
289
290
  if (force || at >= obsDue) {
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { type ConnectorError, getJson } from "../../connector.ts";
2
+ import { ConnectorError, fetchTimeout } from "../../connector.ts";
3
3
  import { RAIN_INTENSITIES } from "./rain.ts";
4
4
  import { sunDisagrees } from "./sun.ts";
5
5
  import {
@@ -324,6 +324,33 @@ export type GoogleOptions = {
324
324
  export const MIN_POLL_MINUTES = 15;
325
325
  export const CURRENT_MINUTES = 30;
326
326
 
327
+ /**
328
+ * Why Google refused: the `reason` of its error body's ErrorInfo ("API_KEY_INVALID"), since the status alone doesn't say.
329
+ * A bad key and a bad latitude are both HTTP 400 INVALID_ARGUMENT; only the key's carries a reason (live, 2026-11-10).
330
+ */
331
+ async function refusal(res: Response): Promise<string | undefined> {
332
+ try {
333
+ const body = (await res.json()) as {
334
+ error?: { details?: { reason?: unknown }[] };
335
+ };
336
+ const reason = body.error?.details?.find((d) => d.reason)?.reason;
337
+ return typeof reason === "string" ? reason : undefined;
338
+ } catch {
339
+ return undefined; // not JSON: the status is all there is
340
+ }
341
+ }
342
+
343
+ /** Google's refusal: the HTTP status, and `why`, the reason its error body gave. */
344
+ class GoogleError extends ConnectorError {
345
+ constructor(
346
+ message: string,
347
+ status: number,
348
+ readonly why?: string,
349
+ ) {
350
+ super(message, { status });
351
+ }
352
+ }
353
+
327
354
  /**
328
355
  * Google Weather as a source: modelled `now`, `forecast` (10 days available, 2 read), `sky` without radiation, and `rain` from the
329
356
  * minute forecast (Experimental at Google, so its failure is a `problem` on `rain` and never stops the other slices).
@@ -353,12 +380,28 @@ export function google(options: GoogleOptions): WeatherSource {
353
380
  signal,
354
381
  }): Promise<SourceReading> {
355
382
  const q = (extra: string) =>
356
- `key=${encodeURIComponent(options.key)}&location.latitude=${place.latitude}&location.longitude=${place.longitude}${extra}`;
357
- const json = (path: string) =>
358
- getJson(fetcher, `${base}/${path}`, {
359
- init: { headers: { "user-agent": USER_AGENT } },
360
- signal,
361
- });
383
+ `location.latitude=${place.latitude}&location.longitude=${place.longitude}${extra}`;
384
+ // The key goes in a header, never the URL, so no error, log or proxy that shows a URL can show it (all four lookups answered 200 this way, live 2026-11-10).
385
+ const json = async (path: string): Promise<unknown> => {
386
+ const res = await fetchTimeout(
387
+ fetcher,
388
+ `${base}/${path}`,
389
+ {
390
+ headers: {
391
+ "user-agent": USER_AGENT,
392
+ "x-goog-api-key": options.key,
393
+ },
394
+ },
395
+ { signal },
396
+ );
397
+ if (res.ok) return res.json();
398
+ const why = await refusal(res);
399
+ throw new GoogleError(
400
+ `HTTP ${res.status}${why ? ` ${why}` : ""} on GET /${path.split("?")[0]}`,
401
+ res.status,
402
+ why,
403
+ );
404
+ };
362
405
  // Started first and never throws: a failed minute forecast must not sink the rest.
363
406
  const minutes = json(`forecast/minutes:lookup?${q("")}`).then(
364
407
  (value) => ({ value }),
@@ -385,10 +428,23 @@ export function google(options: GoogleOptions): WeatherSource {
385
428
  slowDue = nextPoll(at, Math.max(pollMinutes, CURRENT_MINUTES));
386
429
  }
387
430
  } catch (error) {
388
- const status = (error as ConnectorError).status;
389
- if (status === 400 || status === 401 || status === 403)
431
+ // Only a GoogleError is Google's answer; a timeout or an abort is a DOMException, with fields of its own.
432
+ const { status, why } =
433
+ error instanceof GoogleError
434
+ ? error
435
+ : ({} as { status?: number; why?: string });
436
+ const said = `HTTP ${status}${why ? ` ${why}` : ""}`;
437
+ if (status === 401 || status === 403 || why?.startsWith("API_KEY_"))
438
+ return {
439
+ stop: `google: the key was rejected (${said})`,
440
+ stopReason: "auth",
441
+ nextAt: Infinity,
442
+ };
443
+ // A 400 with a good key is the request itself, and here that is the location: asking again won't change it.
444
+ if (status === 400)
390
445
  return {
391
- stop: `google: key rejected or request refused (HTTP ${status})`,
446
+ stop: `google: the request was refused (${said}); check the latitude and longitude`,
447
+ stopReason: "config",
392
448
  nextAt: Infinity,
393
449
  };
394
450
  problem = `google: ${(error as Error).message}`; // the old slices stay; the minute forecast below is still used