@khalido/home 0.1.2 → 0.2.0

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 (116) hide show
  1. package/.env.example +2 -2
  2. package/CHANGELOG.md +74 -2
  3. package/README.md +7 -2
  4. package/dist/clock.js +2 -0
  5. package/dist/config.d.ts +5 -3
  6. package/dist/config.js +5 -1
  7. package/dist/connector.d.ts +9 -2
  8. package/dist/connectors/amber/index.js +1 -0
  9. package/dist/connectors/cast/index.d.ts +8 -6
  10. package/dist/connectors/cast/index.js +30 -4
  11. package/dist/connectors/cast/state.d.ts +66 -0
  12. package/dist/connectors/cast/state.js +141 -0
  13. package/dist/connectors/cast/writes.d.ts +0 -2
  14. package/dist/connectors/cast/writes.js +25 -7
  15. package/dist/connectors/daikin/client.d.ts +45 -2
  16. package/dist/connectors/daikin/client.js +67 -2
  17. package/dist/connectors/daikin/index.d.ts +8 -4
  18. package/dist/connectors/daikin/index.js +25 -4
  19. package/dist/connectors/enphase/index.d.ts +5 -1
  20. package/dist/connectors/enphase/index.js +34 -2
  21. package/dist/connectors/hue/ca.d.ts +2 -0
  22. package/dist/connectors/hue/ca.js +38 -0
  23. package/dist/connectors/hue/client.d.ts +81 -6
  24. package/dist/connectors/hue/client.js +217 -6
  25. package/dist/connectors/hue/color.d.ts +30 -0
  26. package/dist/connectors/hue/color.js +92 -0
  27. package/dist/connectors/hue/index.d.ts +21 -6
  28. package/dist/connectors/hue/index.js +59 -9
  29. package/dist/connectors/hue/schemas.d.ts +27 -0
  30. package/dist/connectors/hue/schemas.js +26 -0
  31. package/dist/connectors/hue/state.d.ts +10 -1
  32. package/dist/connectors/hue/state.js +20 -1
  33. package/dist/connectors/printers/index.d.ts +6 -2
  34. package/dist/connectors/printers/index.js +23 -0
  35. package/dist/connectors/spotify/changes.d.ts +19 -5
  36. package/dist/connectors/spotify/changes.js +29 -7
  37. package/dist/connectors/spotify/index.d.ts +7 -2
  38. package/dist/connectors/spotify/index.js +34 -3
  39. package/dist/connectors/up/index.js +1 -0
  40. package/dist/connectors/weather/bom.js +1 -0
  41. package/dist/connectors/weather/google.js +52 -9
  42. package/dist/connectors/weather/index.d.ts +2 -1
  43. package/dist/connectors/weather/index.js +7 -0
  44. package/dist/connectors/weather/source.d.ts +2 -0
  45. package/dist/devices.d.ts +51 -0
  46. package/dist/devices.js +115 -0
  47. package/dist/discover.js +1 -1
  48. package/dist/finders-entry.d.ts +1 -1
  49. package/dist/finders-entry.js +1 -1
  50. package/dist/finders.d.ts +2 -1
  51. package/dist/finders.js +16 -5
  52. package/dist/health.d.ts +2 -1
  53. package/dist/health.js +2 -1
  54. package/dist/home.d.ts +52 -5
  55. package/dist/home.js +114 -33
  56. package/dist/index.d.ts +3 -2
  57. package/dist/index.js +2 -1
  58. package/dist/node-fetch.d.ts +2 -2
  59. package/dist/node-fetch.js +3 -2
  60. package/docs/cli.md +13 -7
  61. package/docs/config.md +18 -5
  62. package/docs/decisions.md +16 -0
  63. package/docs/hue.md +161 -0
  64. package/docs/using.md +115 -3
  65. package/examples/home.jsonc +2 -1
  66. package/home.schema.json +10 -3
  67. package/llms.txt +19 -6
  68. package/package.json +1 -1
  69. package/skills/draw-home/SKILL.md +1 -1
  70. package/skills/use-home/SKILL.md +10 -10
  71. package/src/cli/cast.ts +51 -5
  72. package/src/cli/context.ts +2 -0
  73. package/src/cli/daikin.ts +71 -0
  74. package/src/cli/enphase.ts +5 -1
  75. package/src/cli/hue.ts +147 -59
  76. package/src/cli/main.ts +35 -10
  77. package/src/cli/probe.ts +21 -17
  78. package/src/cli/status.ts +87 -13
  79. package/src/clock.ts +1 -0
  80. package/src/config.ts +8 -2
  81. package/src/connector.ts +18 -2
  82. package/src/connectors/amber/index.ts +1 -0
  83. package/src/connectors/cast/README.md +10 -3
  84. package/src/connectors/cast/index.ts +65 -7
  85. package/src/connectors/cast/state.ts +204 -0
  86. package/src/connectors/cast/writes.ts +29 -8
  87. package/src/connectors/daikin/README.md +20 -4
  88. package/src/connectors/daikin/client.ts +104 -3
  89. package/src/connectors/daikin/index.ts +49 -4
  90. package/src/connectors/enphase/index.ts +49 -2
  91. package/src/connectors/hue/README.md +62 -17
  92. package/src/connectors/hue/ca.ts +39 -0
  93. package/src/connectors/hue/client.ts +362 -15
  94. package/src/connectors/hue/color.ts +123 -0
  95. package/src/connectors/hue/index.ts +94 -12
  96. package/src/connectors/hue/schemas.ts +26 -0
  97. package/src/connectors/hue/state.ts +27 -1
  98. package/src/connectors/printers/README.md +4 -2
  99. package/src/connectors/printers/index.ts +43 -1
  100. package/src/connectors/spotify/README.md +21 -3
  101. package/src/connectors/spotify/changes.ts +38 -9
  102. package/src/connectors/spotify/index.ts +55 -3
  103. package/src/connectors/up/index.ts +1 -0
  104. package/src/connectors/weather/README.md +1 -1
  105. package/src/connectors/weather/bom.ts +1 -0
  106. package/src/connectors/weather/google.ts +66 -10
  107. package/src/connectors/weather/index.ts +16 -2
  108. package/src/connectors/weather/source.ts +2 -0
  109. package/src/devices.ts +182 -0
  110. package/src/discover.ts +1 -2
  111. package/src/finders-entry.ts +1 -1
  112. package/src/finders.ts +18 -6
  113. package/src/health.ts +4 -2
  114. package/src/home.ts +166 -39
  115. package/src/index.ts +11 -1
  116. package/src/node-fetch.ts +6 -2
