@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
@@ -0,0 +1,38 @@
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
+ /** Signify's root certificates for Hue bridges (PEM, two certificates): the default `ca` everywhere one is taken. */
12
+ export const HUE_BRIDGE_ROOT_CA = `-----BEGIN CERTIFICATE-----
13
+ MIICMjCCAdigAwIBAgIUO7FSLbaxikuXAljzVaurLXWmFw4wCgYIKoZIzj0EAwIw
14
+ OTELMAkGA1UEBhMCTkwxFDASBgNVBAoMC1BoaWxpcHMgSHVlMRQwEgYDVQQDDAty
15
+ b290LWJyaWRnZTAiGA8yMDE3MDEwMTAwMDAwMFoYDzIwMzgwMTE5MDMxNDA3WjA5
16
+ MQswCQYDVQQGEwJOTDEUMBIGA1UECgwLUGhpbGlwcyBIdWUxFDASBgNVBAMMC3Jv
17
+ b3QtYnJpZGdlMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEjNw2tx2AplOf9x86
18
+ aTdvEcL1FU65QDxziKvBpW9XXSIcibAeQiKxegpq8Exbr9v6LBnYbna2VcaK0G22
19
+ jOKkTqOBuTCBtjAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAdBgNV
20
+ HQ4EFgQUZ2ONTFrDT6o8ItRnKfqWKnHFGmQwdAYDVR0jBG0wa4AUZ2ONTFrDT6o8
21
+ ItRnKfqWKnHFGmShPaQ7MDkxCzAJBgNVBAYTAk5MMRQwEgYDVQQKDAtQaGlsaXBz
22
+ IEh1ZTEUMBIGA1UEAwwLcm9vdC1icmlkZ2WCFDuxUi22sYpLlwJY81Wrqy11phcO
23
+ MAoGCCqGSM49BAMCA0gAMEUCIEBYYEOsa07TH7E5MJnGw557lVkORgit2Rm1h3B2
24
+ sFgDAiEA1Fj/C3AN5psFMjo0//mrQebo0eKd3aWRx+pQY08mk48=
25
+ -----END CERTIFICATE-----
26
+ -----BEGIN CERTIFICATE-----
27
+ MIIBzDCCAXOgAwIBAgICEAAwCgYIKoZIzj0EAwIwPDELMAkGA1UEBhMCTkwxFDAS
28
+ BgNVBAoMC1NpZ25pZnkgSHVlMRcwFQYDVQQDDA5IdWUgUm9vdCBDQSAwMTAgFw0y
29
+ NTAyMjUwMDAwMDBaGA8yMDUwMTIzMTIzNTk1OVowPDELMAkGA1UEBhMCTkwxFDAS
30
+ BgNVBAoMC1NpZ25pZnkgSHVlMRcwFQYDVQQDDA5IdWUgUm9vdCBDQSAwMTBZMBMG
31
+ ByqGSM49AgEGCCqGSM49AwEHA0IABFfOO0jfSAUXGQ9kjEDzyBrcMQ3ItyA5krE+
32
+ cyvb1Y3xFti7KlAad8UOnAx0FBLn7HZrlmIwm1QnX0fK3LPM13mjYzBhMB0GA1Ud
33
+ DgQWBBTF1pSpsCASX/z0VHLigxU2CAaqoTAfBgNVHSMEGDAWgBTF1pSpsCASX/z0
34
+ VHLigxU2CAaqoTAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAKBggq
35
+ hkjOPQQDAgNHADBEAiAk7duT+IHbOGO4UUuGLAEpyYejGZK9Z7V9oSfnvuQ5BQIg
36
+ IYSgwwxHXm73/JgcU9lAM6c8Bmu3UE3kBIUwBs1qXFw=
37
+ -----END CERTIFICATE-----
38
+ `;
@@ -1,5 +1,7 @@
1
+ import { type Rgb } from "./color.ts";
1
2
  import { type Kind, Resource } from "./schemas.ts";
2
- import type { Clock } from "../../clock.ts";
3
+ import { type GroupView, type LightView } from "./state.ts";
4
+ import { type Clock } from "../../clock.ts";
3
5
  import { ConnectorError } from "../../connector.ts";
4
6
  /** What a Hue client needs: the bridge's LAN address, its application key, and a pinned `fetch`. */
