@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
package/CHANGELOG.md CHANGED
@@ -1,9 +1,39 @@
1
1
  # Changelog
2
2
 
3
- All notable changes to home.ts (`@khalido/home` on npm), in [Keep a Changelog](https://keepachangelog.com/) form. Versions follow [SemVer](https://semver.org) at 0.x: a minor release can break what a consumer calls, a patch can't.
3
+ All notable changes to home.ts (`@khalido/home` on npm), in [Keep a Changelog](https://keepachangelog.com/) form. Versions follow [SemVer](https://semver.org) at 0.x: a minor release can break what a consumer calls, a patch can't. A release that breaks something opens with a **Breaking** heading, so a consumer on `latest` sees it first.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.1.3] - 2026-11-10
8
+
9
+ ### Added
10
+
11
+ - **`Part.reason`:** why a part is `unavailable`, for code to switch on: `auth` (a key or token was rejected or unusable, so a person must re-key), `config` (a setting or file is wrong) or `crashed` (its `run` threw). `health()` and `home --json` carry it. It can be absent. Hue, Enphase, Amber and Up set `auth` on a rejected key, and so does an Enphase token that does not parse. In `home status`, a connector that fails to build from the environment says `auth` or `config`.
12
+ - **`homeDevices(snapshot, config?)`:** every device in one list (Amber, Up and the weather read services and list nothing), each answering the same questions (`connector`, `id`, `name`, `kind`, `available`, `reason`, `asOf`, `state`, `text`, `room`, `members`), with the connector's own typed row in `detail` and `connector` narrowing its type. Each device connector also exports its own: `hueHomeDevices`, `daikinHomeDevices`, `enphaseHomeDevices`, `castHomeDevices`, `printersHomeDevices`, `spotifyHomeDevices`. `DeviceRecord` is the common shape and `deviceRecords(part, records)` what a connector returns them through: while its part isn't connected, none is `available`.
13
+ - **`ConnectorCode`:** the type of `ConnectorError.code`, every code the library sets, so a consumer's table of messages fails to compile when one is added.
14
+ - **cast:** `castStatus(snapshot)` says what each device and group is doing (`idle`, `restorable`, `busy` or `offline`, with the app, title and volume), and `castFree(snapshot, targets)` gives the targets an announcement would not disturb, with the ones it `skipped` and why. For announcing only where nothing is playing.
15
+ - **weather:** `reason` on an `unavailable` weather part (`auth` if a stopped source's key was rejected, else `config`), and `state.sources.<name>.reason` beside `stopped`, so code can see a dead Google key while another source keeps the part `connected`. A custom `WeatherSource` can return `stopReason` with `stop`.
16
+
17
+ ### Changed
18
+
19
+ - **`ConnectorError.code`** is typed `ConnectorCode`, no longer `string`. Reading it is unchanged. Only code that builds a `ConnectorError` with a code outside that set stops compiling; chota-bot and jaldi don't.
20
+ - **hue:** a room or zone the bridge gives no on or off for prints its name alone in `home hue`, no longer "off".
21
+ - **weather:** Google's key is sent in the `X-Goog-Api-Key` header, no longer in the URL.
22
+ - **weather:** Google's stop text says which it was: `google: the key was rejected (HTTP 400 API_KEY_INVALID)` or `google: the request was refused (HTTP 400); check the latitude and longitude`. Before, both read `google: key rejected or request refused (HTTP 400)`.
23
+
24
+ ### Fixed
25
+
26
+ - **cast:** `announce` to a group with a `volume` left the group's own level at the announcement's, though every member's was put back. The group's is now restored too, before its members', so theirs land last.
27
+ - **spotify:** `interrupt` paused the music when the account was playing on another speaker (the box reports `playing` while not the active device). It now leaves it alone and returns `music: "untouched"`, as `duck` does.
28
+ - **spotify:** `maxVolume` gave up when a lowering was refused or unanswered after a second start signal (buffering, then playing) had opened a new window, leaving the volume above the cap. The new window now lowers again.
29
+ - **`createHome`:** an `onChange` that throws or rejects no longer marks the integration `unavailable`; the error goes to `console.error` and the integration keeps running.
30
+ - **`createHome`:** `refresh()` before `start()` resolves at once instead of never.
31
+ - **`createHome`:** an integration whose `run` fails after a good report keeps its last `state`.
32
+ - **`createHome`:** a `run` that throws synchronously marks that integration `unavailable`; the others still start.
33
+ - **`home <connector> --config`** needs only that connector's keys, and runs only that connector.
34
+ - **`realClock.sleep`** resolves at once on an already-aborted signal.
35
+ - **Docs:** the `readFile` in `docs/config.md` expands `~/`, which `home discover --write` puts in Enphase's `token`. The README says the package is ESM only.
36
+
7
37
  ## [0.1.2] - 2026-11-09
8
38
 
9
39
  From chota-bot's live writes on a Raspberry Pi. `setVolume` on an inactive speaker now rejects, and `track` events can come later than before.
@@ -68,7 +98,8 @@ First release. Nine connectors: Hue, Daikin, Enphase, Google Cast, AirPrint prin
68
98
  - **Agent skills** in `skills/`: `use-home` (reading safely, connecting, the rules for writes) and `draw-home` (an SVG plan of a home and its rooms). `llms.txt` points agents at the docs.
69
99
  - **Node.** Hue and Enphase take the fetches in `@khalido/home/node`. The library runs on Bun 1.4+ and Node 24+; the `home` command needs Bun.
70
100
 
71
- [Unreleased]: https://github.com/khalido/home.ts/compare/v0.1.2...HEAD
101
+ [Unreleased]: https://github.com/khalido/home.ts/compare/v0.1.3...HEAD
102
+ [0.1.3]: https://github.com/khalido/home.ts/compare/v0.1.2...v0.1.3
72
103
  [0.1.2]: https://github.com/khalido/home.ts/compare/v0.1.1...v0.1.2
73
104
  [0.1.1]: https://github.com/khalido/home.ts/compare/v0.1.0...v0.1.1
74
105
  [0.1.0]: https://github.com/khalido/home.ts/releases/tag/v0.1.0
package/README.md CHANGED
@@ -13,7 +13,7 @@ Small, typed home automation for TypeScript, starting with the devices in my hou
13
13
  bun add @khalido/home # or: npm install @khalido/home
14
14
  ```
15
15
 
16
- The library runs on Bun 1.4+ and Node 24+. The `home` command needs Bun.
16
+ The library runs on Bun 1.4+ and Node 24+, and is ESM only. The `home` command needs Bun.
17
17
 
18
18
  ## Try it
19
19
 
package/dist/clock.js CHANGED
@@ -2,6 +2,8 @@
2
2
  export const realClock = {
3
3
  now: () => Date.now(),
4
4
  sleep: (ms, signal) => new Promise((resolve) => {
5
+ if (signal?.aborted)
6
+ return resolve();
5
7
  const done = () => (clearTimeout(timer),
6
8
  signal?.removeEventListener("abort", done),
7
9
  resolve());
@@ -6,13 +6,20 @@ export declare const TIMEOUT_MS = 10000;
6
6
  * Node's fetch puts the reason there: "fetch failed: connect ECONNREFUSED 192.0.2.10:443".
7
7
  */
8
8
  export declare function errorText(error: unknown): string;
9
+ /**
10
+ * Every `code` a `ConnectorError` from this library carries, so a consumer's table of messages fails to compile when one is added.
11
+ * The spotify connector's writes: `offline` (not connected), `waiting` (nobody signed in), `inactive` (not the active device), `refused`
12
+ * (the device said no), `timeout` (no answer), `invalid` (bad input, nothing sent). `link_button`: Hue wants its button
13
+ * pressed. `ipp`: a printer answered with an IPP error, its code in `status`.
14
+ */
15
+ export type ConnectorCode = "offline" | "waiting" | "inactive" | "refused" | "timeout" | "invalid" | "link_button" | "ipp";
9
16
  /** A failed device or service call. `status` is the HTTP status (or a device's own code); `code` a connector's own reason. */
10
17
  export declare class ConnectorError extends Error {
11
18
  readonly status?: number;
12
- readonly code?: string;
19
+ readonly code?: ConnectorCode;
13
20
  constructor(message: string, options?: {
14
21
  status?: number;
15
- code?: string;
22
+ code?: ConnectorCode;
16
23
  cause?: unknown;
17
24
  });
18
25
  }
@@ -44,6 +44,7 @@ export function amber(config) {
44
44
  if (error instanceof AmberError && error.status === 401)
45
45
  return update({
46
46
  status: "unavailable",
47
+ reason: "auth",
47
48
  detail: "Amber rejected the key",
48
49
  state: last,
49
50
  });
@@ -1,14 +1,14 @@
1
1
  import { z } from "zod";
2
2
  import { type Clock } from "../../clock.ts";
3
3
  import { type Found } 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
6
  import { type CastConnect } from "./codec.ts";
7
- import { type CastDevice, type CastSnapshot, type Resolved } from "./state.ts";
7
+ import { type CastDevice, type CastSnapshot, type Resolved, type CastForm, type CastStatus } from "./state.ts";
8
8
  import { type CastActions, type ServeClip } from "./writes.ts";
9
9
  export type { CastConnect } from "./codec.ts";
10
- export { castForm } from "./state.ts";
11
- export type { CastDevice, CastForm, CastSnapshot, Resolved } from "./state.ts";
10
+ export { castDevices, castForm, castFree, castStatus } from "./state.ts";
11
+ export type { CastDevice, CastForm, CastSkipped, CastSnapshot, CastStatus, Resolved, } from "./state.ts";
12
12
  export type { AnnounceResult, CastActions, CastClip, ServeClip, } from "./writes.ts";
13
13
  /** A Cast device or speaker group in a home config. No host: a group's moves with its leader, so every address comes from discovery. */
14
14
  export declare const configSchema: z.ZodObject<{
@@ -58,6 +58,8 @@ export type CastConfig = {
58
58
  export declare function cast(config: CastConfig): Integration<CastSnapshot, "cast"> & CastActions;
59
59
  /** One Cast device or group in a few words: "Nest Hub: idle · 47%", "Group: idle · 43% · 3 members: A, B, C, led by A". */
60
60
  export declare function castText(d: CastDevice, s: CastSnapshot): string;
61
- /** Devices first, then groups, each by name. */
62
- export declare const castDevices: (s: CastSnapshot) => CastDevice[];
61
+ /** A Cast device or group as a home device: `kind` is its form (`cast` for a model it doesn't know) or `group`; `detail` its `CastStatus`. */
62
+ export type CastHomeDevice = DeviceRecord<"cast", CastForm | "cast" | "group", CastStatus["state"], CastStatus>;
63
+ /** Every Cast device and group as a home device, devices first, each by name. */
64
+ export declare function castHomeDevices(part: Part<CastSnapshot> | undefined): CastHomeDevice[];
63
65
  export declare const manifest: Manifest<CastSnapshot>;
@@ -3,11 +3,12 @@ import { asOf, realClock } from "../../clock.js";
3
3
  import { errorText } from "../../connector.js";
4
4
  import { HOUR, lostDetail, rescanDelay } from "../../discover.js";
5
5
  import { poller } from "../../poll.js";
6
+ import { deviceRecords, } from "../../home.js";
6
7
  import { NS } from "./codec.js";
7
8
  import { CastPayload, KNOWN_TYPES } from "./schemas.js";
8
- import { castKey, connection, found, mediaApp, reduce, } from "./state.js";
9
+ import { castKey, connection, found, mediaApp, reduce, castDevices, castStatus, } from "./state.js";
9
10
  import { castActions, } from "./writes.js";
10
- export { castForm } from "./state.js";
11
+ export { castDevices, castForm, castFree, castStatus } from "./state.js";
11
12
  /** A Cast device or speaker group in a home config. No host: a group's moves with its leader, so every address comes from discovery. */
12
13
  export const configSchema = z.looseObject({
13
14
  connector: z.literal("cast"),
@@ -411,8 +412,33 @@ export function castText(d, s) {
411
412
  ];
412
413
  return `${d.name}: ${parts.filter(Boolean).join(" · ")}`;
413
414
  }
414
- /** Devices first, then groups, each by name. */
415
- export const castDevices = (s) => Object.values(s.devices).sort((a, b) => a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name));
415
+ /** Every Cast device and group as a home device, devices first, each by name. */
416
+ export function castHomeDevices(part) {
417
+ const s = part?.state;
418
+ if (!s)
419
+ return [];
420
+ return deviceRecords(part, castStatus(s).map((detail) => {
421
+ const d = s.devices[detail.id];
422
+ // Connected and heard from: available. Connecting, or connected with no status yet: not known.
423
+ const available = d.status === "disconnected"
424
+ ? false
425
+ : d.status === "connected" && detail.state !== "offline"
426
+ ? true
427
+ : undefined;
428
+ return {
429
+ connector: "cast",
430
+ id: d.id,
431
+ name: d.name,
432
+ kind: d.kind === "group" ? "group" : (d.form ?? "cast"),
433
+ ...(available === undefined ? {} : { available }),
434
+ ...(d.asOf ? { asOf: d.asOf } : {}), // never answered: no time to give
435
+ state: detail.state,
436
+ text: castText(d, s),
437
+ ...(d.members ? { members: d.members } : {}),
438
+ detail,
439
+ };
440
+ }));
441
+ }
416
442
  /** How many entries have an address, and one line per device and group. */
417
443
  const castView = (s) => ({
418
444
  found: Object.values(s.devices).filter((d) => d.host).length,
@@ -64,3 +64,55 @@ export declare const mediaApp: (app?: {
64
64
  }[];
65
65
  transportId?: string;
66
66
  }) => boolean;
67
+ /** Google's own player for a URL of audio or video: what `announce` plays a clip with, and can put back. */
68
+ export declare const DEFAULT_MEDIA_RECEIVER = "CC1AD845";
69
+ /** Devices first, then groups, each by name. */
70
+ export declare const castDevices: (s: CastSnapshot) => CastDevice[];
71
+ /** A target is a castId, else a name in any case. `announce` and `castFree` find it the same way. */
72
+ export declare const findCast: (devices: Record<string, CastDevice>, target: string) => CastDevice | undefined;
73
+ /**
74
+ * What a device or group is doing, for a caller choosing where to play something. `state`:
75
+ * - `idle`: no app, or the idle screen.
76
+ * - `restorable`: the Default Media Receiver, whose clip `announce` puts back at its position.
77
+ * - `busy`: another app, playing or paused. An announce ends it and nothing here can resume it.
78
+ * - `offline`: not connected, or connected and not heard from yet.
79
+ * A member playing its group's media takes the group's state, with `via` naming the group and `app` the group's. A group
80
+ * is as free as its least free connected member. `standby` is a TV that is off or on another input: `idle` by its app,
81
+ * but a clip may wake it.
82
+ */
83
+ export type CastStatus = {
84
+ id: string;
85
+ name: string;
86
+ kind: "device" | "group";
87
+ state: "idle" | "restorable" | "busy" | "offline";
88
+ app?: string;
89
+ playerState?: string;
90
+ title?: string;
91
+ artist?: string;
92
+ via?: string;
93
+ volume?: number;
94
+ muted?: boolean;
95
+ standby?: boolean;
96
+ members?: CastStatus[];
97
+ };
98
+ /** Every device and group in the snapshot as a `CastStatus`, devices first, each by name. */
99
+ export declare function castStatus(s: CastSnapshot): CastStatus[];
100
+ /** A target `castFree` left out: `standby` is a TV that is off, which a clip may wake. */
101
+ export type CastSkipped = {
102
+ name: string;
103
+ why: "busy" | "offline" | "standby" | "unknown";
104
+ app?: string;
105
+ };
106
+ /**
107
+ * Of these targets (names in any case, or ids), the ones an announcement would not disturb: `idle` or `restorable`, and
108
+ * not a TV in standby. `free` is names, for `announceAll`; `skipped` says which were left out and why.
109
+ * - A group stays a group while every connected member is free. An offline member doesn't split it: the group plays without it.
110
+ * - A group playing another app is skipped whole. Otherwise a group with a busy member, or one whose own socket is down,
111
+ * becomes its free members, named one by one (they then play out of step with each other).
112
+ * - A device is never named beside a free group that holds it, nor two groups that share a member: the second becomes its
113
+ * members not yet covered.
114
+ */
115
+ export declare function castFree(s: CastSnapshot, targets: string[]): {
116
+ free: string[];
117
+ skipped: CastSkipped[];
118
+ };
@@ -143,3 +143,125 @@ function link(s) {
143
143
  delete d.playingVia;
144
144
  return { devices, asOf: s.asOf };
145
145
  }
146
+ /** Google's own player for a URL of audio or video: what `announce` plays a clip with, and can put back. */
147
+ export const DEFAULT_MEDIA_RECEIVER = "CC1AD845";
148
+ /** Devices first, then groups, each by name. */
149
+ export const castDevices = (s) => Object.values(s.devices).sort((a, b) => a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name));
150
+ /** A target is a castId, else a name in any case. `announce` and `castFree` find it the same way. */
151
+ export const findCast = (devices, target) => {
152
+ const all = Object.values(devices);
153
+ return (all.find((x) => x.id === castKey(target)) ??
154
+ all.find((x) => x.name.toLowerCase() === target.toLowerCase()));
155
+ };
156
+ const RANK = { idle: 0, restorable: 1, busy: 2, offline: 3 };
157
+ /** By its own app alone: not its group's media, not its members. */
158
+ const ownState = (d) => d.status !== "connected" || d.idle === undefined
159
+ ? "offline"
160
+ : d.idle
161
+ ? "idle"
162
+ : d.appId === DEFAULT_MEDIA_RECEIVER
163
+ ? "restorable"
164
+ : "busy";
165
+ /** Every device and group in the snapshot as a `CastStatus`, devices first, each by name. */
166
+ export function castStatus(s) {
167
+ const one = (d) => {
168
+ const group = d.playingVia ? s.devices[d.playingVia] : undefined;
169
+ const own = ownState(d);
170
+ // Its group's media is playing on it: the group's app decides, and an unknown one counts as busy.
171
+ const self = own === "offline" || !d.playingVia
172
+ ? own
173
+ : group && ownState(group) === "restorable"
174
+ ? "restorable"
175
+ : "busy";
176
+ const members = d.kind === "group"
177
+ ? (d.members ?? []).flatMap((id) => s.devices[id] ? [one(s.devices[id])] : [])
178
+ : undefined;
179
+ const state = self === "offline"
180
+ ? self
181
+ : (members ?? [])
182
+ .map((m) => m.state)
183
+ .filter((m) => m !== "offline")
184
+ .reduce((a, b) => (RANK[b] > RANK[a] ? b : a), self);
185
+ const status = {
186
+ id: d.id,
187
+ name: d.name,
188
+ kind: d.kind,
189
+ state,
190
+ app: d.idle === false
191
+ ? d.app
192
+ : group?.idle === false
193
+ ? group.app
194
+ : undefined,
195
+ playerState: d.playerState,
196
+ title: d.title,
197
+ artist: d.artist,
198
+ via: d.playingVia && (group?.name ?? d.playingVia),
199
+ volume: d.volume,
200
+ muted: d.muted,
201
+ standby: d.standby,
202
+ members,
203
+ };
204
+ for (const k of Object.keys(status))
205
+ if (status[k] === undefined)
206
+ delete status[k];
207
+ return status;
208
+ };
209
+ return castDevices(s).map(one);
210
+ }
211
+ /**
212
+ * Of these targets (names in any case, or ids), the ones an announcement would not disturb: `idle` or `restorable`, and
213
+ * not a TV in standby. `free` is names, for `announceAll`; `skipped` says which were left out and why.
214
+ * - A group stays a group while every connected member is free. An offline member doesn't split it: the group plays without it.
215
+ * - A group playing another app is skipped whole. Otherwise a group with a busy member, or one whose own socket is down,
216
+ * becomes its free members, named one by one (they then play out of step with each other).
217
+ * - A device is never named beside a free group that holds it, nor two groups that share a member: the second becomes its
218
+ * members not yet covered.
219
+ */
220
+ export function castFree(s, targets) {
221
+ const status = new Map(castStatus(s).map((c) => [c.id, c]));
222
+ const free = new Map();
223
+ const covered = new Set(); // members of the groups in `free`
224
+ const skipped = new Map();
225
+ const ok = (c) => (c.state === "idle" || c.state === "restorable") && !c.standby;
226
+ const skip = (c) => skipped.set(c.id, {
227
+ name: c.name,
228
+ why: c.state === "offline"
229
+ ? "offline"
230
+ : c.standby && c.state !== "busy"
231
+ ? "standby"
232
+ : "busy",
233
+ ...(c.app ? { app: c.app } : {}),
234
+ });
235
+ const device = (c) => (ok(c) ? free.set(c.id, c) : skip(c));
236
+ for (const target of targets) {
237
+ const d = findCast(s.devices, target);
238
+ const c = d && status.get(d.id);
239
+ if (!d || !c)
240
+ skipped.set(target, { name: target, why: "unknown" });
241
+ else if (c.kind === "device")
242
+ device(c);
243
+ else {
244
+ const members = c.members ?? [];
245
+ const own = ownState(d);
246
+ const up = members.filter((m) => m.state !== "offline");
247
+ if (own === "busy" || (own === "offline" && !members.length))
248
+ skip(c);
249
+ else if (own !== "offline" &&
250
+ up.every(ok) &&
251
+ !members.some((m) => covered.has(m.id))) {
252
+ free.set(c.id, c);
253
+ for (const m of members)
254
+ covered.add(m.id);
255
+ }
256
+ else
257
+ for (const m of members)
258
+ device(m);
259
+ }
260
+ }
261
+ return {
262
+ free: [...free.values()]
263
+ .filter((c) => c.kind === "group" || !covered.has(c.id))
264
+ .map((c) => c.name),
265
+ skipped: [...skipped.values()],
266
+ };
267
+ }
@@ -1,7 +1,5 @@
1
1
  import type { Clock } from "../../clock.ts";
2
2
  import { type CastDevice } from "./state.ts";
3
- /** Google's own player for a URL of audio or video. */
4
- export declare const DEFAULT_MEDIA_RECEIVER = "CC1AD845";
5
3
  /** Each request (a status, a volume, LAUNCH, LOAD, STOP) gets this long to answer. */
6
4
  export declare const ANSWER_MS = 10000;
7
5
  /** How long an announcement waits for its clip to finish, beyond the clip's own length when the device reports it. */
@@ -2,11 +2,9 @@ import { errorText } from "../../connector.js";
2
2
  import { writeLane } from "../../lane.js";
3
3
  import { NS } from "./codec.js";
4
4
  import { MediaStatus, MultizoneStatus, ReceiverStatus, } from "./schemas.js";
5
- import { castKey } from "./state.js";
5
+ import { castKey, DEFAULT_MEDIA_RECEIVER, findCast, } from "./state.js";
6
6
  // Writes over the sockets `cast()` already holds: volume, mute, stop, and an announcement that plays a clip and puts
7
- // the device back as it was. Never called by the integration itself; tried on a scripted fake socket, not yet live.
8
- /** Google's own player for a URL of audio or video. */
9
- export const DEFAULT_MEDIA_RECEIVER = "CC1AD845";
7
+ // 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).
10
8
  /** Each request (a status, a volume, LAUNCH, LOAD, STOP) gets this long to answer. */
11
9
  export const ANSWER_MS = 10_000;
12
10
  /** How long an announcement waits for its clip to finish, beyond the clip's own length when the device reports it. */
@@ -83,8 +81,7 @@ export function castActions(ctx) {
83
81
  const { clock } = ctx;
84
82
  const find = (target) => {
85
83
  const all = Object.values(ctx.devices());
86
- const d = all.find((x) => x.id === castKey(target)) ??
87
- all.find((x) => x.name.toLowerCase() === target.toLowerCase());
84
+ const d = findCast(ctx.devices(), target);
88
85
  if (!d)
89
86
  throw new Error(`no Cast device or group "${target}"; have: ${all
90
87
  .map((x) => x.name)
@@ -244,7 +241,7 @@ export function castActions(ctx) {
244
241
  /**
245
242
  * 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
246
243
  * put back what it can, always: what the Default Media Receiver was playing is loaded again where it was, an idle
247
- * device gets STOP, then the volume (a group's, member by member through each member's own socket). Another app
244
+ * device gets STOP, then the volume (a group's own first, then member by member through each member's own socket). Another app
248
245
  * (Spotify, YouTube) is interrupted and cannot be resumed from here; the result says so. Throws only before anything changed.
249
246
  */
250
247
  async announce(target, clip, options = {}) {
@@ -360,6 +357,27 @@ export function castActions(ctx) {
360
357
  muted: before.volume?.muted,
361
358
  },
362
359
  ];
360
+ // A group keeps a level of its own, which its members' levels don't bring back (live, 2026-11-10: it stayed at the
361
+ // announcement's 30 with every member restored). First, so whatever it does to the members, their own levels land last.
362
+ if (d.kind === "group" && options.volume !== undefined) {
363
+ const level = before.volume?.level;
364
+ if (level === undefined)
365
+ result.notRestored.push({
366
+ name: d.name,
367
+ why: "its volume before was not reported",
368
+ });
369
+ else
370
+ try {
371
+ await receiver(s, { type: "SET_VOLUME", volume: { level } });
372
+ result.restored.push(`${d.name}: volume`);
373
+ }
374
+ catch (error) {
375
+ result.notRestored.push({
376
+ name: d.name,
377
+ why: `volume not restored: ${errorText(error)}`,
378
+ });
379
+ }
380
+ }
363
381
  for (const m of each) {
364
382
  const ms = m.id === d.id ? s : ctx.session(m.id);
365
383
  if (m.level === undefined)
@@ -1,9 +1,9 @@
1
1
  import { type Clock } from "../../clock.ts";
2
2
  import { z } from "zod";
3
- import type { Integration } from "../../home.ts";
3
+ import { type DeviceRecord, type Integration, type Part } from "../../home.ts";
4
4
  import { type Finder } from "../../discover.ts";
5
5
  import type { Manifest } from "../../manifest.ts";
6
- import { daikinClient, type DaikinState } from "./client.ts";
6
+ import { daikinClient, type DaikinState, type Mode } from "./client.ts";
7
7
  export { DaikinError, type DaikinState, daikinClient, type Fan, type Mode, setFan, setMode, setPower, setTarget, type WriteRequest, } from "./client.ts";
8
8
  /** Poll every 120 s, as Home Assistant's daikin integration does (its coordinator's update_interval). */
9
9
  export declare const POLL_MS = 120000;
@@ -39,3 +39,7 @@ export declare function daikin(config: DaikinConfig): Integration<DaikinSnapshot
39
39
  /** The AC in a few words: "off · cool 25 °C · room 24 °C · outdoor unit 19.5 °C". */
40
40
  export declare function acText(s: DaikinState): string;
41
41
  export declare const manifest: Manifest<DaikinSnapshot>;
42
+ /** The AC as a home device: `state` is `off`, its mode while on, or `on` when it hasn't said which; `detail` its snapshot. */
43
+ export type DaikinHomeDevice = DeviceRecord<"daikin", "ac", "off" | "on" | Mode, DaikinSnapshot>;
44
+ /** 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. */
45
+ export declare function daikinHomeDevices(part: Part<DaikinSnapshot> | undefined): DaikinHomeDevice[];
@@ -1,8 +1,9 @@
1
1
  import { asOf, realClock } from "../../clock.js";
2
2
  import { z } from "zod";
3
+ import { deviceRecords, } from "../../home.js";
3
4
  import { lostDetail, relocator } from "../../discover.js";
4
5
  import { poller } from "../../poll.js";
5
- import { daikinClient, DaikinError } from "./client.js";
6
+ import { daikinClient, DaikinError, } from "./client.js";
6
7
  export { DaikinError, daikinClient, setFan, setMode, setPower, setTarget, } from "./client.js";
7
8
  /** Poll every 120 s, as Home Assistant's daikin integration does (its coordinator's update_interval). */
8
9
  export const POLL_MS = 120_000;
@@ -106,3 +107,22 @@ export const manifest = {
106
107
  ],
107
108
  lines: ({ state }) => (state ? [acText(state)] : []),
108
109
  };
110
+ /** 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. */
111
+ export function daikinHomeDevices(part) {
112
+ const s = part?.state;
113
+ if (!s)
114
+ return [];
115
+ return deviceRecords(part, [
116
+ {
117
+ connector: "daikin",
118
+ id: "daikin",
119
+ name: manifest.title,
120
+ kind: "ac",
121
+ available: true,
122
+ ...(s.on === undefined ? {} : { state: s.on ? (s.mode ?? "on") : "off" }),
123
+ asOf: s.asOf,
124
+ text: `${manifest.title}: ${acText(s)}`,
125
+ detail: s,
126
+ },
127
+ ]);
128
+ }
@@ -1,6 +1,6 @@
1
1
  import { type Clock } from "../../clock.ts";
2
2
  import { z } from "zod";
3
- import { type Integration } from "../../home.ts";
3
+ import { type DeviceRecord, type Integration, type Part } from "../../home.ts";
4
4
  import { type Finder } from "../../discover.ts";
5
5
  import { type Manifest } from "../../manifest.ts";
6
6
  import { type EnphaseState } from "./client.ts";
@@ -41,3 +41,7 @@ export declare function enphase(config: EnphaseConfig): Integration<EnphaseSnaps
41
41
  /** "solar 2.1 kW, using 0.6 kW · token 300 days left". The login ask near the end is the integration's, in `detail`. */
42
42
  export declare function enphaseText(s: EnphaseSnapshot): string;
43
43
  export declare const manifest: Manifest<EnphaseSnapshot>;
44
+ /** The gateway as a home device: `state` is `producing` while the solar makes power, else `idle`; `detail` its snapshot. */
45
+ export type EnphaseHomeDevice = DeviceRecord<"enphase", "solar", "producing" | "idle", EnphaseSnapshot>;
46
+ /** The one gateway, once it has been read. */
47
+ export declare function enphaseHomeDevices(part: Part<EnphaseSnapshot> | undefined): EnphaseHomeDevice[];
@@ -1,6 +1,6 @@
1
1
  import { localDay, realClock } from "../../clock.js";
2
2
  import { z } from "zod";
3
- import { filePath } from "../../home.js";
3
+ import { deviceRecords, filePath, } from "../../home.js";
4
4
  import { lostDetail, relocator } from "../../discover.js";
5
5
  import { kw } from "../../manifest.js";
6
6
  import { poller } from "../../poll.js";
@@ -51,7 +51,17 @@ export function enphase(config) {
51
51
  name: "enphase",
52
52
  refresh: () => (where.reset(), poll.nudge()),
53
53
  async run(signal, update) {
54
- const expiresAt = tokenExpiry(config.token); // a bad token fails this integration only
54
+ let expiresAt;
55
+ try {
56
+ expiresAt = tokenExpiry(config.token); // a bad token fails this integration only
57
+ }
58
+ catch {
59
+ return update({
60
+ status: "unavailable",
61
+ reason: "auth",
62
+ detail: "the token is unreadable; run `home enphase login`",
63
+ });
64
+ }
55
65
  let last;
56
66
  let day;
57
67
  let moved; // kept in `detail` so a consumer can persist the new address
@@ -106,6 +116,7 @@ export function enphase(config) {
106
116
  if (error instanceof EnphaseError && error.status === 401)
107
117
  return update({
108
118
  status: "unavailable",
119
+ reason: "auth",
109
120
  detail: "the gateway rejected the token; run `home enphase login`",
110
121
  state: last,
111
122
  });
@@ -172,3 +183,24 @@ export const manifest = {
172
183
  files: ["token"],
173
184
  lines: ({ state }) => (state ? [enphaseText(state)] : []),
174
185
  };
186
+ /** The one gateway, once it has been read. */
187
+ export function enphaseHomeDevices(part) {
188
+ const s = part?.state;
189
+ if (!s)
190
+ return [];
191
+ return deviceRecords(part, [
192
+ {
193
+ connector: "enphase",
194
+ id: "enphase",
195
+ name: manifest.title,
196
+ kind: "solar",
197
+ available: true,
198
+ ...(s.productionW === undefined
199
+ ? {}
200
+ : { state: s.productionW > 0 ? "producing" : "idle" }),
201
+ asOf: s.asOf,
202
+ text: `${manifest.title}: ${enphaseText(s)}`,
203
+ detail: s,
204
+ },
205
+ ]);
206
+ }
@@ -1,10 +1,10 @@
1
1
  import { type Clock } from "../../clock.ts";
2
2
  import { z } from "zod";
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
6
  import { type HueClient } from "./client.ts";
7
- import { type HueSnapshot } from "./state.ts";
7
+ import { type GroupView, type HueSnapshot, type LightView } from "./state.ts";
8
8
  export { bunPinnedFetch, type HueClient, HueError, type HueOptions, hueClient, pair, } from "./client.ts";
9
9
  export type { HueEvent, Resource } from "./schemas.ts";
10
10
  export { type GroupView, groups, type HueSnapshot, homeGroupedLightId, type LightView, lights, scenes, } from "./state.ts";
@@ -39,4 +39,8 @@ export type HueConfig = {
39
39
  export declare function hue(config: HueConfig): Integration<HueSnapshot, "hue"> & {
40
40
  readonly client: HueClient;
41
41
  };
42
+ /** A Hue light, or a room or zone, as a home device: `detail` is its `LightView` or `GroupView`. */
43
+ export type HueHomeDevice = DeviceRecord<"hue", "light", "on" | "off", LightView> | DeviceRecord<"hue", "group", "on" | "off", GroupView>;
44
+ /** Every light, then every room and zone. A light's `available` is its Zigbee link; a group's `members` are its lights. */
45
+ export declare function hueHomeDevices(part: Part<HueSnapshot> | undefined): HueHomeDevice[];
42
46
  export declare const manifest: Manifest<HueSnapshot>;