@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
@@ -1,3 +1,4 @@
1
+ import { mirekToKelvin, xyToHex } from "./color.js";
1
2
  /** A snapshot from one full `get()`. */
2
3
  export function snapshotFrom(resources, asOf) {
3
4
  return {
@@ -32,7 +33,7 @@ const ofType = (s, type) => Object.values(s.resources).filter((r) => r.type ===
32
33
  const groupedLightOf = (s, r) => r.services
33
34
  ?.map((x) => s.resources[x.rid])
34
35
  .find((x) => x?.type === "grouped_light");
35
- /** Every light, with on, brightness and reachability. */
36
+ /** Every light, with on, brightness, reachability, and its white temperature and colour where it has them. */
36
37
  export function lights(s) {
37
38
  const zigbee = ofType(s, "zigbee_connectivity");
38
39
  return ofType(s, "light").map((l) => {
@@ -43,9 +44,27 @@ export function lights(s) {
43
44
  on: l.on?.on,
44
45
  brightness: l.dimming?.brightness,
45
46
  reachable: link ? link.status === "connected" : undefined,
47
+ ...colorOf(l),
46
48
  };
47
49
  });
48
50
  }
51
+ /** A light's temperature and colour fields, only those the bridge reported. */
52
+ function colorOf(l) {
53
+ const out = {};
54
+ const ct = l.color_temperature;
55
+ if (typeof ct?.mirek === "number")
56
+ out.kelvin = mirekToKelvin(ct.mirek);
57
+ const { mirek_minimum: min, mirek_maximum: max } = ct?.mirek_schema ?? {};
58
+ if (min !== undefined && max !== undefined)
59
+ out.kelvinRange = [mirekToKelvin(max), mirekToKelvin(min)];
60
+ if (l.color?.xy) {
61
+ out.xy = l.color.xy;
62
+ out.hex = xyToHex(l.color.xy);
63
+ }
64
+ if (l.color?.gamut)
65
+ out.gamut = l.color.gamut;
66
+ return out;
67
+ }
49
68
  /** Rooms and zones. A room's children are devices; a zone's are lights. */
50
69
  export function groups(s) {
51
70
  const all = ofType(s, "light");
@@ -1,9 +1,9 @@
1
1
  import { z } from "zod";
2
2
  import { type Clock } from "../../clock.ts";
3
3
  import { type Finder } from "../../discover.ts";
4
- import type { Integration } from "../../home.ts";
4
+ import { type DeviceRecord, type Integration, type Part } from "../../home.ts";
5
5
  import type { Manifest } from "../../manifest.ts";
6
- import { type Printer, type PrintersState } from "./state.ts";
6
+ import { type Printer, type PrinterStatus, type PrintersState } from "./state.ts";
7
7
  export { type Marker, type Printer, type PrintersState, type PrinterStatus, printerText, } from "./state.ts";
8
8
  /** One printer in a home config. Everything the printer reports about itself (model, markers, state) comes live; `host` and `port` are only where it last answered. */
9
9
  export declare const configSchema: z.ZodObject<{
@@ -50,3 +50,7 @@ export declare function printers(config?: PrintersConfig): Integration<PrintersS
50
50
  /** Printers sorted by name. */
51
51
  export declare const printerList: (s: PrintersState) => Printer[];
52
52
  export declare const manifest: Manifest<PrintersState>;
53
+ /** A printer as a home device: `state` is the printer's own (`idle`, `printing`, `stopped`, `offline`); `detail` its `Printer`. */
54
+ export type PrinterHomeDevice = DeviceRecord<"printers", "printer", PrinterStatus, Printer>;
55
+ /** Every printer, by name. One that has stopped answering is not `available`, and keeps its last values. */
56
+ export declare function printersHomeDevices(part: Part<PrintersState> | undefined): PrinterHomeDevice[];
@@ -2,6 +2,7 @@ import { z } from "zod";
2
2
  import { asOf, realClock } from "../../clock.js";
3
3
  import { errorText } from "../../connector.js";
4
4
  import { DAY_MS, discover, HOUR, lostDetail, rescanDelay, } from "../../discover.js";
5
+ import { deviceRecords, } from "../../home.js";
5
6
  import { poller } from "../../poll.js";
6
7
  import { getPrinterAttributes, printerUrl } from "./client.js";
7
8
  import { attributesOf } from "./codec.js";
@@ -280,3 +281,25 @@ export const manifest = {
280
281
  ];
281
282
  },
282
283
  };
284
+ /** Every printer, by name. One that has stopped answering is not `available`, and keeps its last values. */
285
+ export function printersHomeDevices(part) {
286
+ if (!part?.state)
287
+ return [];
288
+ return deviceRecords(part, printerList(part.state).map((p) => ({
289
+ connector: "printers",
290
+ id: p.uuid,
291
+ name: p.name,
292
+ kind: "printer",
293
+ ...(p.state === undefined ? {} : { available: p.state !== "offline" }),
294
+ ...(p.state === undefined ? {} : { state: p.state }),
295
+ // An offline printer's values are from when it last answered, not from this check.
296
+ ...(p.state === "offline"
297
+ ? p.lastSeen === undefined
298
+ ? {}
299
+ : { asOf: p.lastSeen }
300
+ : { asOf: p.asOf }),
301
+ text: printerText(p),
302
+ ...(p.room ? { room: p.room } : {}),
303
+ detail: p,
304
+ })));
305
+ }
@@ -2,8 +2,16 @@ import type { Part } from "../../home.ts";
2
2
  import type { SpotifyState, Stall, Track } from "./client.ts";
3
3
  /** A stall in a few words: `playback stalled at 1:39 (Soloist issue #5); tried a seek`. */
4
4
  export declare function stallText(s: Stall | undefined): string | undefined;
5
- /** 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. */
6
- export type SpeakerState = "offline" | "connecting" | "waiting" | "idle" | "playing" | "paused" | "buffering" | (string & {});
5
+ /**
6
+ * Soloist says playing while it is not the account's active device: the music is on another Spotify device (a phone,
7
+ * another speaker) and this one is silent (live, 2026-11-10). Soloist doesn't say which. Unknown `active` is not elsewhere.
8
+ */
9
+ export declare const playingElsewhere: (s: SpotifyState | undefined) => boolean;
10
+ /**
11
+ * The speaker in one word, to switch on. `offline`: not reached (or no part); `waiting`: running, nobody has picked it;
12
+ * `elsewhere`: the account's music is playing on another Spotify device, not this one. A status Soloist sends beyond its four passes through.
13
+ */
14
+ export type SpeakerState = "offline" | "connecting" | "waiting" | "elsewhere" | "idle" | "playing" | "paused" | "buffering" | (string & {});
7
15
  export declare function speakerState(part: Part<SpotifyState> | undefined): SpeakerState;
8
16
  /**
9
17
  * A change worth telling someone about, from two reports of the speaker. `own`, on playback events only: it happened while
@@ -24,9 +32,15 @@ export type SpotifyEvent = {
24
32
  /**
25
33
  * The events between two reports, for `onChange`'s `before` and `after`: `offline` and `online`; `waiting` (signed out) and
26
34
  * `picked` (someone picked the speaker); `started` (from idle), `resumed` (from paused), `paused`, `stopped` (to idle), then
27
- * `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
35
+ * `track` for a new track, once it has a name, then `stalled` and `recovered` (Soloist issue #5). Buffering counts as playing.
36
+ * Music on another Spotify device (`playingElsewhere`) is not playing here: it moving away is `stopped`, it arriving is `started`
37
+ * then `track`, and nothing fires for its tracks meanwhile. No `before`, or one
28
38
  * 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.
29
39
  */
30
40
  export declare function spotifyChanges(before: Part<SpotifyState> | undefined, after: Part<SpotifyState> | undefined): SpotifyEvent[];
31
- /** The speaker in a few words: `Kitchen speaker: playing "Song" by Band · 35%`, or `…: waiting for someone to pick it`. */
32
- export declare function spotifyText(s: SpotifyState): string;
41
+ /**
42
+ * The speaker in a few words: `Kitchen speaker: playing "Song" by Band · 35%`, `…: waiting for someone to pick it`, or,
43
+ * 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).
44
+ * `on` names where, when the caller knows (`spotifyElsewhere`): `… is playing on Front room speaker`.
45
+ */
46
+ export declare function spotifyText(s: SpotifyState, on?: string[]): string;
@@ -9,6 +9,12 @@ export function stallText(s) {
9
9
  : `playback stalled at ${at} (Soloist issue #5)`;
10
10
  return s.tried ? `${what}; tried ${s.tried}` : what;
11
11
  }
12
+ const PLAYING = new Set(["playing", "buffering"]);
13
+ /**
14
+ * Soloist says playing while it is not the account's active device: the music is on another Spotify device (a phone,
15
+ * another speaker) and this one is silent (live, 2026-11-10). Soloist doesn't say which. Unknown `active` is not elsewhere.
16
+ */
17
+ export const playingElsewhere = (s) => s?.active === false && PLAYING.has(s.status ?? "");
12
18
  export function speakerState(part) {
13
19
  if (!part || part.status === "disconnected" || part.status === "unavailable")
14
20
  return "offline";
@@ -16,13 +22,16 @@ export function speakerState(part) {
16
22
  return "connecting";
17
23
  if (part.state?.loggedIn === false)
18
24
  return "waiting";
25
+ if (playingElsewhere(part.state))
26
+ return "elsewhere";
19
27
  return part.state?.status ?? "idle"; // signed in, nothing reported yet: nothing is playing
20
28
  }
21
- const PLAYING = new Set(["playing", "buffering"]);
22
29
  /**
23
30
  * The events between two reports, for `onChange`'s `before` and `after`: `offline` and `online`; `waiting` (signed out) and
24
31
  * `picked` (someone picked the speaker); `started` (from idle), `resumed` (from paused), `paused`, `stopped` (to idle), then
25
- * `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
32
+ * `track` for a new track, once it has a name, then `stalled` and `recovered` (Soloist issue #5). Buffering counts as playing.
33
+ * Music on another Spotify device (`playingElsewhere`) is not playing here: it moving away is `stopped`, it arriving is `started`
34
+ * then `track`, and nothing fires for its tracks meanwhile. No `before`, or one
26
35
  * 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.
27
36
  */
28
37
  export function spotifyChanges(before, after) {
@@ -45,8 +54,11 @@ export function spotifyChanges(before, after) {
45
54
  events.push({ type: "waiting" });
46
55
  if (b?.loggedIn === false && a?.loggedIn === true)
47
56
  events.push({ type: "picked" });
48
- const from = b?.status;
49
- const to = a?.status;
57
+ // What this speaker itself is doing: music on another device is silence here.
58
+ const here = (s) => (playingElsewhere(s) ? "idle" : s?.status);
59
+ const from = here(b);
60
+ // Still inactive after playing elsewhere: a pause or stop there is the other device's, not this one's.
61
+ const to = playingElsewhere(b) && a?.active === false ? "idle" : here(a);
50
62
  const playingNow = to !== undefined && PLAYING.has(to);
51
63
  const playingThen = from !== undefined && PLAYING.has(from);
52
64
  if (playingNow && !playingThen)
@@ -57,7 +69,11 @@ export function spotifyChanges(before, after) {
57
69
  add({ type: "stopped" });
58
70
  // Soloist's first report of a new track can have no name yet (seen live): the event waits for it.
59
71
  const named = (p) => !!p?.track?.name;
60
- if (a?.track && named(a) && (a.track.uri !== b?.track?.uri || !named(b)))
72
+ const heard = playingElsewhere(b) && playingNow ? undefined : b; // a track that was on another device is new here once it plays here
73
+ if (a?.track &&
74
+ named(a) &&
75
+ !playingElsewhere(a) &&
76
+ (a.track.uri !== heard?.track?.uri || !named(heard)))
61
77
  add({ type: "track", track: a.track });
62
78
  if (a?.stalled && !b?.stalled)
63
79
  events.push({ type: "stalled", stall: a.stalled });
@@ -68,8 +84,12 @@ export function spotifyChanges(before, after) {
68
84
  events.push({ type: "recovered" });
69
85
  return events;
70
86
  }
71
- /** The speaker in a few words: `Kitchen speaker: playing "Song" by Band · 35%`, or `…: waiting for someone to pick it`. */
72
- export function spotifyText(s) {
87
+ /**
88
+ * The speaker in a few words: `Kitchen speaker: playing "Song" by Band · 35%`, `…: waiting for someone to pick it`, or,
89
+ * 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).
90
+ * `on` names where, when the caller knows (`spotifyElsewhere`): `… is playing on Front room speaker`.
91
+ */
92
+ export function spotifyText(s, on = []) {
73
93
  const who = s.deviceName ?? "Soloist";
74
94
  if (s.loggedIn === false)
75
95
  return `${who}: waiting for someone to pick it`;
@@ -77,6 +97,8 @@ export function spotifyText(s) {
77
97
  const what = t
78
98
  ? `${t.name ? ` "${t.name}"` : ` ${t.uri}`}${t.artists.length ? ` by ${t.artists.join(", ")}` : ""}`
79
99
  : "";
100
+ if (playingElsewhere(s))
101
+ return `${who}: silent ·${what || " music"} is playing on ${on.length ? on.join(", ") : "another Spotify device"}`;
80
102
  const parts = [
81
103
  `${s.status ?? "status unknown"}${what}`,
82
104
  s.volume === undefined ? "" : `${s.volume}%`,
@@ -1,10 +1,11 @@
1
1
  import { z } from "zod";
2
2
  import { type Clock } from "../../clock.ts";
3
- import type { Integration } from "../../home.ts";
3
+ import { type DeviceRecord, type Integration, type Part } from "../../home.ts";
4
4
  import type { Manifest } from "../../manifest.ts";
5
+ import { type SpeakerState } from "./changes.ts";
5
6
  import { type SocketFactory, type SpotifyState } from "./client.ts";
6
7
  export { positionMs, type PositionAnchor, type SocketFactory, type SocketLike, type SpotifyState, type Stall, toSpotifyUri, type Track, } from "./client.ts";
7
- export { type SpeakerState, speakerState, type SpotifyEvent, spotifyChanges, spotifyText, } from "./changes.ts";
8
+ export { type SpeakerState, playingElsewhere, speakerState, type SpotifyEvent, spotifyChanges, spotifyText, } from "./changes.ts";
8
9
  /** Soloist has no default port (`--ws ADDR:PORT` opens it); this library assumes this one, the port in chota-bot's unit. */
9
10
  export declare const DEFAULT_URL = "ws://127.0.0.1:9091";
10
11
  /** A Soloist speaker in a home config. No key: Soloist's API key is the daemon's, never the library's. */
@@ -92,3 +93,7 @@ export type SpotifyActions = {
92
93
  */
93
94
  export declare function spotify(config?: SpotifyConfig): Integration<SpotifyState, "spotify"> & SpotifyActions;
94
95
  export declare const manifest: Manifest<SpotifyState>;
96
+ /** The Soloist speaker as a home device: `state` is `speakerState`'s word; `detail` its `SpotifyState`. */
97
+ export type SpotifyHomeDevice = DeviceRecord<"spotify", "speaker", SpeakerState, SpotifyState>;
98
+ /** The one speaker, once Soloist has said something. Its name is Soloist's `--device-name`, the one phones show. */
99
+ export declare function spotifyHomeDevices(part: Part<SpotifyState> | undefined): SpotifyHomeDevice[];
@@ -1,11 +1,12 @@
1
1
  import { z } from "zod";
2
2
  import { asOf, realClock } from "../../clock.js";
3
3
  import { ConnectorError, errorText } from "../../connector.js";
4
+ import { deviceRecords, } from "../../home.js";
4
5
  import { writeLane } from "../../lane.js";
5
- import { spotifyText, stallText } from "./changes.js";
6
+ import { speakerState, spotifyText, stallText, } from "./changes.js";
6
7
  import { commandFrame, fold, Frame, FRAME_TYPES, OPEN, oddities, positionMs, toSpotifyUri, } from "./client.js";
7
8
  export { positionMs, toSpotifyUri, } from "./client.js";
8
- export { speakerState, spotifyChanges, spotifyText, } from "./changes.js";
9
+ export { playingElsewhere, speakerState, spotifyChanges, spotifyText, } from "./changes.js";
9
10
  /** Soloist has no default port (`--ws ADDR:PORT` opens it); this library assumes this one, the port in chota-bot's unit. */
10
11
  export const DEFAULT_URL = "ws://127.0.0.1:9091";
11
12
  /** A Soloist speaker in a home config. No key: Soloist's API key is the daemon's, never the library's. */
@@ -259,6 +260,8 @@ export function spotify(config = {}) {
259
260
  capping = false;
260
261
  if (error instanceof ConnectorError && error.code === "offline")
261
262
  return; // the status says it
263
+ if (capUntil !== window)
264
+ return applyCap(); // a new start while it went: this failure doesn't speak for that one
262
265
  capped = true; // refused or unanswered: not again this window
263
266
  note = `lowering the volume to ${cap} failed: ${errorText(error)}`;
264
267
  publish();
@@ -563,6 +566,9 @@ export function spotify(config = {}) {
563
566
  const no = notPlaying();
564
567
  if (no)
565
568
  return no;
569
+ // "playing" while inactive is the account playing on another speaker (live, 2026-11-10): a pause would stop that one.
570
+ if (inactive())
571
+ return "it is not the active device, so the music playing is on another speaker";
566
572
  track = state.track?.uri;
567
573
  try {
568
574
  shown = (await actions.pause()).confirmed; // a pause that never shows still runs fn: what interrupts matters more
@@ -645,6 +651,7 @@ export function spotify(config = {}) {
645
651
  if (!st ||
646
652
  state.aside === "repair" ||
647
653
  (state.track?.uri === st.track &&
654
+ state.active !== false && // moved to another device: no longer this speaker's stall
648
655
  (state.status === "playing" || state.status === "buffering")))
649
656
  return;
650
657
  const { stalled: _, ...rest } = state;
@@ -672,7 +679,11 @@ export function spotify(config = {}) {
672
679
  a.track === b.track &&
673
680
  b.atMs - a.atMs >= gap &&
674
681
  Math.abs(b.ms - a.ms) < 1000;
675
- const playing = () => isOpen() && state.status === "playing" && !state.aside;
682
+ // Not while inactive: "playing" then is another device's music, and a repair's seek or pause would land on it.
683
+ const playing = () => isOpen() &&
684
+ state.status === "playing" &&
685
+ state.active !== false &&
686
+ !state.aside;
676
687
  /** One look while playing: a fresh position, then frozen, overrun, recovered or fine (Soloist issue #5). */
677
688
  const checkStall = async () => {
678
689
  if (!playing())
@@ -714,6 +725,7 @@ export function spotify(config = {}) {
714
725
  // Still ours to fix: playing this track, stalled, and no duck, interrupt or person in between.
715
726
  const ours = () => isOpen() &&
716
727
  state.status === "playing" &&
728
+ state.active !== false &&
717
729
  state.track?.uri === track &&
718
730
  state.stalled?.track === track &&
719
731
  (!state.aside || state.aside === "repair");
@@ -812,3 +824,22 @@ export const manifest = {
812
824
  ? [spotifyText(state)]
813
825
  : [],
814
826
  };
827
+ /** The one speaker, once Soloist has said something. Its name is Soloist's `--device-name`, the one phones show. */
828
+ export function spotifyHomeDevices(part) {
829
+ const s = part?.state;
830
+ if (!s || (s.loggedIn === undefined && s.status === undefined))
831
+ return [];
832
+ return deviceRecords(part, [
833
+ {
834
+ connector: "spotify",
835
+ id: "spotify",
836
+ name: s.deviceName ?? "Soloist",
837
+ kind: "speaker",
838
+ available: true,
839
+ state: speakerState(part),
840
+ asOf: s.asOf,
841
+ text: spotifyText(s),
842
+ detail: s,
843
+ },
844
+ ]);
845
+ }
@@ -142,6 +142,7 @@ export function up(config) {
142
142
  if (error instanceof UpError && error.status === 401)
143
143
  return update({
144
144
  status: "unavailable",
145
+ reason: "auth",
145
146
  detail: "Up rejected the token",
146
147
  state: last,
147
148
  });
@@ -185,6 +185,7 @@ export function bom(options = {}) {
185
185
  if (!options.fetch && base.startsWith("ftp:"))
186
186
  return {
187
187
  stop: "bom: plain fetch cannot read ftp://; pass fetch: ftpFetch() from @khalido/home/weather/ftp",
188
+ stopReason: "config",
188
189
  nextAt: Infinity,
189
190
  };
190
191
  if (force || at >= obsDue) {
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { getJson } from "../../connector.js";
2
+ import { ConnectorError, fetchTimeout } from "../../connector.js";
3
3
  import { RAIN_INTENSITIES } from "./rain.js";
4
4
  import { sunDisagrees } from "./sun.js";
5
5
  import { HOURLY_MINUTES, iso, nextPoll, num, RAIN_HORIZON_MINUTES, USER_AGENT, } from "./source.js";
@@ -273,6 +273,28 @@ export function toRain(minutes, fetchedAt) {
273
273
  */
274
274
  export const MIN_POLL_MINUTES = 15;
275
275
  export const CURRENT_MINUTES = 30;
276
+ /**
277
+ * Why Google refused: the `reason` of its error body's ErrorInfo ("API_KEY_INVALID"), since the status alone doesn't say.
278
+ * A bad key and a bad latitude are both HTTP 400 INVALID_ARGUMENT; only the key's carries a reason (live, 2026-11-10).
279
+ */
280
+ async function refusal(res) {
281
+ try {
282
+ const body = (await res.json());
283
+ const reason = body.error?.details?.find((d) => d.reason)?.reason;
284
+ return typeof reason === "string" ? reason : undefined;
285
+ }
286
+ catch {
287
+ return undefined; // not JSON: the status is all there is
288
+ }
289
+ }
290
+ /** Google's refusal: the HTTP status, and `why`, the reason its error body gave. */
291
+ class GoogleError extends ConnectorError {
292
+ why;
293
+ constructor(message, status, why) {
294
+ super(message, { status });
295
+ this.why = why;
296
+ }
297
+ }
276
298
  /**
277
299
  * Google Weather as a source: modelled `now`, `forecast` (10 days available, 2 read), `sky` without radiation, and `rain` from the
278
300
  * minute forecast (Experimental at Google, so its failure is a `problem` on `rain` and never stops the other slices).
@@ -295,11 +317,20 @@ export function google(options) {
295
317
  },
296
318
  minPollMinutes: MIN_POLL_MINUTES,
297
319
  async read({ place, at, pollMinutes, force, signal, }) {
298
- const q = (extra) => `key=${encodeURIComponent(options.key)}&location.latitude=${place.latitude}&location.longitude=${place.longitude}${extra}`;
299
- const json = (path) => getJson(fetcher, `${base}/${path}`, {
300
- init: { headers: { "user-agent": USER_AGENT } },
301
- signal,
302
- });
320
+ const q = (extra) => `location.latitude=${place.latitude}&location.longitude=${place.longitude}${extra}`;
321
+ // 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).
322
+ const json = async (path) => {
323
+ const res = await fetchTimeout(fetcher, `${base}/${path}`, {
324
+ headers: {
325
+ "user-agent": USER_AGENT,
326
+ "x-goog-api-key": options.key,
327
+ },
328
+ }, { signal });
329
+ if (res.ok)
330
+ return res.json();
331
+ const why = await refusal(res);
332
+ throw new GoogleError(`HTTP ${res.status}${why ? ` ${why}` : ""} on GET /${path.split("?")[0]}`, res.status, why);
333
+ };
303
334
  // Started first and never throws: a failed minute forecast must not sink the rest.
304
335
  const minutes = json(`forecast/minutes:lookup?${q("")}`).then((value) => ({ value }), (error) => ({ error: error }));
305
336
  let problem;
@@ -324,10 +355,22 @@ export function google(options) {
324
355
  }
325
356
  }
326
357
  catch (error) {
327
- const status = error.status;
328
- if (status === 400 || status === 401 || status === 403)
358
+ // Only a GoogleError is Google's answer; a timeout or an abort is a DOMException, with fields of its own.
359
+ const { status, why } = error instanceof GoogleError
360
+ ? error
361
+ : {};
362
+ const said = `HTTP ${status}${why ? ` ${why}` : ""}`;
363
+ if (status === 401 || status === 403 || why?.startsWith("API_KEY_"))
364
+ return {
365
+ stop: `google: the key was rejected (${said})`,
366
+ stopReason: "auth",
367
+ nextAt: Infinity,
368
+ };
369
+ // A 400 with a good key is the request itself, and here that is the location: asking again won't change it.
370
+ if (status === 400)
329
371
  return {
330
- stop: `google: key rejected or request refused (HTTP ${status})`,
372
+ stop: `google: the request was refused (${said}); check the latitude and longitude`,
373
+ stopReason: "config",
331
374
  nextAt: Infinity,
332
375
  };
333
376
  problem = `google: ${error.message}`; // the old slices stay; the minute forecast below is still used
@@ -1,5 +1,5 @@
1
1
  import { type Clock } from "../../clock.ts";
2
- import type { Integration } from "../../home.ts";
2
+ import type { Integration, Reason } from "../../home.ts";
3
3
  import { type Manifest } from "../../manifest.ts";
4
4
  import { type BlockDef, type DayBlocks } from "./blocks.ts";
5
5
  import { type RainSoon } from "./rain.ts";
@@ -38,6 +38,7 @@ export type WeatherState = Slices & {
38
38
  read: boolean;
39
39
  problem?: string;
40
40
  stopped?: string;
41
+ reason?: Exclude<Reason, "crashed">;
41
42
  anomaly?: string;
42
43
  }>;
43
44
  };
@@ -66,6 +66,7 @@ export function weather(config) {
66
66
  read: r !== undefined,
67
67
  problem: r?.problem,
68
68
  stopped: r?.stop,
69
+ reason: r?.stop ? (r.stopReason ?? "config") : undefined,
69
70
  anomaly: r?.anomaly,
70
71
  },
71
72
  ];
@@ -87,6 +88,12 @@ export function weather(config) {
87
88
  : undefined;
88
89
  update({
89
90
  status,
91
+ // Every source has stopped: a rejected key outranks a wrong setting, since it needs a person.
92
+ reason: status !== "unavailable"
93
+ ? undefined
94
+ : Object.values(report).some((r) => r.reason === "auth")
95
+ ? "auth"
96
+ : "config",
90
97
  detail,
91
98
  anomaly: notes("anomaly"),
92
99
  lastGoodAt,
@@ -1,3 +1,4 @@
1
+ import type { Reason } from "../../home.ts";
1
2
  /** Sent on every request, so each service can see who is calling rather than a runtime's default string. */
2
3
  export declare const USER_AGENT = "home (+https://github.com/khalido/home.ts)";
3
4
  export type Place = {
@@ -116,6 +117,7 @@ export type SourceReading = Slices & {
116
117
  problem?: string;
117
118
  anomaly?: string;
118
119
  stop?: string;
120
+ stopReason?: Exclude<Reason, "crashed">;
119
121
  };
120
122
  /**
121
123
  * A weather provider. `owns` names the slices it can fill and how old each may get (by its `at`, beyond one polling
@@ -0,0 +1,51 @@
1
+ import { type CastHomeDevice, castHomeDevices } from "./connectors/cast/index.ts";
2
+ import { type DaikinHomeDevice, daikinHomeDevices } from "./connectors/daikin/index.ts";
3
+ import { type EnphaseHomeDevice, enphaseHomeDevices } from "./connectors/enphase/index.ts";
4
+ import { type HueHomeDevice, hueHomeDevices } from "./connectors/hue/index.ts";
5
+ import { type PrinterHomeDevice, printersHomeDevices } from "./connectors/printers/index.ts";
6
+ import { type SpotifyHomeDevice, spotifyHomeDevices } from "./connectors/spotify/index.ts";
7
+ import type { HomeConfig } from "./config.ts";
8
+ import type { Part } from "./home.ts";
9
+ /** Any device this library reads. `connector`, then `kind` where a connector has more than one, narrows `detail` and `state`. */
10
+ export type HomeDevice = HueHomeDevice | DaikinHomeDevice | EnphaseHomeDevice | CastHomeDevice | PrinterHomeDevice | SpotifyHomeDevice;
11
+ /** Each connector's devices from its part, in the order `home status` lists the connectors. */
12
+ export declare const DEVICES: {
13
+ hue: typeof hueHomeDevices;
14
+ daikin: typeof daikinHomeDevices;
15
+ enphase: typeof enphaseHomeDevices;
16
+ cast: typeof castHomeDevices;
17
+ printers: typeof printersHomeDevices;
18
+ spotify: typeof spotifyHomeDevices;
19
+ };
20
+ /** Connectors that read a service, not a device: nothing to list. */
21
+ export declare const SERVICES: readonly string[];
22
+ /**
23
+ * Every device in the snapshot. With the home `config`, a device takes the `room` its entry gives it, a Hue room or zone
24
+ * (and its lights) the room whose `hueRoom` names it (a light in a room and a zone, the room's), and a device that reports
25
+ * no name of its own (the Daikin, the Enphase gateway) the entry's `name`. A device's own name is never replaced.
26
+ */
27
+ export declare function homeDevices(snapshot: Record<string, Part | undefined>, config?: Pick<HomeConfig, "devices" | "rooms">): HomeDevice[];
28
+ /**
29
+ * Where the account's Spotify is playing when the Soloist speaker says it is on another device: the names of the Cast
30
+ * devices and groups playing a track of that name in Spotify. A group is named, not its members. Empty when the music is
31
+ * on the speaker itself, nothing is playing, or it is on something Cast can't see (a phone, a laptop). Matched by the
32
+ * track's name, since neither side names the other.
33
+ */
34
+ export declare function spotifyElsewhere(snapshot: Record<string, Part | undefined>): string[];
35
+ /** A room of the home config with the devices placed in it. `noData`: device connectors the config places here that listed nothing in it (never reached, not set up, or a `hueRoom` the bridge doesn't have), so the room isn't empty, it is unread. Per connector, not per device. */
36
+ export type RoomDevices = {
37
+ id: string;
38
+ name: string;
39
+ floor?: string;
40
+ devices: HomeDevice[];
41
+ noData: string[];
42
+ };
43
+ /**
44
+ * `homeDevices` room by room, in the config's order, for a map of the home. A connector that is down but has read before
45
+ * still lists its devices, `available: false`; `noData` names one the config places in the room (a device's `room`, or
46
+ * `hueRoom`) that has nothing to list. `unplaced`: devices with no room, or one the config doesn't have.
47
+ */
48
+ export declare function byRoom(snapshot: Record<string, Part | undefined>, config: Pick<HomeConfig, "devices" | "rooms">): {
49
+ rooms: RoomDevices[];
50
+ unplaced: HomeDevice[];
51
+ };