@khalido/home 0.1.3 → 0.2.1

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 (129) hide show
  1. package/.env.example +2 -2
  2. package/CHANGELOG.md +56 -1
  3. package/README.md +7 -2
  4. package/dist/config.d.ts +5 -3
  5. package/dist/config.js +5 -1
  6. package/dist/connector.d.ts +2 -0
  7. package/dist/connector.js +3 -0
  8. package/dist/connectors/amber/client.d.ts +1 -1
  9. package/dist/connectors/amber/client.js +1 -1
  10. package/dist/connectors/amber/index.js +2 -2
  11. package/dist/connectors/cast/index.d.ts +1 -1
  12. package/dist/connectors/cast/index.js +1 -1
  13. package/dist/connectors/cast/state.d.ts +17 -3
  14. package/dist/connectors/cast/state.js +26 -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 +2 -2
  18. package/dist/connectors/daikin/index.js +4 -3
  19. package/dist/connectors/enphase/client.d.ts +1 -1
  20. package/dist/connectors/enphase/client.js +1 -1
  21. package/dist/connectors/enphase/index.js +2 -1
  22. package/dist/connectors/hue/ca.d.ts +2 -0
  23. package/dist/connectors/hue/ca.js +38 -0
  24. package/dist/connectors/hue/client.d.ts +81 -6
  25. package/dist/connectors/hue/client.js +217 -6
  26. package/dist/connectors/hue/color.d.ts +30 -0
  27. package/dist/connectors/hue/color.js +92 -0
  28. package/dist/connectors/hue/events.d.ts +0 -2
  29. package/dist/connectors/hue/events.js +1 -2
  30. package/dist/connectors/hue/index.d.ts +15 -4
  31. package/dist/connectors/hue/index.js +27 -10
  32. package/dist/connectors/hue/schemas.d.ts +27 -0
  33. package/dist/connectors/hue/schemas.js +26 -0
  34. package/dist/connectors/hue/state.d.ts +10 -1
  35. package/dist/connectors/hue/state.js +20 -1
  36. package/dist/connectors/printers/client.d.ts +41 -6
  37. package/dist/connectors/printers/client.js +81 -8
  38. package/dist/connectors/printers/codec.d.ts +17 -3
  39. package/dist/connectors/printers/codec.js +32 -8
  40. package/dist/connectors/printers/index.d.ts +2 -0
  41. package/dist/connectors/printers/index.js +4 -0
  42. package/dist/connectors/printers/raster.d.ts +30 -0
  43. package/dist/connectors/printers/raster.js +175 -0
  44. package/dist/connectors/printers/state.d.ts +2 -0
  45. package/dist/connectors/spotify/changes.d.ts +19 -5
  46. package/dist/connectors/spotify/changes.js +29 -7
  47. package/dist/connectors/spotify/index.d.ts +1 -1
  48. package/dist/connectors/spotify/index.js +20 -3
  49. package/dist/connectors/up/client.d.ts +1 -1
  50. package/dist/connectors/up/client.js +1 -1
  51. package/dist/connectors/up/index.js +2 -2
  52. package/dist/connectors/weather/google.js +2 -2
  53. package/dist/devices.d.ts +24 -0
  54. package/dist/devices.js +56 -2
  55. package/dist/discover.js +1 -1
  56. package/dist/finders-entry.d.ts +1 -1
  57. package/dist/finders-entry.js +1 -1
  58. package/dist/finders.d.ts +2 -1
  59. package/dist/finders.js +16 -5
  60. package/dist/home.d.ts +11 -2
  61. package/dist/home.js +98 -34
  62. package/dist/index.d.ts +1 -1
  63. package/dist/index.js +1 -1
  64. package/dist/node-fetch.d.ts +2 -2
  65. package/dist/node-fetch.js +3 -2
  66. package/docs/cli.md +15 -7
  67. package/docs/config.md +11 -3
  68. package/docs/decisions.md +19 -0
  69. package/docs/hue.md +161 -0
  70. package/docs/ideas.md +2 -0
  71. package/docs/using.md +98 -3
  72. package/examples/home.jsonc +2 -1
  73. package/home.schema.json +10 -3
  74. package/llms.txt +19 -6
  75. package/package.json +1 -1
  76. package/skills/draw-home/README.md +2 -0
  77. package/skills/draw-home/SKILL.md +1 -1
  78. package/skills/use-home/SKILL.md +3 -3
  79. package/src/cli/cast.ts +51 -5
  80. package/src/cli/context.ts +2 -0
  81. package/src/cli/daikin.ts +71 -0
  82. package/src/cli/enphase.ts +31 -5
  83. package/src/cli/hue.ts +147 -59
  84. package/src/cli/main.ts +43 -13
  85. package/src/cli/printers.ts +254 -3
  86. package/src/cli/probe.ts +21 -17
  87. package/src/cli/status.ts +61 -10
  88. package/src/config.ts +8 -2
  89. package/src/connector.ts +5 -0
  90. package/src/connectors/amber/README.md +1 -1
  91. package/src/connectors/amber/client.ts +1 -1
  92. package/src/connectors/amber/index.ts +2 -2
  93. package/src/connectors/cast/README.md +6 -3
  94. package/src/connectors/cast/index.ts +7 -1
  95. package/src/connectors/cast/state.ts +32 -5
  96. package/src/connectors/daikin/README.md +16 -4
  97. package/src/connectors/daikin/client.ts +104 -3
  98. package/src/connectors/daikin/index.ts +8 -2
  99. package/src/connectors/enphase/README.md +3 -1
  100. package/src/connectors/enphase/client.ts +1 -1
  101. package/src/connectors/enphase/index.ts +2 -1
  102. package/src/connectors/hue/README.md +62 -17
  103. package/src/connectors/hue/ca.ts +39 -0
  104. package/src/connectors/hue/client.ts +362 -15
  105. package/src/connectors/hue/color.ts +123 -0
  106. package/src/connectors/hue/events.ts +1 -4
  107. package/src/connectors/hue/index.ts +46 -13
  108. package/src/connectors/hue/schemas.ts +26 -0
  109. package/src/connectors/hue/state.ts +27 -1
  110. package/src/connectors/printers/README.md +9 -4
  111. package/src/connectors/printers/client.ts +186 -12
  112. package/src/connectors/printers/codec.ts +44 -10
  113. package/src/connectors/printers/index.ts +17 -0
  114. package/src/connectors/printers/raster.ts +215 -0
  115. package/src/connectors/printers/state.ts +2 -0
  116. package/src/connectors/spotify/README.md +10 -2
  117. package/src/connectors/spotify/changes.ts +38 -9
  118. package/src/connectors/spotify/index.ts +21 -2
  119. package/src/connectors/up/README.md +1 -1
  120. package/src/connectors/up/client.ts +1 -1
  121. package/src/connectors/up/index.ts +2 -2
  122. package/src/connectors/weather/google.ts +6 -2
  123. package/src/devices.ts +83 -3
  124. package/src/discover.ts +1 -2
  125. package/src/finders-entry.ts +1 -1
  126. package/src/finders.ts +18 -6
  127. package/src/home.ts +99 -39
  128. package/src/index.ts +7 -1
  129. package/src/node-fetch.ts +6 -2
