@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
package/.env.example CHANGED
@@ -4,8 +4,8 @@
4
4
  HUE_BRIDGE_HOST=hue-bridge.local # optional: found by mDNS (dns-sd) when unset
5
5
  HUE_BRIDGE_ID=0017880000000000 # optional: the certificate's CN must match it; mDNS finds it when unset
6
6
  HUE_APPLICATION_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx # from `home pair`
7
- HUE_CA_FILE=/path/to/hue-root-ca.pem # until HUE_BRIDGE_ROOT_CA ships in src/connectors/hue/
8
- DAIKIN_HOST=192.0.2.20 # the unit's address; `home probe` finds it by UDP broadcast
7
+ HUE_CA_FILE=/path/to/hue-root-ca.pem # optional: a root CA to trust instead of Signify's two, which the package carries
8
+ DAIKIN_HOST=192.0.2.20 # optional: the unit's address; found by UDP broadcast when unset (1.5 s a run)
9
9
  IQ_GATEWAY_HOST=envoy.local # the IQ Gateway on the LAN (a scheme is tolerated)
10
10
  IQ_GATEWAY_SERIAL_NUMBER=000000000000 # optional: for `home enphase login`; from the Enphase app: System > Devices > Gateway
11
11
  ENPHASE_USERNAME=you@example.com # optional: for `home enphase login`, once a year: the Enphase account