5
7
  export type HueOptions = {
@@ -8,11 +10,46 @@ export type HueOptions = {
8
10
  fetch: typeof fetch;
9
11
  clock?: Clock;
10
12
  };
11
- /** A failed bridge call. `code` is "link_button" when pairing waits on the bridge's button. */
13
+ /** 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. */
12
14
  export declare class HueError extends ConnectorError {
13
- readonly code?: "link_button";
15
+ readonly code?: "link_button" | "invalid" | "offline";
14
16
  }
15
- /** Minimum gap between writes, per lane. Signify's guidance (login-gated, unconfirmed): ~10/s to lights, 1/s to groups. */
17
+ /**
18
+ * 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
19
+ * string or `{ r, g, b }`, `kelvin` a white temperature; `color` and `kelvin` are one or the other. Without `on`, a
20
+ * change turns the light on, since a light that is off takes no brightness or colour. `transitionMs` is the fade (the
21
+ * bridge's own is 400 ms; 0 is at once).
22
+ */
23
+ export type LightChange = {
24
+ on?: boolean;
25
+ brightness?: number;
26
+ color?: string | Rgb;
27
+ kelvin?: number;
28
+ transitionMs?: number;
29
+ };
30
+ /**
31
+ * What a write left: the light or group once the bridge reported the change (`confirmed`), or as it stood when
32
+ * `CONFIRM_MS` ran out. With `{ dryRun: true }` nothing is sent: `wouldSend` is the request the write would make, `state`
33
+ * is how things stand now, and `confirmed` is false. Check `wouldSend` first.
34
+ */
35
+ export type HueApplied<V> = {
36
+ state: V;
37
+ confirmed: boolean;
38
+ wouldSend?: HueRequest;
39
+ };
40
+ /** One CLIP v2 write: `PUT /clip/v2/resource/<path>` with this body. */
41
+ export type HueRequest = {
42
+ method: "PUT";
43
+ path: string;
44
+ body: object;
45
+ };
46
+ /** `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. */
47
+ export type HueWriteOptions = {
48
+ dryRun?: boolean;
49
+ };
50
+ /** How long `light`, `room` and `scene` wait for the bridge to report what was asked, reading every half second. */
51
+ export declare const CONFIRM_MS = 3000;
52
+ /** 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. */
16
53
  export declare const WRITE_GAP_MS: {
17
54
  readonly group: 1000;
18
55
  readonly light: 100;
@@ -29,6 +66,24 @@ export declare function hueClient(options: HueOptions): {
29
66
  get(kind?: Kind, signal?: AbortSignal): Promise<Resource[]>;
30
67
  /** Recall a scene in its room or zone. */
31
68
  recallScene(id: string): Promise<unknown[]>;
69
+ /** Change one light, by name or id. Only when a person asks. Resolves with the light as the bridge then reported it. */
70
+ light(target: string, change: LightChange, options?: HueWriteOptions): Promise<HueApplied<LightView>>;
71
+ /**
72
+ * Every light in the home off, with one write to the bridge's home group. Only when a person asks. Resolves with
73
+ * every light as the bridge then reported it, `confirmed` once none it can reach is on.
74
+ */
75
+ allOff(options?: HueWriteOptions): Promise<HueApplied<LightView[]>>;
76
+ /**
77
+ * Change every light in a room or zone with one write, by name or id. Only when a person asks. Resolves with the
78
+ * group as the bridge then reported it; its brightness is the bridge's figure for the group, so a group holding a
79
+ * light that is off or unreachable may stay unconfirmed.
80
+ */
81
+ room(target: string, change: LightChange, options?: HueWriteOptions): Promise<HueApplied<GroupView>>;
82
+ /**
83
+ * Recall a scene by name or id. A name several rooms use ("Relax") needs `room`. Only when a person asks. Resolves
84
+ * with the scene's room or zone, `confirmed` once the bridge reports the scene active.
85
+ */
86
+ scene(target: string, room?: string, options?: HueWriteOptions): Promise<HueApplied<GroupView>>;
32
87
  };
33
88
  export type HueClient = ReturnType<typeof hueClient>;
34
89
  /** Ask the bridge for an application key. One call: throws `HueError` with code "link_button" until KO presses it. */
@@ -36,13 +91,33 @@ export declare function pair(options: {
36
91
  host: string;
37
92
  fetch: typeof fetch;
38
93
  deviceType: string;
94
+ signal?: AbortSignal;
95
+ }): Promise<{
96
+ key: string;
97
+ clientKey?: string;
98
+ }>;
99
+ /**
100
+ * `pair`, asked again every `everyMs` (2 s) until someone presses the bridge's button, for `timeoutMs` (30 s, as long as
101
+ * the button stays pressed). `onWaiting` is called once, when the bridge first says the button isn't pressed: the moment
102
+ * to tell the person. Throws the last `link_button` error when time runs out, any other error at once, and the signal's
103
+ * reason when aborted.
104
+ */
105
+ export declare function pairWhenPressed(options: {
106
+ host: string;
107
+ fetch: typeof fetch;
108
+ deviceType: string;
109
+ timeoutMs?: number;
110
+ everyMs?: number;
111
+ onWaiting?: () => void;
112
+ clock?: Clock;
113
+ signal?: AbortSignal;
39
114
  }): Promise<{
40
115
  key: string;
41
116
  clientKey?: string;
42
117
  }>;
43
118
  /**
44
- * A `fetch` that trusts only `ca` and accepts only a certificate whose CN is `bridgeId`.
119
+ * 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`.
45
120
  * 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`.
46
121
  */
47
- export declare function bunPinnedFetch(bridgeId: string, ca: string): typeof fetch;
122
+ export declare function bunPinnedFetch(bridgeId: string, ca?: string): typeof fetch;
48
123
  export {};
@@ -1,11 +1,17 @@
1
1
  import { z } from "zod";
2
+ import { colorToXy, kelvinToMirek } from "./color.js";
2
3
  import { Envelope, PairResponse, Resource } from "./schemas.js";
4
+ import { groups, lights, scenes, snapshotFrom, homeGroupedLightId, } from "./state.js";
5
+ import { HUE_BRIDGE_ROOT_CA } from "./ca.js";
6
+ import { realClock } from "../../clock.js";
3
7
  import { ConnectorError, fetchTimeout } from "../../connector.js";
4
8
  import { writeLane } from "../../lane.js";
5
- /** A failed bridge call. `code` is "link_button" when pairing waits on the bridge's button. */
9
+ /** 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. */
6
10
  export class HueError extends ConnectorError {
7
11
  }
8
- /** Minimum gap between writes, per lane. Signify's guidance (login-gated, unconfirmed): ~10/s to lights, 1/s to groups. */
12
+ /** How long `light`, `room` and `scene` wait for the bridge to report what was asked, reading every half second. */
13
+ export const CONFIRM_MS = 3000;
14
+ /** 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. */
9
15
  export const WRITE_GAP_MS = { group: 1000, light: 100 };
10
16
  /** Which lane a write goes in: `light` writes are fast, everything else (grouped_light, scene) is a group write. */
11
17
  export const laneFor = (kind) => kind === "light" ? "light" : "group";
@@ -44,7 +50,8 @@ export function hueClient(options) {
44
50
  }
45
51
  /** Change one resource, e.g. `put("grouped_light", id, { on: { on: false } })`. Spaced per lane; writes to one resource waiting their turn merge. */
46
52
  const put = (kind, id, body) => lanes[laneFor(kind)].send(`${kind}/${id}`, body, (b) => call("PUT", `/clip/v2/resource/${kind}/${id}`, b), mergeBody);
47
- return {
53
+ const clock = options.clock ?? realClock;
54
+ const base = {
48
55
  put,
49
56
  /** Every resource of one kind, or every resource on the bridge when `kind` is left out (one call). */
50
57
  async get(kind, signal) {
@@ -57,6 +64,184 @@ export function hueClient(options) {
57
64
  return put("scene", id, { recall: { action: "active" } });
58
65
  },
59
66
  };
67
+ /** Whether two of these labels are the same, so only an id tells them apart. */
68
+ const same = (labels) => new Set(labels).size < labels.length;
69
+ const invalid = (message) => new HueError(message, { code: "invalid" });
70
+ /** One of `items` by id or by name (any case). A miss, or a name two of them share, throws listing what exists. */
71
+ function pick(items, target, what, label = (i) => i.name ?? i.id) {
72
+ const wanted = target.trim().toLowerCase();
73
+ const found = items.filter((i) => i.id === target || i.name?.toLowerCase() === wanted);
74
+ if (found.length === 1)
75
+ return found[0];
76
+ throw invalid(found.length
77
+ ? `"${target}" names ${found.length}: ${same(found.map(label)) ? found.map((i) => `${label(i)} (${i.id})`).join(", ") : found.map(label).join(", ")}`
78
+ : `no ${what} "${target}"; have: ${items.map(label).join(", ") || "none"}`);
79
+ }
80
+ const read = async () => snapshotFrom(await base.get(), "");
81
+ /** The CLIP body for `change`, checked against what the light (or a group's lights) can take. Throws before anything is sent. */
82
+ function bodyFor(change, name, members, isOn) {
83
+ const { on, brightness, color, kelvin, transitionMs } = change;
84
+ const look = brightness !== undefined || color !== undefined || kelvin !== undefined;
85
+ if (on === undefined && !look)
86
+ throw invalid("nothing to change");
87
+ if (on === false && look)
88
+ throw invalid("off takes no brightness or colour");
89
+ if (color !== undefined && kelvin !== undefined)
90
+ throw invalid("a colour or a white temperature, not both");
91
+ const body = {};
92
+ if (on !== undefined)
93
+ body.on = { on };
94
+ else if (isOn !== true)
95
+ body.on = { on: true }; // off, or a group where some may be
96
+ if (brightness !== undefined) {
97
+ if (!(brightness >= 1 && brightness <= 100))
98
+ throw invalid(`brightness is 1–100, not ${brightness}`);
99
+ body.dimming = { brightness };
100
+ }
101
+ if (color !== undefined) {
102
+ const gamut = members.find((l) => l.gamut)?.gamut;
103
+ if (!gamut)
104
+ throw invalid(`${name} has no colour light`);
105
+ try {
106
+ body.color = { xy: colorToXy(color, gamut) };
107
+ }
108
+ catch (error) {
109
+ throw invalid(error.message); // a bad colour, as every other change that can't be made
110
+ }
111
+ }
112
+ if (kelvin !== undefined) {
113
+ const ranges = members.flatMap((l) => l.kelvinRange ? [l.kelvinRange] : []);
114
+ if (!ranges.length)
115
+ throw invalid(`${name} takes no white temperature`);
116
+ const low = Math.min(...ranges.map((r) => r[0]));
117
+ const high = Math.max(...ranges.map((r) => r[1]));
118
+ if (!(kelvin >= low && kelvin <= high))
119
+ throw invalid(`${kelvin} K is outside ${name}'s range, ${low}–${high} K`);
120
+ body.color_temperature = { mirek: kelvinToMirek(kelvin) };
121
+ }
122
+ if (transitionMs !== undefined) {
123
+ if (!(Number.isInteger(transitionMs) && transitionMs >= 0))
124
+ throw invalid(`transitionMs is a whole number of ms, not ${transitionMs}`);
125
+ body.dynamics = { duration: transitionMs };
126
+ }
127
+ return body;
128
+ }
129
+ /** 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. */
130
+ async function confirm(view, asked) {
131
+ const until = clock.now() + CONFIRM_MS;
132
+ let state;
133
+ let failed;
134
+ for (;;) {
135
+ try {
136
+ state = (await view()) ?? state;
137
+ if (state !== undefined && asked(state))
138
+ break;
139
+ }
140
+ catch (error) {
141
+ failed = error;
142
+ }
143
+ if (clock.now() >= until)
144
+ break;
145
+ await clock.sleep(500);
146
+ }
147
+ if (state === undefined)
148
+ throw failed ?? new HueError("sent, but it is no longer on the bridge");
149
+ return { state, confirmed: asked(state) };
150
+ }
151
+ /**
152
+ * Whether a view shows what the body asked: on, brightness within a point, and on a light the white temperature within
153
+ * 2 mirek or the colour within 0.02 in `xy`. A room reports neither, so its colour is not compared (its `on` is).
154
+ */
155
+ const shows = (body, light) => (v) => {
156
+ const on = body.on?.on;
157
+ const brightness = body.dimming
158
+ ?.brightness;
159
+ const mirek = body.color_temperature
160
+ ?.mirek;
161
+ const xy = body.color?.xy;
162
+ return ((on === undefined || v.on === on) &&
163
+ (brightness === undefined ||
164
+ (v.brightness !== undefined &&
165
+ Math.abs(v.brightness - brightness) <= 1)) &&
166
+ (!light ||
167
+ mirek === undefined ||
168
+ (v.kelvin !== undefined &&
169
+ Math.abs(kelvinToMirek(v.kelvin) - mirek) <= 2)) &&
170
+ (!light ||
171
+ xy === undefined ||
172
+ (v.xy !== undefined &&
173
+ Math.abs(v.xy.x - xy.x) <= 0.02 &&
174
+ Math.abs(v.xy.y - xy.y) <= 0.02)));
175
+ };
176
+ /** Send, or with `dryRun` say what would be sent and how `now` stands. */
177
+ async function send(options, kind, id, body, now, after) {
178
+ if (options?.dryRun) {
179
+ if (now === undefined)
180
+ throw new HueError("its room or zone is not on the bridge");
181
+ return {
182
+ state: now,
183
+ confirmed: false,
184
+ wouldSend: { method: "PUT", path: `${kind}/${id}`, body },
185
+ };
186
+ }
187
+ await put(kind, id, body);
188
+ return after();
189
+ }
190
+ return {
191
+ ...base,
192
+ /** Change one light, by name or id. Only when a person asks. Resolves with the light as the bridge then reported it. */
193
+ async light(target, change, options) {
194
+ const l = pick(lights(await read()), target, "light");
195
+ // The bridge takes a write to a light it has lost and answers "may not have effect": say so before sending.
196
+ if (l.reachable === false)
197
+ throw new HueError(`${l.name ?? l.id} is unreachable: the bridge has lost it (is it switched off at the wall?)`, { code: "offline" });
198
+ const body = bodyFor(change, l.name ?? l.id, [l], l.on);
199
+ return send(options, "light", l.id, body, l, () => confirm(async () => lights(await read()).find((x) => x.id === l.id), shows(body, true)));
200
+ },
201
+ /**
202
+ * Every light in the home off, with one write to the bridge's home group. Only when a person asks. Resolves with
203
+ * every light as the bridge then reported it, `confirmed` once none it can reach is on.
204
+ */
205
+ async allOff(options) {
206
+ const s = await read();
207
+ const id = homeGroupedLightId(s);
208
+ if (!id)
209
+ throw invalid("the bridge has no home group");
210
+ return send(options, "grouped_light", id, { on: { on: false } }, lights(s), () => confirm(async () => lights(await read()), (all) => all.every((l) => !l.on || l.reachable === false)));
211
+ },
212
+ /**
213
+ * Change every light in a room or zone with one write, by name or id. Only when a person asks. Resolves with the
214
+ * group as the bridge then reported it; its brightness is the bridge's figure for the group, so a group holding a
215
+ * light that is off or unreachable may stay unconfirmed.
216
+ */
217
+ async room(target, change, options) {
218
+ const s = await read();
219
+ const g = pick(groups(s), target, "room or zone");
220
+ const name = g.name ?? g.id;
221
+ if (!g.groupedLightId)
222
+ throw invalid(`${name} has no lights`);
223
+ const members = lights(s).filter((l) => g.lightIds.includes(l.id));
224
+ const body = bodyFor(change, name, members, undefined);
225
+ return send(options, "grouped_light", g.groupedLightId, body, g, () => confirm(async () => groups(await read()).find((x) => x.id === g.id), shows(body, false)));
226
+ },
227
+ /**
228
+ * Recall a scene by name or id. A name several rooms use ("Relax") needs `room`. Only when a person asks. Resolves
229
+ * with the scene's room or zone, `confirmed` once the bridge reports the scene active.
230
+ */
231
+ async scene(target, room, options) {
232
+ const s = await read();
233
+ const all = groups(s);
234
+ const within = room === undefined ? undefined : pick(all, room, "room or zone");
235
+ const roomName = (id) => all.find((g) => g.id === id)?.name ?? "no room";
236
+ const sc = pick(scenes(s).filter((x) => !within || x.groupId === within.id), target, "scene", (x) => `${x.name ?? x.id} in ${roomName(x.groupId)}`);
237
+ let active = false;
238
+ return send(options, "scene", sc.id, { recall: { action: "active" } }, all.find((g) => g.id === sc.groupId), () => confirm(async () => {
239
+ const now = await read();
240
+ active = scenes(now).find((x) => x.id === sc.id)?.active === true;
241
+ return groups(now).find((x) => x.id === sc.groupId);
242
+ }, () => active));
243
+ },
244
+ };
60
245
  }
61
246
  /** Ask the bridge for an application key. One call: throws `HueError` with code "link_button" until KO presses it. */
62
247
  export async function pair(options) {
@@ -67,7 +252,7 @@ export async function pair(options) {
67
252
  devicetype: options.deviceType,
68
253
  generateclientkey: true,
69
254
  }),
70
- });
255
+ }, { signal: options.signal });
71
256
  const parsed = PairResponse.safeParse(await res.json().catch(() => undefined));