@@ -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,20 +16,38 @@ 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,
22
26
  } from "./state.ts";
23
27
 
28
+ export { HUE_BRIDGE_ROOT_CA } from "./ca.ts";
29
+ export {
30
+ colorToXy,
31
+ type Gamut,
32
+ kelvinToMirek,
33
+ mirekToKelvin,
34
+ type Rgb,
35
+ type XY,
36
+ xyToHex,
37
+ } from "./color.ts";
24
38
  export {
25
39
  bunPinnedFetch,
40
+ CONFIRM_MS,
41
+ type HueApplied,
42
+ type HueRequest,
43
+ type HueWriteOptions,
44
+ type LightChange,
26
45
  type HueClient,
27
46
  HueError,
28
47
  type HueOptions,
29
48
  hueClient,
30
49
  pair,
50
+ pairWhenPressed,
31
51
  } from "./client.ts";
32
52
  export type { HueEvent, Resource } from "./schemas.ts";
33
53
  export {
@@ -50,18 +70,22 @@ export const configSchema = z.looseObject({
50
70
  key: envName.describe(
51
71
  "env name holding the application key from `home pair`",
52
72
  ),
53
- ca: filePath.describe("Signify's root CA (PEM), relative to the config file"),
73
+ ca: filePath
74
+ .optional()
75
+ .describe(
76
+ "a root CA (PEM) to trust instead of Signify's two, which the package carries; relative to the config file",
77
+ ),
54
78
  });
55
79
 
56
80
  /**
57
- * Hue settings for a home. Leave out `fetch` in Bun and it pins to `ca` and `bridgeId`; in Node pass a pinned one.
81
+ * Hue settings for a home. Leave out `fetch` in Bun and it pins to `bridgeId` and `ca` (Signify's roots, `HUE_BRIDGE_ROOT_CA`, when left out); in Node pass a pinned one.
58
82
  * `find` (mDNS, matched by `bridgeId`) finds the bridge again after three failed reconnects; `idleMs` is the stream's watchdog.
59
83
  */
60
84
  export type HueConfig = {
61
85
  host: string;
62
86
  bridgeId: string;
63
87
  key: string;
64
- ca: string;
88
+ ca?: string;
65
89
  fetch?: typeof fetch;
66
90
  clock?: Clock;
67
91
  find?: Finder;
@@ -189,6 +213,7 @@ export function hue(
189
213
  if (isRejectedKey(error))
190
214
  return update({
191
215
  status: "unavailable",
216
+ reason: "auth",
192
217
  detail: "the bridge rejected the key; pair again",
193
218
  state: snapshot,
194
219
  });
@@ -215,6 +240,68 @@ const view = (s: HueSnapshot) => ({
215
240
  })),
216
241
  });