package/CHANGELOG.md CHANGED
@@ -1,9 +1,80 @@
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.2.0] - 2026-11-12
8
+
9
+ ### Breaking
10
+
11
+ - **spotify: music on another Spotify device no longer reads as this speaker playing.** When Soloist says `playing` (or `buffering`) while it isn't the account's active device, `speakerState` now gives `elsewhere` (it gave `playing`), `spotifyText` says `silent · "Song" by Band is playing on another Spotify device`, and `spotifyChanges` treats it as silence: music moving off this speaker is `stopped`, music moving onto it is `started` then `track`, and nothing fires for its tracks or pauses meanwhile (it fired `resumed` with the box silent). `home devices --json` carries the new `state`. What to do: add `elsewhere` wherever you switch on `speakerState`; `state.status` is still what Soloist said.
12
+ - **cli: `home scene <name>` no longer takes the first match** when several rooms have a scene by that name: it lists them and exits 2, and `home scene <name> in <room>` picks one. A wrong or shared room or scene name is a usage error (exit 2), where it was exit 1. What to do: a script that recalls a shared scene name adds `in <room>`.
13
+
14
+ ### Fixed
15
+
16
+ - **spotify:** the stall watch no longer looks at, or repairs, music playing on another device.
17
+
18
+ ### Changed
19
+
20
+ - **cli:** `home room <name> <n>` and `home scene` print the room's line after the write, where they printed nothing.
21
+ - **hue:** a light's line shows its white temperature or colour when on (`Desk: on · 40% · 2700 K`), in `home hue`, `home devices` and `homeDevices`.
22
+ - **docs, for a consumer:** `docs/using.md` has each write and what it resolves with in one table, an announce-where-free example, and what a part's `status` and `reason` and an error's `code` mean. `docs/config.md`'s `loadHome` call now passes what Cast needs (`finders`, `castConnect`). `llms.txt` says what to call.
23
+ - **docs:** `castStatus` and `castFree` take the Cast part's state (`home.part("cast")?.state`), which the docs called "the snapshot".
24
+ - **cli:** `home` and `home daikin` find the Daikin by UDP broadcast when `DAIKIN_HOST` is unset, as the write commands do, so `DAIKIN_HOST` is optional. With no unit on the network it reads "not set up".
25
+ - **cli:** `home cast volume | mute | stop` print the device's line once it reports the change, where they printed nothing; after 2 s without it, "sent; the device has not reported it yet".
26
+ - **cli:** "no route to host" on macOS now adds that the terminal may need the Local Network permission.
27
+
28
+ ### Added
29
+
30
+ - **Dry run on Hue's and the Daikin's writes**: `{ dryRun: true }` as the last argument of `light`, `room`, `scene`, `allOff`, `power` and `mode`. The read and every check made before sending run, and no write is sent; the result's `wouldSend` is the request it would make (`HueRequest`, `DaikinRequest` with `sets`), `state` how things stand, `confirmed: false`. `--dry-run` on the same commands (`home light | room | scene | lights off`, `home hue …`, `home daikin …`); every other command refuses the flag.
31
+ - **hue: `client.allOff()`**: every light in the home off with one write, resolving `{ state, confirmed }` with every light as the bridge then reported it. `home lights off` uses it and now prints the result.
32
+ - **`byRoom(snapshot, config)`** from `@khalido/home`, for a map of the home: each room of the config with its devices, `noData` for a connector the config places there that has nothing to list, and `unplaced` for the rest.
33
+ - **config: `photos` on a room**, a list of paths. The library carries them and reads nothing.
34
+ - **cast: `castFreeSpeakers(state)`**: every free speaker and display in one call, a whole group where all of it is free, never a TV; `free` goes straight to `announceAll`.
35
+ - **cast: `castFree(state, targets, { forms })`**: with `forms: ["speaker", "display"]` a TV is left out (`why: "form"`, only when `forms` is given), and a group holding one becomes its other free members.
36
+ - **`home.add(integration)` and `home.remove(name)`** on the home `createHome` returns: add one while the rest keep running (a bridge paired just now), replace one by name (the old run has ended before the new one starts; how an `unavailable` part comes back after a re-key), or stop one and drop its part. `start` now also waits for those added.
37
+ - **spotify: `playingElsewhere(state)`**, and **`spotifyElsewhere(snapshot)`** from `@khalido/home`: the names of the Cast devices and groups playing that track in Spotify, matched by the track's name since Soloist doesn't say where the music is. Empty for a phone or a laptop. `homeDevices` and `home devices` put it in the speaker's line (`silent · "Song" is playing on Front room speaker`), and `spotifyText(state, on)` takes it. Tested on built snapshots only.
38
+ - **hue: lights, rooms and scenes by name.** `client.light(name, change)`, `client.room(name, change)` and `client.scene(name, room?)`, each resolving `{ state, confirmed }` (`HueApplied`). `change` (`LightChange`) is `{ on?, brightness?, color?, kelvin?, transitionMs? }`: a colour as hex or `{ r, g, b }`, sent as `xy` inside the light's own gamut; a white temperature in kelvin, checked against the light's range. An unknown name or a value the light can't take throws `HueError` with `code: "invalid"` before anything is sent, and a light the bridge has lost throws with `code: "offline"`.
39
+ - **hue: colour in the state.** Each light's `kelvin` and `kelvinRange`, and on a colour light its `xy`, `hex` and `gamut`. `colorToXy`, `xyToHex`, `kelvinToMirek`, `mirekToKelvin` and `hueText` are exported.
40
+ - **`home light <name> …`**, and `home room` and `home scene` now take a brightness, a temperature (`2700k`) and a colour (`ff8800`) together, name a scene's room (`in <room>`), and print what the bridge then reported.
41
+ - **hue: Signify's root certificates are in the package**, as `HUE_BRIDGE_ROOT_CA` from `@khalido/home/hue`: both roots, the bundle from Signify's "Using HTTPS" page. `ca` is now optional everywhere it was required: `hue({ host, bridgeId, key })`, `bunPinnedFetch(bridgeId)`, `nodePinnedFetch(bridgeId)`, the config's `ca` and the CLI's `HUE_CA_FILE`. One given still replaces the bundle. `home probe` reads the bridge without setup, and `home discover --write` no longer writes a `ca` line.
42
+ - **hue: `pairWhenPressed`**: `pair`, asked every 2 s for 30 s until the bridge's button is pressed, with `onWaiting` for the moment to prompt. `pair` takes a `signal`. `home pair` now waits 60 s for the button, counting down, and says when no press was detected.
43
+ - **daikin: `client.power(on)` and `client.mode(mode, targetC?)`**, resolving with `{ state, confirmed }` (`DaikinApplied`) once the unit reports what was asked, or after 5 s (`CONFIRM_MS`) without it. `mode` sends the mode, power on and the setpoint as one request: the one given, else the library's defaults, `DEFAULT_TARGET_C` (cool 25, heat 27).
44
+ - **`home daikin on | off`** (power only, as it was last left) and **`home daikin cool | heat | auto [°C] | dry | fan`**, each printing what the unit then reports.
45
+ - **`MissingTool`** from `@khalido/home/finders`: thrown when the discovery tool (`dns-sd` on macOS, `avahi-browse` on Linux) isn't installed.
46
+ - **`home devices`:** every device the connectors read, one line each from `homeDevices`: its state, its room and name from the config when there is one, and why when it isn't available. `--json` prints the records. A connector that isn't connected is named on stderr.
47
+
48
+ ## [0.1.3] - 2026-11-10
49
+
50
+ ### Added
51
+
52
+ - **`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`.
53
+ - **`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`.
54
+ - **`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.
55
+ - **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.
56
+ - **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`.
57
+
58
+ ### Changed
59
+
60
+ - **`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.
61
+ - **hue:** a room or zone the bridge gives no on or off for prints its name alone in `home hue`, no longer "off".
62
+ - **weather:** Google's key is sent in the `X-Goog-Api-Key` header, no longer in the URL.
63
+ - **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)`.
64
+
65
+ ### Fixed
66
+
67
+ - **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.
68
+ - **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.
69
+ - **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.
70
+ - **`createHome`:** an `onChange` that throws or rejects no longer marks the integration `unavailable`; the error goes to `console.error` and the integration keeps running.
71
+ - **`createHome`:** `refresh()` before `start()` resolves at once instead of never.
72
+ - **`createHome`:** an integration whose `run` fails after a good report keeps its last `state`.
73
+ - **`createHome`:** a `run` that throws synchronously marks that integration `unavailable`; the others still start.
74
+ - **`home <connector> --config`** needs only that connector's keys, and runs only that connector.
75
+ - **`realClock.sleep`** resolves at once on an already-aborted signal.
76
+ - **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.
77
+
7
78
  ## [0.1.2] - 2026-11-09