72
257
  if (!parsed.success)
73
258
  throw new HueError(`unexpected answer to POST /api (HTTP ${res.status})`, {
@@ -83,10 +268,36 @@ export async function pair(options) {
83
268
  });
84
269
  }
85
270
  /**
86
- * A `fetch` that trusts only `ca` and accepts only a certificate whose CN is `bridgeId`.
271
+ * `pair`, asked again every `everyMs` (2 s) until someone presses the bridge's button, for `timeoutMs` (30 s, as long as
272
+ * the button stays pressed). `onWaiting` is called once, when the bridge first says the button isn't pressed: the moment
273
+ * to tell the person. Throws the last `link_button` error when time runs out, any other error at once, and the signal's
274
+ * reason when aborted.
275
+ */
276
+ export async function pairWhenPressed(options) {
277
+ const clock = options.clock ?? realClock;
278
+ const until = clock.now() + (options.timeoutMs ?? 30_000);
279
+ let waiting = false;
280
+ while (true) {
281
+ try {
282
+ return await pair(options);
283
+ }
284
+ catch (error) {
285
+ const pressed = !(error instanceof HueError && error.code === "link_button");
286
+ if (pressed || clock.now() >= until)
287
+ throw error;
288
+ if (!waiting)
289
+ options.onWaiting?.();
290
+ waiting = true;
291
+ }
292
+ await clock.sleep(options.everyMs ?? 2000, options.signal);
293
+ options.signal?.throwIfAborted();
294
+ }
295
+ }
296
+ /**
297
+ * 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`.
87
298
  * 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`.
88
299
  */