217
242
 
243
+ /** A light, room or zone in a line: "Kitchen: on · 40%", "Desk: on · 40% · 2700 K", "Strip: on · 80% · #ff8800", "Kitchen: off", or the name alone when the bridge hasn't said. */
244
+ export const hueText = (v: {
245
+ name?: string;
246
+ id: string;
247
+ on?: boolean;
248
+ brightness?: number;
249
+ kelvin?: number;
250
+ hex?: string;
251
+ }) => {
252
+ if (v.on === undefined) return v.name ?? v.id;
253
+ const parts = v.on
254
+ ? [
255
+ "on",
256
+ v.brightness === undefined ? "" : `${Math.round(v.brightness)}%`,
257
+ // A colour light showing a white reports both; the white is what it is showing.
258
+ v.kelvin !== undefined ? `${v.kelvin} K` : (v.hex ?? ""),
259
+ ]
260
+ : ["off"];
261
+ return `${v.name ?? v.id}: ${parts.filter(Boolean).join(" · ")}`;
262
+ };
263
+
264
+ /** A Hue light, or a room or zone, as a home device: `detail` is its `LightView` or `GroupView`. */
265
+ export type HueHomeDevice =
266
+ | DeviceRecord<"hue", "light", "on" | "off", LightView>
267
+ | DeviceRecord<"hue", "group", "on" | "off", GroupView>;
268
+
269
+ /** Every light, then every room and zone. A light's `available` is its Zigbee link; a group's `members` are its lights. */
270
+ export function hueHomeDevices(
271
+ part: Part<HueSnapshot> | undefined,
272
+ ): HueHomeDevice[] {
273
+ const s = part?.state;
274
+ if (!s) return [];
275
+ const on = (v: { on?: boolean }) =>
276
+ v.on === undefined
277
+ ? {}
278
+ : { state: v.on ? ("on" as const) : ("off" as const) };
279
+ return deviceRecords<HueHomeDevice>(part, [
280
+ ...lights(s).map((l): HueHomeDevice => ({
281
+ connector: "hue",
282
+ id: l.id,
283
+ name: l.name ?? l.id,
284
+ kind: "light",
285
+ ...(l.reachable === undefined ? {} : { available: l.reachable }),
286
+ ...on(l),
287
+ asOf: s.asOf,
288
+ text: hueText(l),
289
+ detail: l,
290
+ })),
291
+ ...groups(s).map((g): HueHomeDevice => ({
292
+ connector: "hue",
293
+ id: g.id,
294
+ name: g.name ?? g.id,
295
+ kind: "group",
296
+ ...on(g),
297
+ asOf: s.asOf,
298
+ text: hueText(g),
299
+ members: g.lightIds,
300
+ detail: g,
301
+ })),
302
+ ]);
303
+ }
304
+
218
305
  export const manifest: Manifest<HueSnapshot> = {
219
306
  name: "hue",
220
307
  title: "Philips Hue",
@@ -241,21 +328,16 @@ export const manifest: Manifest<HueSnapshot> = {
241
328
  {
242
329
  name: "HUE_CA_FILE",
243
330
  example: "/path/to/hue-root-ca.pem",
244
- what: "until HUE_BRIDGE_ROOT_CA ships in src/connectors/hue/",
331
+ optional: true,
332
+ what: "a root CA to trust instead of Signify's two, which the package carries",
245
333
  },
246
334
  ],
247
335
  files: ["ca"],
248
336
  /** "3 lights on", then "Kitchen: on · 40%" per room and zone. */
249
337
  lines({ state }) {
250
338
  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
- ];
339
+ const { lightsOn } = view(state);
340
+ return [`${lightsOn} lights on`, ...groups(state).map(hueText)];
259
341
  },