8
79
 
9
80
  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 +139,8 @@ First release. Nine connectors: Hue, Daikin, Enphase, Google Cast, AirPrint prin
68
139
  - **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
140
  - **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
141
 
71
- [Unreleased]: https://github.com/khalido/home.ts/compare/v0.1.2...HEAD
142
+ [Unreleased]: https://github.com/khalido/home.ts/compare/v0.1.3...HEAD
143
+ [0.1.3]: https://github.com/khalido/home.ts/compare/v0.1.2...v0.1.3
72
144
  [0.1.2]: https://github.com/khalido/home.ts/compare/v0.1.1...v0.1.2
73
145
  [0.1.1]: https://github.com/khalido/home.ts/compare/v0.1.0...v0.1.1
74
146
  [0.1.0]: https://github.com/khalido/home.ts/releases/tag/v0.1.0
package/README.md CHANGED
@@ -13,7 +13,9 @@ 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 on your `PATH`; in a project that added the package, run it as `bunx home`.
17
+
18
+ Pointing a coding agent at it? Give it [llms.txt](llms.txt): what to call and where each doc is. [Using it from an app](docs/using.md) is the same for people.
17
19
 
18
20
  ## Try it
19
21
 
@@ -26,6 +28,7 @@ bun link # puts `home` on your PATH
26
28
  home discover # what answers on this network, and which of it this package supports
27
29
  home probe # read-only checks, no keys
28
30
  home # each connector: connected, or what it still needs