89
- export function bunPinnedFetch(bridgeId, ca) {
300
+ export function bunPinnedFetch(bridgeId, ca = HUE_BRIDGE_ROOT_CA) {
90
301
  const checkServerIdentity = (_host, cert) => cert.subject?.CN?.toLowerCase() === bridgeId.toLowerCase()
91
302
  ? undefined
92
303
  : new Error(`Hue bridge certificate is for ${cert.subject?.CN ?? "nobody"}, expected ${bridgeId}`);
@@ -0,0 +1,30 @@
1
+ export type XY = {
2
+ x: number;
3
+ y: number;
4
+ };
5
+ export type Gamut = {
6
+ red: XY;
7
+ green: XY;
8
+ blue: XY;
9
+ };
10
+ export type Rgb = {
11
+ r: number;
12
+ g: number;
13
+ b: number;
14
+ };
15
+ /** A colour as a caller gives it: "#ff8800", "ff8800", "#f80", or `{ r, g, b }` each 0–255. Throws on anything else. */
16
+ export declare function parseColor(color: string | Rgb): Rgb;
17
+ /** Whether `p` is inside the gamut triangle. */
18
+ export declare function inGamut(p: XY, g: Gamut): boolean;
19
+ /** `p` if the light can show it, else the nearest point on its gamut's edge: the colour it can show that is closest. */
20
+ export declare function nearestInGamut(p: XY, g: Gamut): XY;
21
+ /**
22
+ * A colour's `xy`, moved inside `gamut` when one is given. Brightness is not part of it: black has no colour and throws
23
+ * (turn the light off instead), and a dark red and a bright red are the same point.
24
+ */
25
+ export declare function colorToXy(color: string | Rgb, gamut?: Gamut): XY;
26
+ /** An `xy` as a hex colour for a screen, at full brightness: what the light's colour looks like, not how bright it is. */
27
+ export declare function xyToHex(xy: XY): string;
28
+ /** Colour temperature: Hue counts in mirek, a million over the kelvin. 2700 K is 370 mirek. */
29
+ export declare const kelvinToMirek: (kelvin: number) => number;
30
+ export declare const mirekToKelvin: (mirek: number) => number;
@@ -0,0 +1,92 @@
1
+ import { ConnectorError } from "../../connector.js";
2
+ /** A colour as a caller gives it: "#ff8800", "ff8800", "#f80", or `{ r, g, b }` each 0–255. Throws on anything else. */
3
+ export function parseColor(color) {
4
+ if (typeof color !== "string") {
5
+ const ok = [color.r, color.g, color.b].every((v) => Number.isFinite(v) && v >= 0 && v <= 255);
6
+ if (!ok)
7
+ throw new ConnectorError("a colour's r, g and b are each 0–255", {
8
+ code: "invalid",
9
+ });
10
+ return color;
11
+ }
12
+ const hex = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(color.trim())?.[1];
13
+ if (!hex)
14
+ throw new ConnectorError(`"${color}" is not a colour like #ff8800`, {
15
+ code: "invalid",
16
+ });
17
+ const full = hex.length === 3 ? [...hex].map((c) => c + c).join("") : hex;
18
+ const n = Number.parseInt(full, 16);
19
+ return { r: n >> 16, g: (n >> 8) & 255, b: n & 255 };
20
+ }
21
+ const linear = (v) => v > 0.04045 ? ((v + 0.055) / 1.055) ** 2.4 : v / 12.92;
22
+ const gamma = (v) => v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055;
23
+ const cross = (a, b) => a.x * b.y - a.y * b.x;
24
+ const minus = (a, b) => ({ x: a.x - b.x, y: a.y - b.y });
25
+ /** Whether `p` is inside the gamut triangle. */
26
+ export function inGamut(p, g) {
27
+ const v1 = minus(g.green, g.red);
28
+ const v2 = minus(g.blue, g.red);
29
+ const q = minus(p, g.red);
30
+ const s = cross(q, v2) / cross(v1, v2);
31
+ const t = cross(v1, q) / cross(v1, v2);
32
+ return s >= 0 && t >= 0 && s + t <= 1;
33
+ }
34
+ /** The nearest point to `p` on the segment a–b. */
35
+ function onSegment(a, b, p) {
36
+ const ab = minus(b, a);
37
+ const t = Math.min(1, Math.max(0, ((p.x - a.x) * ab.x + (p.y - a.y) * ab.y) / (ab.x ** 2 + ab.y ** 2)));
38
+ return { x: a.x + ab.x * t, y: a.y + ab.y * t };
39
+ }
40
+ /** `p` if the light can show it, else the nearest point on its gamut's edge: the colour it can show that is closest. */
41
+ export function nearestInGamut(p, g) {
42
+ if (inGamut(p, g))
43
+ return p;
44
+ const edges = [
45
+ onSegment(g.red, g.green, p),
46
+ onSegment(g.green, g.blue, p),
47
+ onSegment(g.blue, g.red, p),
48
+ ];
49
+ const far = (q) => (q.x - p.x) ** 2 + (q.y - p.y) ** 2;
50
+ return edges.reduce((best, q) => (far(q) < far(best) ? q : best));
51
+ }
52
+ /**
53
+ * A colour's `xy`, moved inside `gamut` when one is given. Brightness is not part of it: black has no colour and throws
54
+ * (turn the light off instead), and a dark red and a bright red are the same point.
55
+ */
56
+ export function colorToXy(color, gamut) {
57
+ const { r, g, b } = parseColor(color);
58
+ const [lr, lg, lb] = [r, g, b].map((v) => linear(v / 255));
59
+ const X = lr * 0.4124 + lg * 0.3576 + lb * 0.1805;
60
+ const Y = lr * 0.2126 + lg * 0.7152 + lb * 0.0722;
61
+ const Z = lr * 0.0193 + lg * 0.1192 + lb * 0.9505;
62
+ const sum = X + Y + Z;
63
+ if (sum === 0)
64
+ throw new ConnectorError("black is not a colour a light can show", {
65
+ code: "invalid",
66
+ });
67
+ const xy = { x: X / sum, y: Y / sum };
68
+ const at = gamut ? nearestInGamut(xy, gamut) : xy;
69
+ return { x: round4(at.x), y: round4(at.y) };
70
+ }
71
+ const round4 = (v) => Math.round(v * 1e4) / 1e4;
72
+ /** An `xy` as a hex colour for a screen, at full brightness: what the light's colour looks like, not how bright it is. */
73
+ export function xyToHex(xy) {
74
+ if (xy.y <= 0)
75
+ return "#000000";
76
+ const X = xy.x / xy.y;
77
+ const Z = (1 - xy.x - xy.y) / xy.y;
78
+ const rgb = [
79
+ X * 3.2406 - 1.5372 - Z * 0.4986,
80
+ -X * 0.9689 + 1.8758 + Z * 0.0415,
81
+ X * 0.0557 - 0.204 + Z * 1.057,
82
+ ].map((v) => Math.max(0, v));
83
+ const max = Math.max(...rgb);
84
+ return `#${rgb
85
+ .map((v) => Math.round(255 * Math.min(1, Math.max(0, gamma(max > 0 ? v / max : 0))))
86
+ .toString(16)
87
+ .padStart(2, "0"))
88
+ .join("")}`;
89
+ }
90
+ /** Colour temperature: Hue counts in mirek, a million over the kelvin. 2700 K is 370 mirek. */
91
+ export const kelvinToMirek = (kelvin) => Math.round(1e6 / kelvin);
92
+ export const mirekToKelvin = (mirek) => Math.round(1e6 / mirek);
@@ -7,8 +7,6 @@ export type SseMessage = {
7
7
  };
8
8
  /** Split an SSE byte stream into messages, one per blank-line-separated block. Comment lines (`:`) are skipped. */
9
9
  export declare function parseSse(body: ReadableStream<Uint8Array>): AsyncGenerator<SseMessage>;
10
- /** A 401 or 403: the key is wrong or revoked. */
11
- export declare const isRejectedKey: (error: unknown) => boolean;
12
10
  /** What the stream yields: a (re)connection, a batch of events, or a drop with the wait before the next try. */
13
11
  export type StreamItem = {
14
12
  kind: "connected";
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { realClock } from "../../clock.js";
3
+ import { isRejectedKey } from "../../connector.js";
3
4
  import { HueError } from "./client.js";
4
5
  import { HueEvent } from "./schemas.js";
5
6
  /** Split an SSE byte stream into messages, one per blank-line-separated block. Comment lines (`:`) are skipped. */
@@ -50,8 +51,6 @@ const safeJson = (text) => {
50
51
  return undefined;
51
52
  }
52
53
  };
53
- /** A 401 or 403: the key is wrong or revoked. */
54
- export const isRejectedKey = (error) => error instanceof HueError && (error.status === 401 || error.status === 403);
55
54
  /**
56
55
  * A connection with no bytes for this long is reconnected: a silent half-close otherwise looks connected forever (aiohue
57
56
  * uses 90 s). The bridge sends nothing on its own, so a quiet house trips it too; the reconnect is a silent probe.
@@ -5,7 +5,9 @@ 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
7
  import { type GroupView, type HueSnapshot, type LightView } from "./state.ts";
8
- export { bunPinnedFetch, type HueClient, HueError, type HueOptions, hueClient, pair, } from "./client.ts";
8
+ export { HUE_BRIDGE_ROOT_CA } from "./ca.ts";
9
+ export { colorToXy, type Gamut, kelvinToMirek, mirekToKelvin, type Rgb, type XY, xyToHex, } from "./color.ts";
10
+ export { bunPinnedFetch, CONFIRM_MS, type HueApplied, type HueRequest, type HueWriteOptions, type LightChange, type HueClient, HueError, type HueOptions, hueClient, pair, pairWhenPressed, } from "./client.ts";
9
11
  export type { HueEvent, Resource } from "./schemas.ts";
10
12
  export { type GroupView, groups, type HueSnapshot, homeGroupedLightId, type LightView, lights, scenes, } from "./state.ts";
11
13
  /** A Hue bridge in a home config: what it takes to reach it. Its model and software come live into the snapshot. */
@@ -14,17 +16,17 @@ export declare const configSchema: z.ZodObject<{
14
16
  host: z.ZodString;
15
17
  bridgeId: z.ZodString;
16
18
  key: z.ZodString;
17
- ca: z.ZodString;
19
+ ca: z.ZodOptional<z.ZodString>;
18
20
  }, z.core.$loose>;
19
21
  /**
20
- * Hue settings for a home. Leave out `fetch` in Bun and it pins to `ca` and `bridgeId`; in Node pass a pinned one.
22
+ * Hue settings for a home. Leave out `fetch` in Bun and it pins to `bridgeId` and `ca` (Signify's roots, `HUE_BRIDGE_ROOT_CA`, when left out); in Node pass a pinned one.
21
23
  * `find` (mDNS, matched by `bridgeId`) finds the bridge again after three failed reconnects; `idleMs` is the stream's watchdog.
22
24
  */
23
25
  export type HueConfig = {
24
26
  host: string;
25
27
  bridgeId: string;
26
28
  key: string;
27
- ca: string;
29
+ ca?: string;
28
30
  fetch?: typeof fetch;
29
31
  clock?: Clock;
30
32
  find?: Finder;
@@ -39,6 +41,15 @@ export type HueConfig = {
39
41
  export declare function hue(config: HueConfig): Integration<HueSnapshot, "hue"> & {
40
42
  readonly client: HueClient;
41
43
  };
44
+ /** A light, room or zone in a line: "Kitchen: on · 40%", "Desk: on · 40% · 2700 K", "Strip: on · 80% · #ff8800", "Kitchen: off", or the name alone when the bridge hasn't said. */
45
+ export declare const hueText: (v: {
46
+ name?: string;
47
+ id: string;
48
+ on?: boolean;
49
+ brightness?: number;
50
+ kelvin?: number;
51
+ hex?: string;
52
+ }) => string;
42
53
  /** A Hue light, or a room or zone, as a home device: `detail` is its `LightView` or `GroupView`. */
43
54
  export type HueHomeDevice = DeviceRecord<"hue", "light", "on" | "off", LightView> | DeviceRecord<"hue", "group", "on" | "off", GroupView>;
44
55
  /** Every light, then every room and zone. A light's `available` is its Zigbee link; a group's `members` are its lights. */