260
342
  record: ({ state }) => (state ? view(state) : {}),
261
343
  };
@@ -6,6 +6,7 @@ import { z } from "zod";
6
6
  // all are optional and unknown fields pass through, so a schema change degrades, never throws.
7
7
 
8
8
  const ref = z.object({ rid: z.string(), rtype: z.string() });
9
+ const xy = z.looseObject({ x: z.number(), y: z.number() });
9
10
 
10
11
  /** The resource kinds this library reads. */
11
12
  export const KINDS = [
@@ -27,6 +28,31 @@ export const Resource = z.looseObject({
27
28
  metadata: z.looseObject({ name: z.string().optional() }).optional(),
28
29
  on: z.looseObject({ on: z.boolean() }).optional(),
29
30
  dimming: z.looseObject({ brightness: z.number() }).optional(),
31
+ // A light's colour and the triangle of colours it can show; absent on a white light. A shape this doesn't expect
32
+ // reads as absent, so one odd light never fails the whole read.
33
+ color: z
34
+ .looseObject({
35
+ xy: xy.optional().catch(undefined),
36
+ gamut: z
37
+ .looseObject({ red: xy, green: xy, blue: xy })
38
+ .optional()
39
+ .catch(undefined),
40
+ })
41
+ .optional()
42
+ .catch(undefined),
43
+ // mirek is null while the light shows a colour; the schema is this light's own range.
44
+ color_temperature: z
45
+ .looseObject({
46
+ mirek: z.number().nullable().optional(),
47
+ mirek_schema: z
48
+ .looseObject({
49
+ mirek_minimum: z.number().optional(),
50
+ mirek_maximum: z.number().optional(),
51
+ })
52
+ .optional(),
53
+ })
54
+ .optional()
55
+ .catch(undefined),
30
56
  children: z.array(ref).optional(),
31
57
  services: z.array(ref).optional(),
32
58
  group: ref.optional(), // scene: the room or zone it belongs to
@@ -1,3 +1,4 @@
1
+ import { type Gamut, mirekToKelvin, type XY, xyToHex } from "./color.ts";
1
2
  import type { HueEvent, Resource } from "./schemas.ts";
2
3
 
3
4
  /** Every resource on the bridge by id, from the last full read plus every event since. `asOf` is local time it was last known fresh. */
@@ -56,9 +57,17 @@ export type LightView = {
56
57
  on?: boolean;
57
58
  brightness?: number;
58
59
  reachable?: boolean;
60
+ /** White temperature in kelvin, while the light shows a white; its range is `kelvinRange` (warmest, coolest). */
61
+ kelvin?: number;
62
+ kelvinRange?: [number, number];
63
+ /** The colour it shows: the bridge's `xy`, and `hex` for a screen (the hue at full brightness). Colour lights only. */
64
+ xy?: XY;
65
+ hex?: string;
66
+ /** The colours it can show. Present on a colour light, so its presence says "takes a colour". */
67
+ gamut?: Gamut;
59
68
  };
60
69
 
61
- /** Every light, with on, brightness and reachability. */
70
+ /** Every light, with on, brightness, reachability, and its white temperature and colour where it has them. */
62
71
  export function lights(s: HueSnapshot): LightView[] {
63
72
  const zigbee = ofType(s, "zigbee_connectivity");
64
73
  return ofType(s, "light").map((l) => {
@@ -69,10 +78,27 @@ export function lights(s: HueSnapshot): LightView[] {
69
78
  on: l.on?.on,
70
79
  brightness: l.dimming?.brightness,
71
80
  reachable: link ? link.status === "connected" : undefined,
81
+ ...colorOf(l),
72
82
  };
73
83
  });
74
84
  }
75
85
 
86
+ /** A light's temperature and colour fields, only those the bridge reported. */
87
+ function colorOf(l: Resource): Partial<LightView> {
88
+ const out: Partial<LightView> = {};
89
+ const ct = l.color_temperature;
90
+ if (typeof ct?.mirek === "number") out.kelvin = mirekToKelvin(ct.mirek);
91
+ const { mirek_minimum: min, mirek_maximum: max } = ct?.mirek_schema ?? {};
92
+ if (min !== undefined && max !== undefined)
93
+ out.kelvinRange = [mirekToKelvin(max), mirekToKelvin(min)];
94
+ if (l.color?.xy) {
95
+ out.xy = l.color.xy;
96
+ out.hex = xyToHex(l.color.xy);
97
+ }
98
+ if (l.color?.gamut) out.gamut = l.color.gamut;
99
+ return out;
100
+ }
101
+
76
102
  /** A room or zone as a row shows it, with its grouped light's state. */
77
103
  export type GroupView = {
78
104
  id: string;
@@ -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,14 +28,14 @@ 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.
35
35
 
36
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.
37
37
 
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`).
38
+ **Reading it.** `speakerState` takes the Part; `spotifyText` and `positionMs` take its `state`. `speakerState(part)`: `offline`, `connecting`, `waiting`, `elsewhere` (Soloist says playing but isn't the active device: the music is on another Spotify device and this one is silent; `playingElsewhere(state)` is the test), 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. Music elsewhere is silence here: leaving is `stopped`, arriving is `started` then `track`, and its tracks, pauses and stops meanwhile fire nothing (a resume there after a pause there fires one `stopped`: paused while inactive reads as Soloist says, so the resume looks like music leaving). Soloist doesn't say where it is; `spotifyElsewhere(snapshot)` from `@khalido/home` names the Cast devices playing that track in Spotify, and `homeDevices` puts them in the speaker's line (matched by name, built snapshots only, untried live), and is empty for a phone. 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`).
39
39
 
40
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).
41
41
 
@@ -68,6 +68,24 @@ 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-11, Raspberry Pi 5, Bun 1.4.4, on 0.1.3** (chota-bot's session; `maxVolume: 40` watching, writes from the CLI in another process). Two gaps in the cap, not fixed:
72
+
73
+ - From inactive and paused at 60, `play`: lowered to 40 within 2 ms of going active, `playing` at 40, then 60 again 90 ms after `playing`, and it played at 60. The cap takes its first confirmed lowering as done, so nothing lowered it again, and `anomaly` still said "lowered it". Soloist puts its own level back after `playing`.
74
+ - The connector started while the speaker was already active and paused at 60: `play` resumed at 60, nothing lowered it, no anomaly. This is the documented "not on resuming from pause while active".
75
+ - A volume set by hand (35) held through pause and play, so the put-back belongs to activation, not to pause.
76
+
77
+ **2026-11-10, Raspberry Pi 5, Bun 1.4.4, on 0.1.2, through this connector** (chota-bot's session, KO listening).
78
+
79
+ - 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.
80
+ - **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.
81
+ - **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.
82
+ - **`active: false` with `status: "playing"`:** the account playing on another speaker. `speakerState` said `playing`, `spotifyText` printed it as this speaker's, and `spotifyChanges` fired `resumed`, with the box silent. Since 2026-11-11 all three read it as `elsewhere`, and the stall watch skips it; not yet seen live.
83
+ - 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.
84
+ - **`interrupt` paused music that was on another speaker** (found in chota's switch): it checked `playing` and not `active`. Fixed after 0.1.2.
85
+ - `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.
86
+ - 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.
87
+ - Not run: the first-play cap from idle (the box never went active), stall events.
88
+
71
89
  **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
90
 
73
91
  - Confirmed: pause, resume, next, prev, seek, shuffle, repeat to context and track, queue, all under 900 ms. `interrupt` restored the music.
@@ -14,11 +14,24 @@ export function stallText(s: Stall | undefined): string | undefined {
14
14
  return s.tried ? `${what}; tried ${s.tried}` : what;
15
15
  }
16
16
 
17
- /** The speaker in one word, to switch on. `offline`: not reached (or no part); `waiting`: running, nobody has picked it. A status Soloist sends beyond its four passes through. */
17
+ const PLAYING = new Set(["playing", "buffering"]);
18
+
19
+ /**
20
+ * Soloist says playing while it is not the account's active device: the music is on another Spotify device (a phone,
21
+ * another speaker) and this one is silent (live, 2026-11-10). Soloist doesn't say which. Unknown `active` is not elsewhere.
22
+ */
23
+ export const playingElsewhere = (s: SpotifyState | undefined): boolean =>
24
+ s?.active === false && PLAYING.has(s.status ?? "");
25
+
26
+ /**
27
+ * The speaker in one word, to switch on. `offline`: not reached (or no part); `waiting`: running, nobody has picked it;
28
+ * `elsewhere`: the account's music is playing on another Spotify device, not this one. A status Soloist sends beyond its four passes through.
29
+ */
18
30
  export type SpeakerState =
19
31
  | "offline"
20
32
  | "connecting"
21
33
  | "waiting"
34
+ | "elsewhere"
22
35
  | "idle"
23
36
  | "playing"
24
37
  | "paused"
@@ -32,6 +45,7 @@ export function speakerState(
32
45
  return "offline";
33
46
  if (part.status === "connecting") return "connecting";
34
47
  if (part.state?.loggedIn === false) return "waiting";
48
+ if (playingElsewhere(part.state)) return "elsewhere";
35
49
  return part.state?.status ?? "idle"; // signed in, nothing reported yet: nothing is playing
36
50
  }
37
51
 
@@ -57,12 +71,12 @@ export type SpotifyEvent = { own?: true } & (
57
71
  | { type: "track"; track: Track }
58
72
  );
59
73
 
60
- const PLAYING = new Set(["playing", "buffering"]);
61
-
62
74
  /**
63
75
  * The events between two reports, for `onChange`'s `before` and `after`: `offline` and `online`; `waiting` (signed out) and
64
76
  * `picked` (someone picked the speaker); `started` (from idle), `resumed` (from paused), `paused`, `stopped` (to idle), then
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
77
+ * `track` for a new track, once it has a name, then `stalled` and `recovered` (Soloist issue #5). Buffering counts as playing.
78
+ * Music on another Spotify device (`playingElsewhere`) is not playing here: it moving away is `stopped`, it arriving is `started`
79
+ * then `track`, and nothing fires for its tracks meanwhile. No `before`, or one
66
80
  * 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
81
  */
68
82
  export function spotifyChanges(
@@ -87,8 +101,11 @@ export function spotifyChanges(
87
101
  events.push({ type: "waiting" });
88
102
  if (b?.loggedIn === false && a?.loggedIn === true)
89
103
  events.push({ type: "picked" });
90
- const from = b?.status;
91
- const to = a?.status;
104
+ // What this speaker itself is doing: music on another device is silence here.
105
+ const here = (s?: SpotifyState) => (playingElsewhere(s) ? "idle" : s?.status);
106
+ const from = here(b);
107
+ // Still inactive after playing elsewhere: a pause or stop there is the other device's, not this one's.
108
+ const to = playingElsewhere(b) && a?.active === false ? "idle" : here(a);
92
109
  const playingNow = to !== undefined && PLAYING.has(to);
93
110
  const playingThen = from !== undefined && PLAYING.has(from);
94
111
  if (playingNow && !playingThen)
@@ -98,7 +115,13 @@ export function spotifyChanges(
98
115
  add({ type: "stopped" });
99
116
  // Soloist's first report of a new track can have no name yet (seen live): the event waits for it.
100
117
  const named = (p?: SpotifyState) => !!p?.track?.name;
101
- if (a?.track && named(a) && (a.track.uri !== b?.track?.uri || !named(b)))
118
+ const heard = playingElsewhere(b) && playingNow ? undefined : b; // a track that was on another device is new here once it plays here
119
+ if (
120
+ a?.track &&
121
+ named(a) &&
122
+ !playingElsewhere(a) &&
123
+ (a.track.uri !== heard?.track?.uri || !named(heard))
124
+ )
102
125
  add({ type: "track", track: a.track });
103
126
  if (a?.stalled && !b?.stalled)
104
127
  events.push({ type: "stalled", stall: a.stalled });
@@ -112,14 +135,20 @@ export function spotifyChanges(
112
135
  return events;
113
136
  }
114
137
 
115
- /** The speaker in a few words: `Kitchen speaker: playing "Song" by Band · 35%`, or `…: waiting for someone to pick it`. */
116
- export function spotifyText(s: SpotifyState): string {
138
+ /**
139
+ * The speaker in a few words: `Kitchen speaker: playing "Song" by Band · 35%`, `…: waiting for someone to pick it`, or,
140
+ * for music on another Spotify device, `…: silent · "Song" by Band is playing on another Spotify device` (no volume: it isn't this speaker's playing level).
141
+ * `on` names where, when the caller knows (`spotifyElsewhere`): `… is playing on Front room speaker`.
142
+ */
143
+ export function spotifyText(s: SpotifyState, on: string[] = []): string {
117
144
  const who = s.deviceName ?? "Soloist";
118
145
  if (s.loggedIn === false) return `${who}: waiting for someone to pick it`;
119
146
  const t = s.track;
120
147
  const what = t
121
148
  ? `${t.name ? ` "${t.name}"` : ` ${t.uri}`}${t.artists.length ? ` by ${t.artists.join(", ")}` : ""}`
122
149
  : "";
150
+ if (playingElsewhere(s))
151
+ return `${who}: silent ·${what || " music"} is playing on ${on.length ? on.join(", ") : "another Spotify device"}`;
123
152
  const parts = [
124
153
  `${s.status ?? "status unknown"}${what}`,
125
154
  s.volume === undefined ? "" : `${s.volume}%`,
@@ -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,
@@ -32,6 +43,7 @@ export {
32
43
  } from "./client.ts";
33
44
  export {
34
45
  type SpeakerState,
46
+ playingElsewhere,
35
47
  speakerState,
36
48
  type SpotifyEvent,
37
49
  spotifyChanges,
@@ -411,6 +423,7 @@ export function spotify(
411
423
  (error) => {
412
424
  capping = false;
413
425
  if (error instanceof ConnectorError && error.code === "offline") return; // the status says it
426
+ if (capUntil !== window) return applyCap(); // a new start while it went: this failure doesn't speak for that one
414
427
  capped = true; // refused or unanswered: not again this window
415
428
  note = `lowering the volume to ${cap} failed: ${errorText(error)}`;
416
429
  publish();
@@ -733,6 +746,9 @@ export function spotify(
733
746
  async () => {
734
747
  const no = notPlaying();
735
748
  if (no) return no;
749
+ // "playing" while inactive is the account playing on another speaker (live, 2026-11-10): a pause would stop that one.
750
+ if (inactive())
751
+ return "it is not the active device, so the music playing is on another speaker";
736
752
  track = state.track?.uri;
737
753
  try {
738
754
  shown = (await actions.pause()).confirmed; // a pause that never shows still runs fn: what interrupts matters more
@@ -821,6 +837,7 @@ export function spotify(
821
837
  !st ||
822
838
  state.aside === "repair" ||
823
839
  (state.track?.uri === st.track &&
840
+ state.active !== false && // moved to another device: no longer this speaker's stall
824
841
  (state.status === "playing" || state.status === "buffering"))
825
842
  )
826
843
  return;
@@ -855,7 +872,12 @@ export function spotify(
855
872
  a.track === b.track &&
856
873
  b.atMs - a.atMs >= gap &&
857
874
  Math.abs(b.ms - a.ms) < 1000;
858
- const playing = () => isOpen() && state.status === "playing" && !state.aside;
875
+ // Not while inactive: "playing" then is another device's music, and a repair's seek or pause would land on it.
876
+ const playing = () =>
877
+ isOpen() &&
878
+ state.status === "playing" &&
879
+ state.active !== false &&
880
+ !state.aside;
859
881
 
860
882
  /** One look while playing: a fresh position, then frozen, overrun, recovered or fine (Soloist issue #5). */
861
883
  const checkStall = async () => {
@@ -901,6 +923,7 @@ export function spotify(
901
923
  const ours = () =>
902
924
  isOpen() &&
903
925
  state.status === "playing" &&
926
+ state.active !== false &&
904
927
  state.track?.uri === track &&
905
928
  state.stalled?.track === track &&
906
929
  (!state.aside || state.aside === "repair");
@@ -999,3 +1022,32 @@ export const manifest: Manifest<SpotifyState> = {
999
1022
  ? [spotifyText(state)]
1000
1023
  : [],
1001
1024
  };
1025
+
1026
+ /** The Soloist speaker as a home device: `state` is `speakerState`'s word; `detail` its `SpotifyState`. */
1027
+ export type SpotifyHomeDevice = DeviceRecord<
1028
+ "spotify",
1029
+ "speaker",
1030
+ SpeakerState,
1031
+ SpotifyState
1032
+ >;
1033
+
1034
+ /** The one speaker, once Soloist has said something. Its name is Soloist's `--device-name`, the one phones show. */
1035
+ export function spotifyHomeDevices(
1036
+ part: Part<SpotifyState> | undefined,
1037
+ ): SpotifyHomeDevice[] {
1038
+ const s = part?.state;
1039
+ if (!s || (s.loggedIn === undefined && s.status === undefined)) return [];
1040
+ return deviceRecords<SpotifyHomeDevice>(part, [
1041
+ {
1042
+ connector: "spotify",
1043
+ id: "spotify",
1044
+ name: s.deviceName ?? "Soloist",
1045
+ kind: "speaker",
1046
+ available: true,
1047
+ state: speakerState(part),
1048
+ asOf: s.asOf,
1049
+ text: spotifyText(s),
1050
+ detail: s,
1051
+ },
1052
+ ]);
1053
+ }
@@ -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