31
+ home devices # every device, one line each
29
32
  ```
30
33
 
31
34
  Copy [.env.example](.env.example) to `.env` and fill in the connectors you have, or describe the home in a [config file](docs/config.md) ([example](examples/home.jsonc)).
@@ -66,9 +69,11 @@ Each connector's README says what has run against a real device and what has not
66
69
 
67
70
  ## More
68
71
 
72
+ - [Using it from an app](docs/using.md): the wiring, each write and what it returns
69
73
  - [The `home` command](docs/cli.md) and [the home config](docs/config.md)
74
+ - [Hue](docs/hue.md): Signify's guidance on certificates, pairing and write rates
70
75
  - [Why it is shaped this way](docs/philosophy.md) and [decisions](docs/decisions.md)
71
- - To add a connector, follow [AGENTS.md](AGENTS.md)
76
+ - To add a connector, follow [AGENTS.md](https://github.com/khalido/home.ts/blob/main/AGENTS.md)
72
77
  - For agents: [llms.txt](llms.txt)
73
78
 
74
79
  I use it from jaldi, a Mac launcher, and chota-bot, a family bot on a Raspberry Pi, both on Bun; the docs name both.
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());
package/dist/config.d.ts CHANGED
@@ -16,6 +16,7 @@ export declare const Room: z.ZodObject<{
16
16
  name: z.ZodString;
17
17
  floor: z.ZodOptional<z.ZodString>;
18
18
  hueRoom: z.ZodOptional<z.ZodString>;
19
+ photos: z.ZodOptional<z.ZodArray<z.ZodString>>;
19
20
  }, z.core.$loose>;
20
21
  /** JSONC (comments and trailing commas) through jsonc-parser, which runs on Node too; a syntax error throws rather than parsing to part of the file. */
21
22
  export declare function parseJsonc(text: string): unknown;
@@ -25,7 +26,7 @@ export declare const Device: z.ZodDiscriminatedUnion<[z.ZodObject<{
25
26
  host: z.ZodString;
26
27
  bridgeId: z.ZodString;
27
28
  key: z.ZodString;
28
- ca: z.ZodString;
29
+ ca: z.ZodOptional<z.ZodString>;
29
30
  id: z.ZodString;
30
31
  room: z.ZodOptional<z.ZodString>;
31
32
  name: z.ZodOptional<z.ZodString>;
@@ -123,13 +124,14 @@ export declare const HomeConfig: z.ZodObject<{
123
124
  name: z.ZodString;
124
125
  floor: z.ZodOptional<z.ZodString>;
125
126
  hueRoom: z.ZodOptional<z.ZodString>;
127
+ photos: z.ZodOptional<z.ZodArray<z.ZodString>>;
126
128
  }, z.core.$loose>>>;
127
129
  devices: z.ZodOptional<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
128
130
  connector: z.ZodLiteral<"hue">;
129
131
  host: z.ZodString;
130
132
  bridgeId: z.ZodString;
131
133
  key: z.ZodString;
132
- ca: z.ZodString;
134
+ ca: z.ZodOptional<z.ZodString>;
133
135
  id: z.ZodString;
134
136
  room: z.ZodOptional<z.ZodString>;
135
137
  name: z.ZodOptional<z.ZodString>;
@@ -246,7 +248,7 @@ export type LoadOptions = {
246
248
  fetch?: typeof fetch;
247
249
  /** Hue's pinned fetch and Enphase's unverified one: Bun's by default, `NODE_TLS` from `@khalido/home/node` under Node. */
248
250
  tls?: {
249
- hue(bridgeId: string, ca: string): typeof fetch;
251
+ hue(bridgeId: string, ca?: string): typeof fetch;
250
252
  enphase(): typeof fetch;
251
253
  };
252
254
  /** BOM's transport: `ftpFetch()` from `@khalido/home/weather/ftp`. Without it a listed `bom` source stops and says why. */
package/dist/config.js CHANGED
@@ -25,6 +25,10 @@ export const Room = z.looseObject({
25
25
  .string()
26
26
  .optional()
27
27
  .describe("the Hue room, zone or grouped_light id whose lights this room's marker shows"),
28
+ photos: z
29
+ .array(filePath)
30
+ .optional()
31
+ .describe("photos of the room, as paths relative to the config or from ~/; the library never reads them, a drawing agent or an app does"),
28
32
  });
29
33
  /** JSONC (comments and trailing commas) through jsonc-parser, which runs on Node too; a syntax error throws rather than parsing to part of the file. */
30
34
  export function parseJsonc(text) {
@@ -266,7 +270,7 @@ export function loadHome(input, options) {
266
270
  function one(d, at) {
267
271
  switch (d.connector) {
268
272
  case "hue": {
269
- const ca = file(d.ca);
273
+ const ca = d.ca === undefined ? undefined : file(d.ca);
270
274
  return hue({
271
275
  ...start("hue", d.bridgeId, d.host),
272
276
  bridgeId: d.bridgeId,
@@ -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, castFreeSpeakers, 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, castFreeSpeakers, 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,69 @@ 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; `form` is one whose form wasn't asked for. */
101
+ export type CastSkipped = {
102
+ name: string;
103
+ why: "busy" | "offline" | "standby" | "unknown" | "form";
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
+ * - With `forms` (`["speaker", "display"]` keeps an announcement off TVs), a device of another form, or of a model this
115
+ * library doesn't know, is skipped with `why: "form"`, and a group holding one (or a member the snapshot doesn't have)
116
+ * becomes its other free members. An empty list leaves every device out.
117
+ */
118
+ export declare function castFree(s: CastSnapshot, targets: string[], options?: {
119
+ forms?: readonly CastForm[];
120
+ }): {
121
+ free: string[];
122
+ skipped: CastSkipped[];
123
+ };
124
+ /**
125
+ * Where an announcement can play right now, in one call: every speaker and display that is free, a whole group where all
126
+ * of it is, and never a TV. `castFree` over everything Cast has found, with `forms: ["speaker", "display"]`; `free` goes
127
+ * straight to `announceAll`.
128
+ */
129
+ export declare const castFreeSpeakers: (s: CastSnapshot) => {
130
+ free: string[];
131
+ skipped: CastSkipped[];
132
+ };
@@ -143,3 +143,144 @@ 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
+ * - With `forms` (`["speaker", "display"]` keeps an announcement off TVs), a device of another form, or of a model this
220
+ * library doesn't know, is skipped with `why: "form"`, and a group holding one (or a member the snapshot doesn't have)
221
+ * becomes its other free members. An empty list leaves every device out.
222
+ */
223
+ export function castFree(s, targets, options = {}) {
224
+ /** A device of a form asked for. A group has no form of its own: its members decide. */
225
+ const wanted = (id) => {
226
+ const d = s.devices[id];
227
+ if (!options.forms || d?.kind === "group")
228
+ return true;
229
+ return d?.form !== undefined && options.forms.includes(d.form);
230
+ };
231
+ const status = new Map(castStatus(s).map((c) => [c.id, c]));
232
+ const free = new Map();
233
+ const covered = new Set(); // members of the groups in `free`
234
+ const skipped = new Map();
235
+ const ok = (c) => (c.state === "idle" || c.state === "restorable") && !c.standby;
236
+ const skip = (c) => skipped.set(c.id, {
237
+ name: c.name,
238
+ why: !wanted(c.id)
239
+ ? "form"
240
+ : c.state === "offline"
241
+ ? "offline"
242
+ : c.standby && c.state !== "busy"
243
+ ? "standby"
244
+ : "busy",
245
+ ...(c.app ? { app: c.app } : {}),
246
+ });
247
+ const device = (c) => ok(c) && wanted(c.id) ? free.set(c.id, c) : skip(c);
248
+ for (const target of targets) {
249
+ const d = findCast(s.devices, target);
250
+ const c = d && status.get(d.id);
251
+ if (!d || !c)
252
+ skipped.set(target, { name: target, why: "unknown" });
253
+ else if (c.kind === "device")
254
+ device(c);
255
+ else {
256
+ const members = c.members ?? [];
257
+ const own = ownState(d);
258
+ const up = members.filter((m) => m.state !== "offline");
259
+ if (own === "busy" || (own === "offline" && !members.length))
260
+ skip(c);
261
+ else if (own !== "offline" &&
262
+ up.every(ok) &&
263
+ (d.members ?? []).every(wanted) &&
264
+ !members.some((m) => covered.has(m.id))) {
265
+ free.set(c.id, c);
266
+ for (const m of members)
267
+ covered.add(m.id);
268
+ }
269
+ else
270
+ for (const m of members)
271
+ device(m);
272
+ }
273
+ }
274
+ return {
275
+ free: [...free.values()]
276
+ .filter((c) => c.kind === "group" || !covered.has(c.id))
277
+ .map((c) => c.name),
278
+ skipped: [...skipped.values()],
279
+ };
280
+ }
281
+ /**
282
+ * Where an announcement can play right now, in one call: every speaker and display that is free, a whole group where all
283
+ * of it is, and never a TV. `castFree` over everything Cast has found, with `forms: ["speaker", "display"]`; `free` goes
284
+ * straight to `announceAll`.
285
+ */
286
+ export const castFreeSpeakers = (s) => castFree(s, castDevices(s).map((d) => d.id), { forms: ["speaker", "display"] });
@@ -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)