@@ -1,4 +1,5 @@
1
1
  import { type Clock, localDay, realClock } from "../../clock.ts";
2
+ import { isRejectedKey } from "../../connector.ts";
2
3
  import { z } from "zod";
3
4
  import {
4
5
  type DeviceRecord,
@@ -180,7 +181,7 @@ export function enphase(
180
181
  });
181
182
  } catch (error) {
182
183
  if (signal.aborted) return; // stopped mid-read: not a failure
183
- if (error instanceof EnphaseError && error.status === 401)
184
+ if (isRejectedKey(error))
184
185
  return update({
185
186
  status: "unavailable",
186
187
  reason: "auth",
@@ -4,26 +4,45 @@ Philips Hue over the bridge's local CLIP v2 API: HTTPS on the LAN, one key, one
4
4
 
5
5
  ## Official docs
6
6
 
7
- Both pages need a free Hue developer login.
7
+ The developer pages need a free Hue developer login.
8
8
 
9
9
  - [Getting started](https://developers.meethue.com/develop/hue-api-v2/getting-started/): pairing (`POST /api` with `devicetype` and `generateclientkey`), the `hue-application-key` header, the resource URLs.
10
10
  - [Core concepts](https://developers.meethue.com/develop/hue-api-v2/core-concepts/): the resource model (devices own services; rooms and zones group them; `grouped_light`, `bridge_home`), the event stream, and the rate guidance.
11
- - [Using HTTPS](https://developers.meethue.com/develop/application-design-guidance/using-https/): the bridge certificate and Signify's root CA.
11
+ - [Using HTTPS](https://developers.meethue.com/develop/application-design-guidance/using-https/): the bridge certificate and Signify's two root CAs.
12
+ - [Bridge release notes](https://www.philips-hue.com/en-au/support/release-notes/bridge) (no login): what each bridge software version changed.
13
+
14
+ [docs/hue.md](../../../docs/hue.md) has every link and what each page asks of an app.
12
15
 
13
16
  The schemas here were checked against [openhue-api](https://github.com/openhue/openhue-api) (Apache-2.0), an OpenAPI spec of CLIP v2, not against Signify's gated pages. Treat every field as unconfirmed from the primary source; all are optional, so a change degrades rather than throws.
14
17
 
15
18
  ## Pinning
16
19
 
17
- The bridge serves only its leaf certificate, `CN=<bridge id>`, signed by Signify's `root-bridge` CA (valid to 2038-01-19). Trust that CA and nothing else, and check the CN equals your bridge id. Never turn verification off: the key goes out on every request.
20
+ The bridge serves its certificate, `CN=<bridge id>`, signed by one of Signify's two private roots. Trust those two and nothing else, and check the CN equals your bridge id. Never turn verification off: the key goes out on every request. The package carries both roots as `HUE_BRIDGE_ROOT_CA`, the bundle from Signify's page byte for byte; [docs/hue.md](../../../docs/hue.md) has Signify's rules, the fingerprints and what other projects do.
18
21
 
19
- - **Bun**: `bunPinnedFetch(bridgeId, ca)`, which `hue()` uses by default. Measured on Bun 1.4.2: 124 ms cold, 16 ms kept alive; a wrong bridge id is rejected.
20
- - **Node**: `nodePinnedFetch(bridgeId, ca)` from `@khalido/home/node`, undici's `fetch` with an `Agent` holding `ca` and the same CN check. Checked on Node 24.20 and 26.11 against a local TLS server (2026-10-28), not yet a real bridge.
22
+ - **Bun**: `bunPinnedFetch(bridgeId, ca?)`, which `hue()` uses by default. Measured on Bun 1.4.2: 124 ms cold, 16 ms kept alive.
23
+ - **Node**: `nodePinnedFetch(bridgeId, ca?)` from `@khalido/home/node`, undici's `fetch` with an `Agent` holding the roots and the same CN check. Checked on Node 24.20 and 26.11 against a local TLS server (2026-10-28), not yet a real bridge.
21
24
 
22
- The CA's SHA-256 is `F0:BD:8E:65:09:E8:2F:77:4D:63:BC:00:9D:53:88:C9:69:FE:3D:CF:7D:6D:54:1D:63:51:B7:2B:89:8D:8A:CF`. It comes from a public mirror and passes `openssl verify` against a live BSB002; confirming it against the copy on the developer portal is still to do. It is not in the package yet: pass `ca` yourself (the CLI reads `HUE_CA_FILE`). The plan is a `HUE_BRIDGE_ROOT_CA` export here so pinning is one import.
25
+ `ca` replaces the bundle when given (the config's `ca`, the CLI's `HUE_CA_FILE`); nobody needs it for a real bridge. Live against KO's BSB002 (software 1978545030, Bun 1.4.4, 2026-11-11): the bundle is accepted, the second root alone and a wrong bridge id are refused. A bridge still on a self-signed certificate (software before 1971060010, May 2025) is refused: update it. A Bridge Pro is untried.
23
26
 
24
27
  ## Pairing
25
28
 
26
- `pair({ host, fetch, deviceType })` makes one `POST /api`. Until someone presses the bridge's link button it throws `HueError` with `code: "link_button"` (bridge error 101). It never retries in a loop; the caller decides how to prompt. Pairing happens once: the key it returns keeps working, and the library never stores it.
29
+ `pair({ host, fetch, deviceType })` makes one `POST /api`. Until someone presses the bridge's link button it throws `HueError` with `code: "link_button"` (bridge error 101). `pairWhenPressed` takes the same and asks every 2 s (`everyMs`) for 30 s (`timeoutMs`), calling `onWaiting` once when the bridge first says the button isn't pressed, so the caller can prompt; then it throws that error. `home pair` uses it with 60 s and a countdown. Pairing happens once: the key it returns keeps working, and the library never stores it. Live, 2026-11-11, KO's BSB002 (Bun 1.4.4): `home pair` timed out with no press, then returned a key once the button was pressed.
30
+
31
+ ## Lights, rooms and scenes
32
+
33
+ `hueClient` (and `hue().client`) has three writes by name or id, each resolving `{ state, confirmed }`: the light or group as the bridge then reported it, read every half second for at most 3 s (`CONFIRM_MS`). Unconfirmed is a result, not an error.
34
+
35
+ - `light(name, change)` changes one light; `room(name, change)` every light in a room or zone with one group write.
36
+ - `change` is `{ on?, brightness?, color?, kelvin?, transitionMs? }`, and only what is given is sent. `brightness` is 1–100. `color` is a hex string or `{ r, g, b }`, sent as `xy` moved inside the light's own gamut (a room: its first colour light's). `kelvin` is a white temperature, checked against the light's own range (a room: the widest of its lights'). `color` and `kelvin` are one or the other. `transitionMs` is the fade; the bridge's own is 400 ms.
37
+ - Without `on`, a change to a light that is off turns it on, since an off light takes nothing else; a light already on gets no `on`. A room always gets `on: true`, since some of its lights may be off.
38
+ - `allOff()` turns every light in the home off with one write to the home group; `confirmed` once no light the bridge reaches is on. Fake bridge only, untried live.
39
+ - `scene(name, room?)` recalls a scene. Scene names repeat across rooms, so a name several rooms use needs `room`. `confirmed` is the bridge reporting the scene active.
40
+ - A name is matched whole, in any case. A miss, a name two things share, a value off the range, a colour for a white light: each throws `HueError` with `code: "invalid"` before anything is sent, and a light the bridge has lost throws with `code: "offline"`; a miss lists what exists, and a name two things share lists them with their ids.
41
+ - Confirmation compares on/off, brightness within a point, and on a light the white temperature (within 2 mirek) or the colour (within 0.02 in `xy`). A room reports no colour, so only its on/off and brightness are compared; its brightness is the bridge's figure for the group.
42
+
43
+ The state has each light's `kelvin` and `kelvinRange`, and on a colour light its `xy`, a `hex` for a screen (the hue at full brightness) and its `gamut`. `colorToXy`, `xyToHex`, `kelvinToMirek` and `mirekToKelvin` are exported. `hueText(view)` is the line.
44
+
45
+ CLI: `home light <name> [on|off] [<1-100>] [<kelvin>k] [<hex colour>]`, the same for `home room`, and `home scene <name> [in <room>]`, each printing what the bridge then reported. Quote a name with a space. They ran on KO's bridge on 2026-11-11 (below).
27
46
 
28
47
  ## Events
29
48
 
@@ -33,18 +52,44 @@ Unconfirmed: whether the bridge resumes from `Last-Event-ID`; how many streams o
33
52
 
34
53
  ## Rates
35
54
 
36
- Two write lanes per client (`src/lane.ts`). `grouped_light` and `scene` writes go out 1 s apart, `light` writes 100 ms apart, each lane in order. A write to a resource whose last write is still waiting merges into it (`mergeBody`, the newer field winning), so a burst cannot build a backlog; the merged write keeps the first one's place in the lane. Unverified: which the bridge applies when a merge carries both `color_temperature` and `color`. That is Signify's guidance as secondary sources quote it (about 10 commands a second to lights, 1 a second to groups), unconfirmed from the gated page. Prefer one `grouped_light` or scene write over many light writes: the bridge sends it to every light at once.
55
+ Two write lanes per client (`src/lane.ts`). `grouped_light` and `scene` writes go out 1 s apart, `light` writes 100 ms apart, each lane in order. A write to a resource whose last write is still waiting merges into it (`mergeBody`, the newer field winning), so a burst cannot build a backlog; the merged write keeps the first one's place in the lane. Unverified: which the bridge applies when a merge carries both `color_temperature` and `color`. That is Signify's guidance (about 10 commands a second to lights, 1 a second to groups; its Hue System Performance page, read 2026-11-11; [docs/hue.md](../../../docs/hue.md) has the rest). Prefer one `grouped_light` or scene write over many light writes: the bridge sends it to every light at once.
56
+
57
+ ## First live read (2026-11-11)
58
+
59
+ KO's BSB002, software 1978545030, Bun 1.4.4, reads only, with the key from `home pair` and the bundled roots.
60
+
61
+ - `home hue`: connected in 0.47 s. A full read is 177 resources in 286 ms, and every one parsed.
62
+ - 16 lights, 6 rooms, 30 scenes, 2 lights unreachable (shown as not available, reason `offline`). A room with no lights has no on/off and prints its name alone.
63
+ - Every light has `dimming` and `color_temperature`, each with its own mirek range (153–500 on a colour light, 153–454 on a white one). 6 have `color`, with `xy` and their gamut (type C) on the resource, as Signify's page says. `grouped_light` carries `on`, `dimming`, `color_temperature` and `color` too.
64
+ - The event stream: connected at 0.4 s, then nothing from the bridge for 90 s, so the watchdog reconnected and read again at 90.8 s, as designed. With no light changing, that is one full read every 90 s.
65
+ - Untried: any write through the paired key, an event arriving (nothing changed while watching), a move to a new address.
66
+
67
+ ## First live writes (2026-11-11)
68
+
69
+ KO's BSB002 at his request, Bun 1.4.4, every light put back as it was.
70
+
71
+ - **A room of four white lights:** brightness, a white temperature, off and on each confirmed in 0.8–1.4 s (a read, the write, a read). Each light restored one by one read exactly as before.
72
+ - **A room of three colour lights, one unreachable, through the CLI:** on at 50% and 2700 K, a colour on each reachable light (`ff8800` and `0044ff` read back as exactly those), a colour for the room, a scene by name and room, then off. All confirmed, each command about 1 s. The unreachable light did not stop the room confirming.
73
+ - **A fade does not delay confirmation:** with `transitionMs: 1500` the bridge reported the target at once. Ten room writes 2 s apart over 20 s all confirmed.
74
+ - **A light the bridge has lost:** it accepted the write and answered "has communication issues, command may not have effect", naming the light by id. `light` now refuses first, by name, with `code: "offline"`.
75
+ - **A scene name three rooms use** listed the three with their rooms and exited 2.
76
+ - **A group's brightness is the bridge's own average:** after a room write to 40%, one light read 46% and the group 42%. Passed through as reported.
77
+ - Untried: zones, a white temperature on a room that mixes ranges, an event arriving on the stream after a write, effects and gradients (not built).
78
+
79
+ ## Dry run (2026-11-12)
80
+
81
+ `light`, `room`, `scene` and `allOff` take `{ dryRun: true }` last: the read, every check before sending, and `wouldSend` (`{ method, path, body }`); nothing sent. On KO's bridge (Bun 1.4.4): `home room "Living room" 40 2700k --dry-run`, `home lights off --dry-run` and `home scene Relax --dry-run` (which listed the three rooms with a Relax) printed their requests and changed no light.
37
82
 
38
83
  ## Sources
39
84
 
40
85
  What this folder learned from, and on what terms. No code was copied.
41
86
 
42
- | Source | Licence | Used for |
43
- | ---------------------------------------------------------------------------------------------------------------- | ---------------------------- | ------------------------------------ |
44
- | [Signify's Hue API docs](https://developers.meethue.com/develop/hue-api-v2/) | Developer terms, login-gated | the API itself |
45
- | [openhue/openhue-api](https://github.com/openhue/openhue-api) | Apache-2.0 | resource and event-stream shapes |
46
- | [Home Assistant `hue` integration](https://github.com/home-assistant/core/tree/dev/homeassistant/components/hue) | Apache-2.0 | how HA wires aiohue into entities |
47
- | [home-assistant-libs/aiohue](https://github.com/home-assistant-libs/aiohue) | Apache-2.0 | reconnect behaviour |
48
- | [jash90/lumen-desktop](https://github.com/jash90/lumen-desktop) | MIT | the public copy of Signify's root CA |
49
- | [jdmg94/Hue-Sync](https://github.com/jdmg94/Hue-Sync) | Apache-2.0 | resource types, cross-checked |
50
- | [vchlum/smart-home](https://github.com/vchlum/smart-home) | MIT | a compact CLIP v2 event loop |
87
+ | Source | Licence | Used for |
88
+ | ---------------------------------------------------------------------------------------------------------------- | ---------------------------- | --------------------------------------- |
89
+ | [Signify's Hue API docs](https://developers.meethue.com/develop/hue-api-v2/) | Developer terms, login-gated | the API itself |
90
+ | [openhue/openhue-api](https://github.com/openhue/openhue-api) | Apache-2.0 | resource and event-stream shapes |
91
+ | [Home Assistant `hue` integration](https://github.com/home-assistant/core/tree/dev/homeassistant/components/hue) | Apache-2.0 | how HA wires aiohue into entities |
92
+ | [home-assistant-libs/aiohue](https://github.com/home-assistant-libs/aiohue) | Apache-2.0 | reconnect behaviour |
93
+ | [Signify's Using HTTPS page](https://developers.meethue.com/develop/application-design-guidance/using-https/) | Developer terms, login-gated | the two root certificates, as published |
94
+ | [jdmg94/Hue-Sync](https://github.com/jdmg94/Hue-Sync) | Apache-2.0 | resource types, cross-checked |
95
+ | [vchlum/smart-home](https://github.com/vchlum/smart-home) | MIT | a compact CLIP v2 event loop |
@@ -0,0 +1,39 @@
1
+ // Signify's two root certificates for Hue bridges, so pinning needs no file: `bunPinnedFetch(bridgeId)` and
2
+ // `nodePinnedFetch(bridgeId)` trust these and nothing else. Public certificates, as Signify's "Using HTTPS" page gives
3
+ // them (login-gated); these bytes are the ones openHAB, hb-hue-tools, openhue-go and Raycast's Hue extension ship.
4
+ //
5
+ // CN=root-bridge, O=Philips Hue to 2038-01-19 the one in use: KO's square bridge (BSB002) chains to it
6
+ // SHA-256 F0:BD:8E:65:09:E8:2F:77:4D:63:BC:00:9D:53:88:C9:69:FE:3D:CF:7D:6D:54:1D:63:51:B7:2B:89:8D:8A:CF
7
+ // CN=Hue Root CA 01, O=Signify Hue to 2050-12-31 Signify's spare, likely behind an intermediate: untried here
8
+ // SHA-256 D8:B8:94:48:B2:AF:8E:16:76:18:5A:C0:72:19:EE:9D:CB:C8:F0:1C:12:2A:02:6A:2A:4B:7B:5C:FE:03:28:B8
9
+ //
10
+ // test/hue.test.ts checks both fingerprints, so an edit here fails.
11
+
12
+ /** Signify's root certificates for Hue bridges (PEM, two certificates): the default `ca` everywhere one is taken. */
13
+ export const HUE_BRIDGE_ROOT_CA = `-----BEGIN CERTIFICATE-----
14
+ MIICMjCCAdigAwIBAgIUO7FSLbaxikuXAljzVaurLXWmFw4wCgYIKoZIzj0EAwIw
15
+ OTELMAkGA1UEBhMCTkwxFDASBgNVBAoMC1BoaWxpcHMgSHVlMRQwEgYDVQQDDAty
16
+ b290LWJyaWRnZTAiGA8yMDE3MDEwMTAwMDAwMFoYDzIwMzgwMTE5MDMxNDA3WjA5
17
+ MQswCQYDVQQGEwJOTDEUMBIGA1UECgwLUGhpbGlwcyBIdWUxFDASBgNVBAMMC3Jv
18
+ b3QtYnJpZGdlMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEjNw2tx2AplOf9x86
19
+ aTdvEcL1FU65QDxziKvBpW9XXSIcibAeQiKxegpq8Exbr9v6LBnYbna2VcaK0G22
20
+ jOKkTqOBuTCBtjAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAdBgNV
21
+ HQ4EFgQUZ2ONTFrDT6o8ItRnKfqWKnHFGmQwdAYDVR0jBG0wa4AUZ2ONTFrDT6o8
22
+ ItRnKfqWKnHFGmShPaQ7MDkxCzAJBgNVBAYTAk5MMRQwEgYDVQQKDAtQaGlsaXBz
23
+ IEh1ZTEUMBIGA1UEAwwLcm9vdC1icmlkZ2WCFDuxUi22sYpLlwJY81Wrqy11phcO
24
+ MAoGCCqGSM49BAMCA0gAMEUCIEBYYEOsa07TH7E5MJnGw557lVkORgit2Rm1h3B2
25
+ sFgDAiEA1Fj/C3AN5psFMjo0//mrQebo0eKd3aWRx+pQY08mk48=
26
+ -----END CERTIFICATE-----
27
+ -----BEGIN CERTIFICATE-----
28
+ MIIBzDCCAXOgAwIBAgICEAAwCgYIKoZIzj0EAwIwPDELMAkGA1UEBhMCTkwxFDAS
29
+ BgNVBAoMC1NpZ25pZnkgSHVlMRcwFQYDVQQDDA5IdWUgUm9vdCBDQSAwMTAgFw0y
30
+ NTAyMjUwMDAwMDBaGA8yMDUwMTIzMTIzNTk1OVowPDELMAkGA1UEBhMCTkwxFDAS
31
+ BgNVBAoMC1NpZ25pZnkgSHVlMRcwFQYDVQQDDA5IdWUgUm9vdCBDQSAwMTBZMBMG
32
+ ByqGSM49AgEGCCqGSM49AwEHA0IABFfOO0jfSAUXGQ9kjEDzyBrcMQ3ItyA5krE+
33
+ cyvb1Y3xFti7KlAad8UOnAx0FBLn7HZrlmIwm1QnX0fK3LPM13mjYzBhMB0GA1Ud
34
+ DgQWBBTF1pSpsCASX/z0VHLigxU2CAaqoTAfBgNVHSMEGDAWgBTF1pSpsCASX/z0
35
+ VHLigxU2CAaqoTAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAKBggq
36
+ hkjOPQQDAgNHADBEAiAk7duT+IHbOGO4UUuGLAEpyYejGZK9Z7V9oSfnvuQ5BQIg
37
+ IYSgwwxHXm73/JgcU9lAM6c8Bmu3UE3kBIUwBs1qXFw=
38
+ -----END CERTIFICATE-----
39
+ `;
@@ -1,6 +1,17 @@
1
1
  import { z } from "zod";
2
+ import { colorToXy, kelvinToMirek, type Rgb, type XY } from "./color.ts";
2
3
  import { Envelope, type Kind, PairResponse, Resource } from "./schemas.ts";
3
- import type { Clock } from "../../clock.ts";
4
+ import {
5
+ type GroupView,
6
+ groups,
7
+ type LightView,
8
+ lights,
9
+ scenes,
10
+ snapshotFrom,
11
+ homeGroupedLightId,
12
+ } from "./state.ts";
13
+ import { HUE_BRIDGE_ROOT_CA } from "./ca.ts";
14
+ import { type Clock, realClock } from "../../clock.ts";
4
15
  import { ConnectorError, fetchTimeout } from "../../connector.ts";
5
16
  import { writeLane } from "../../lane.ts";
6
17
 
@@ -12,12 +23,43 @@ export type HueOptions = {
12
23
  clock?: Clock;
13
24
  };
14
25
 
15
- /** A failed bridge call. `code` is "link_button" when pairing waits on the bridge's button. */
26
+ /** A failed bridge call. `code` is "link_button" when pairing waits on the bridge's button, "invalid" for a change that can't be made (an unknown name, a value off the light's range) and "offline" for a light the bridge has lost, both caught before anything is sent. */
16
27
  export class HueError extends ConnectorError {
17
- declare readonly code?: "link_button";
28
+ declare readonly code?: "link_button" | "invalid" | "offline";
18
29
  }
19
30
 
20
- /** Minimum gap between writes, per lane. Signify's guidance (login-gated, unconfirmed): ~10/s to lights, 1/s to groups. */
31
+ /**
32
+ * What to change on a light, or on a room or zone: only what is given is sent. `brightness` is 1–100, `color` a hex
33
+ * string or `{ r, g, b }`, `kelvin` a white temperature; `color` and `kelvin` are one or the other. Without `on`, a
34
+ * change turns the light on, since a light that is off takes no brightness or colour. `transitionMs` is the fade (the
35
+ * bridge's own is 400 ms; 0 is at once).
36
+ */
37
+ export type LightChange = {
38
+ on?: boolean;
39
+ brightness?: number;
40
+ color?: string | Rgb;
41
+ kelvin?: number;
42
+ transitionMs?: number;
43
+ };
44
+
45
+ /**
46
+ * What a write left: the light or group once the bridge reported the change (`confirmed`), or as it stood when
47
+ * `CONFIRM_MS` ran out. With `{ dryRun: true }` nothing is sent: `wouldSend` is the request the write would make, `state`
48
+ * is how things stand now, and `confirmed` is false. Check `wouldSend` first.
49
+ */
50
+ export type HueApplied<V> = {
51
+ state: V;
52
+ confirmed: boolean;
53
+ wouldSend?: HueRequest;
54
+ };
55
+ /** One CLIP v2 write: `PUT /clip/v2/resource/<path>` with this body. */
56
+ export type HueRequest = { method: "PUT"; path: string; body: object };
57
+ /** `dryRun`: every check a write makes before sending (the name, the range, a lost light) and nothing sent. For testing a bot with people home. */
58
+ export type HueWriteOptions = { dryRun?: boolean };
59
+ /** How long `light`, `room` and `scene` wait for the bridge to report what was asked, reading every half second. */
60
+ export const CONFIRM_MS = 3000;
61
+
62
+ /** Minimum gap between writes, per lane: Signify's guidance (Hue System Performance, read 2026-11-11), about 10 a second to lights and 1 a second to groups. */
21
63
  export const WRITE_GAP_MS = { group: 1000, light: 100 } as const;
22
64
  type Lane = keyof typeof WRITE_GAP_MS;
23
65
 
@@ -83,7 +125,8 @@ export function hueClient(options: HueOptions) {
83
125
  mergeBody,
84
126
  );
85
127
 
86
- return {
128
+ const clock = options.clock ?? realClock;
129
+ const base = {
87
130
  put,
88
131
  /** Every resource of one kind, or every resource on the bridge when `kind` is left out (one call). */
89
132
  async get(kind?: Kind, signal?: AbortSignal): Promise<Resource[]> {
@@ -103,6 +146,266 @@ export function hueClient(options: HueOptions) {
103
146
  return put("scene", id, { recall: { action: "active" } });
104
147
  },
105
148
  };
149
+
150
+ /** Whether two of these labels are the same, so only an id tells them apart. */
151
+ const same = (labels: string[]) => new Set(labels).size < labels.length;
152
+ const invalid = (message: string) =>
153
+ new HueError(message, { code: "invalid" });
154
+ /** One of `items` by id or by name (any case). A miss, or a name two of them share, throws listing what exists. */
155
+ function pick<T extends { id: string; name?: string }>(
156
+ items: T[],
157
+ target: string,
158
+ what: string,
159
+ label: (item: T) => string = (i) => i.name ?? i.id,
160
+ ): T {
161
+ const wanted = target.trim().toLowerCase();
162
+ const found = items.filter(
163
+ (i) => i.id === target || i.name?.toLowerCase() === wanted,
164
+ );
165
+ if (found.length === 1) return found[0]!;
166
+ throw invalid(
167
+ found.length
168
+ ? `"${target}" names ${found.length}: ${same(found.map(label)) ? found.map((i) => `${label(i)} (${i.id})`).join(", ") : found.map(label).join(", ")}`
169
+ : `no ${what} "${target}"; have: ${items.map(label).join(", ") || "none"}`,
170
+ );
171
+ }
172
+ const read = async () => snapshotFrom(await base.get(), "");
173
+
174
+ /** The CLIP body for `change`, checked against what the light (or a group's lights) can take. Throws before anything is sent. */
175
+ function bodyFor(
176
+ change: LightChange,
177
+ name: string,
178
+ members: LightView[],
179
+ isOn: boolean | undefined,
180
+ ): Record<string, unknown> {
181
+ const { on, brightness, color, kelvin, transitionMs } = change;
182
+ const look =
183
+ brightness !== undefined || color !== undefined || kelvin !== undefined;
184
+ if (on === undefined && !look) throw invalid("nothing to change");
185
+ if (on === false && look)
186
+ throw invalid("off takes no brightness or colour");
187
+ if (color !== undefined && kelvin !== undefined)
188
+ throw invalid("a colour or a white temperature, not both");
189
+ const body: Record<string, unknown> = {};
190
+ if (on !== undefined) body.on = { on };
191
+ else if (isOn !== true) body.on = { on: true }; // off, or a group where some may be
192
+ if (brightness !== undefined) {
193
+ if (!(brightness >= 1 && brightness <= 100))
194
+ throw invalid(`brightness is 1–100, not ${brightness}`);
195
+ body.dimming = { brightness };
196
+ }
197
+ if (color !== undefined) {
198
+ const gamut = members.find((l) => l.gamut)?.gamut;
199
+ if (!gamut) throw invalid(`${name} has no colour light`);
200
+ try {
201
+ body.color = { xy: colorToXy(color, gamut) };
202
+ } catch (error) {
203
+ throw invalid((error as Error).message); // a bad colour, as every other change that can't be made
204
+ }
205
+ }
206
+ if (kelvin !== undefined) {
207
+ const ranges = members.flatMap((l) =>
208
+ l.kelvinRange ? [l.kelvinRange] : [],
209
+ );
210
+ if (!ranges.length) throw invalid(`${name} takes no white temperature`);
211
+ const low = Math.min(...ranges.map((r) => r[0]));
212
+ const high = Math.max(...ranges.map((r) => r[1]));
213
+ if (!(kelvin >= low && kelvin <= high))
214
+ throw invalid(
215
+ `${kelvin} K is outside ${name}'s range, ${low}–${high} K`,
216
+ );
217
+ body.color_temperature = { mirek: kelvinToMirek(kelvin) };
218
+ }
219
+ if (transitionMs !== undefined) {
220
+ if (!(Number.isInteger(transitionMs) && transitionMs >= 0))
221
+ throw invalid(
222
+ `transitionMs is a whole number of ms, not ${transitionMs}`,
223
+ );
224
+ body.dynamics = { duration: transitionMs };
225
+ }
226
+ return body;
227
+ }
228
+
229
+ /** Read every half second until `view()` gives a state that is as `asked`, or `CONFIRM_MS` passes. A failed read is not a failed write. */
230
+ async function confirm<V>(
231
+ view: () => Promise<V | undefined>,
232
+ asked: (v: V) => boolean,
233
+ ): Promise<HueApplied<V>> {
234
+ const until = clock.now() + CONFIRM_MS;
235
+ let state: V | undefined;
236
+ let failed: unknown;
237
+ for (;;) {
238
+ try {
239
+ state = (await view()) ?? state;
240
+ if (state !== undefined && asked(state)) break;
241
+ } catch (error) {
242
+ failed = error;
243
+ }
244
+ if (clock.now() >= until) break;
245
+ await clock.sleep(500);
246
+ }
247
+ if (state === undefined)
248
+ throw failed ?? new HueError("sent, but it is no longer on the bridge");
249
+ return { state, confirmed: asked(state) };
250
+ }
251
+ /**
252
+ * Whether a view shows what the body asked: on, brightness within a point, and on a light the white temperature within
253
+ * 2 mirek or the colour within 0.02 in `xy`. A room reports neither, so its colour is not compared (its `on` is).
254
+ */
255
+ const shows =
256
+ (body: Record<string, unknown>, light: boolean) =>
257
+ (v: Partial<LightView>): boolean => {
258
+ const on = (body.on as { on: boolean } | undefined)?.on;
259
+ const brightness = (body.dimming as { brightness: number } | undefined)
260
+ ?.brightness;
261
+ const mirek = (body.color_temperature as { mirek: number } | undefined)
262
+ ?.mirek;
263
+ const xy = (body.color as { xy: XY } | undefined)?.xy;
264
+ return (
265
+ (on === undefined || v.on === on) &&
266
+ (brightness === undefined ||
267
+ (v.brightness !== undefined &&
268
+ Math.abs(v.brightness - brightness) <= 1)) &&
269
+ (!light ||
270
+ mirek === undefined ||
271
+ (v.kelvin !== undefined &&
272
+ Math.abs(kelvinToMirek(v.kelvin) - mirek) <= 2)) &&
273
+ (!light ||
274
+ xy === undefined ||
275
+ (v.xy !== undefined &&
276
+ Math.abs(v.xy.x - xy.x) <= 0.02 &&
277
+ Math.abs(v.xy.y - xy.y) <= 0.02))
278
+ );
279
+ };
280
+
281
+ /** Send, or with `dryRun` say what would be sent and how `now` stands. */
282
+ async function send<V>(
283
+ options: HueWriteOptions | undefined,
284
+ kind: Kind,
285
+ id: string,
286
+ body: object,
287
+ now: V | undefined,
288
+ after: () => Promise<HueApplied<V>>,
289
+ ): Promise<HueApplied<V>> {
290
+ if (options?.dryRun) {
291
+ if (now === undefined)
292
+ throw new HueError("its room or zone is not on the bridge");
293
+ return {
294
+ state: now,
295
+ confirmed: false,
296
+ wouldSend: { method: "PUT", path: `${kind}/${id}`, body },
297
+ };
298
+ }
299
+ await put(kind, id, body);
300
+ return after();
301
+ }
302
+ return {
303
+ ...base,
304
+ /** Change one light, by name or id. Only when a person asks. Resolves with the light as the bridge then reported it. */
305
+ async light(
306
+ target: string,
307
+ change: LightChange,
308
+ options?: HueWriteOptions,
309
+ ): Promise<HueApplied<LightView>> {
310
+ const l = pick(lights(await read()), target, "light");
311
+ // The bridge takes a write to a light it has lost and answers "may not have effect": say so before sending.
312
+ if (l.reachable === false)
313
+ throw new HueError(
314
+ `${l.name ?? l.id} is unreachable: the bridge has lost it (is it switched off at the wall?)`,
315
+ { code: "offline" },
316
+ );
317
+ const body = bodyFor(change, l.name ?? l.id, [l], l.on);
318
+ return send(options, "light", l.id, body, l, () =>
319
+ confirm(
320
+ async () => lights(await read()).find((x) => x.id === l.id),
321
+ shows(body, true),
322
+ ),
323
+ );
324
+ },
325
+ /**
326
+ * Every light in the home off, with one write to the bridge's home group. Only when a person asks. Resolves with
327
+ * every light as the bridge then reported it, `confirmed` once none it can reach is on.
328
+ */
329
+ async allOff(options?: HueWriteOptions): Promise<HueApplied<LightView[]>> {
330
+ const s = await read();
331
+ const id = homeGroupedLightId(s);
332
+ if (!id) throw invalid("the bridge has no home group");
333
+ return send(
334
+ options,
335
+ "grouped_light",
336
+ id,
337
+ { on: { on: false } },
338
+ lights(s),
339
+ () =>
340
+ confirm(
341
+ async () => lights(await read()),
342
+ (all) => all.every((l) => !l.on || l.reachable === false),
343
+ ),
344
+ );
345
+ },
346
+ /**
347
+ * Change every light in a room or zone with one write, by name or id. Only when a person asks. Resolves with the
348
+ * group as the bridge then reported it; its brightness is the bridge's figure for the group, so a group holding a
349
+ * light that is off or unreachable may stay unconfirmed.
350
+ */
351
+ async room(
352
+ target: string,
353
+ change: LightChange,
354
+ options?: HueWriteOptions,
355
+ ): Promise<HueApplied<GroupView>> {
356
+ const s = await read();
357
+ const g = pick(groups(s), target, "room or zone");
358
+ const name = g.name ?? g.id;
359
+ if (!g.groupedLightId) throw invalid(`${name} has no lights`);
360
+ const members = lights(s).filter((l) => g.lightIds.includes(l.id));
361
+ const body = bodyFor(change, name, members, undefined);
362
+ return send(options, "grouped_light", g.groupedLightId, body, g, () =>
363
+ confirm(
364
+ async () => groups(await read()).find((x) => x.id === g.id),
365
+ shows(body, false),
366
+ ),
367
+ );
368
+ },
369
+ /**
370
+ * Recall a scene by name or id. A name several rooms use ("Relax") needs `room`. Only when a person asks. Resolves
371
+ * with the scene's room or zone, `confirmed` once the bridge reports the scene active.
372
+ */
373
+ async scene(
374
+ target: string,
375
+ room?: string,
376
+ options?: HueWriteOptions,
377
+ ): Promise<HueApplied<GroupView>> {
378
+ const s = await read();
379
+ const all = groups(s);
380
+ const within =
381
+ room === undefined ? undefined : pick(all, room, "room or zone");
382
+ const roomName = (id?: string) =>
383
+ all.find((g) => g.id === id)?.name ?? "no room";
384
+ const sc = pick(
385
+ scenes(s).filter((x) => !within || x.groupId === within.id),
386
+ target,
387
+ "scene",
388
+ (x) => `${x.name ?? x.id} in ${roomName(x.groupId)}`,
389
+ );
390
+ let active = false;
391
+ return send(
392
+ options,
393
+ "scene",
394
+ sc.id,
395
+ { recall: { action: "active" } },
396
+ all.find((g) => g.id === sc.groupId),
397
+ () =>
398
+ confirm(
399
+ async () => {
400
+ const now = await read();
401
+ active = scenes(now).find((x) => x.id === sc.id)?.active === true;
402
+ return groups(now).find((x) => x.id === sc.groupId);
403
+ },
404
+ () => active,
405
+ ),
406
+ );
407
+ },
408
+ };
106
409
  }
107
410
  export type HueClient = ReturnType<typeof hueClient>;
108
411
 
@@ -111,15 +414,21 @@ export async function pair(options: {
111
414
  host: string;
112
415
  fetch: typeof fetch;
113
416
  deviceType: string;
417
+ signal?: AbortSignal;
114
418
  }): Promise<{ key: string; clientKey?: string }> {
115
- const res = await fetchTimeout(options.fetch, `https://${options.host}/api`, {
116
- method: "POST",
117
- headers: { "content-type": "application/json" },
118
- body: JSON.stringify({
119
- devicetype: options.deviceType,
120
- generateclientkey: true,
121
- }),
122
- });
419
+ const res = await fetchTimeout(
420
+ options.fetch,
421
+ `https://${options.host}/api`,
422
+ {
423
+ method: "POST",
424
+ headers: { "content-type": "application/json" },
425
+ body: JSON.stringify({
426
+ devicetype: options.deviceType,
427
+ generateclientkey: true,
428
+ }),
429
+ },
430
+ { signal: options.signal },
431
+ );
123
432
  const parsed = PairResponse.safeParse(
124
433
  await res.json().catch(() => undefined),
125
434
  );
@@ -138,10 +447,48 @@ export async function pair(options: {
138
447
  }
139
448
 
140
449
  /**
141
- * A `fetch` that trusts only `ca` and accepts only a certificate whose CN is `bridgeId`.
450
+ * `pair`, asked again every `everyMs` (2 s) until someone presses the bridge's button, for `timeoutMs` (30 s, as long as
451
+ * the button stays pressed). `onWaiting` is called once, when the bridge first says the button isn't pressed: the moment
452
+ * to tell the person. Throws the last `link_button` error when time runs out, any other error at once, and the signal's
453
+ * reason when aborted.
454
+ */
455
+ export async function pairWhenPressed(options: {
456
+ host: string;
457
+ fetch: typeof fetch;
458
+ deviceType: string;
459
+ timeoutMs?: number;
460
+ everyMs?: number;
461
+ onWaiting?: () => void;
462
+ clock?: Clock;
463
+ signal?: AbortSignal;
464
+ }): Promise<{ key: string; clientKey?: string }> {
465
+ const clock = options.clock ?? realClock;
466
+ const until = clock.now() + (options.timeoutMs ?? 30_000);
467
+ let waiting = false;
468
+ while (true) {
469
+ try {
470
+ return await pair(options);
471
+ } catch (error) {
472
+ const pressed = !(
473
+ error instanceof HueError && error.code === "link_button"
474
+ );
475
+ if (pressed || clock.now() >= until) throw error;
476
+ if (!waiting) options.onWaiting?.();
477
+ waiting = true;
478
+ }
479
+ await clock.sleep(options.everyMs ?? 2000, options.signal);
480
+ options.signal?.throwIfAborted();
481
+ }
482
+ }
483
+
484
+ /**
485
+ * A `fetch` that trusts only `ca` (Signify's roots, `HUE_BRIDGE_ROOT_CA`, when left out) and accepts only a certificate whose CN is `bridgeId`.
142
486
  * Uses Bun's `tls` fetch option; Node ignores it and then rejects the bridge, so it fails closed there; Node uses `nodePinnedFetch` from `@khalido/home/node`.
143
487
  */
144
- export function bunPinnedFetch(bridgeId: string, ca: string): typeof fetch {
488
+ export function bunPinnedFetch(
489
+ bridgeId: string,
490
+ ca: string = HUE_BRIDGE_ROOT_CA,
491
+ ): typeof fetch {
145
492
  const checkServerIdentity = (
146
493
  _host: string,
147
494
  cert: { subject?: { CN?: string } },