@khalido/home 0.1.3 → 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 (84) hide show
  1. package/.env.example +2 -2
  2. package/CHANGELOG.md +41 -0
  3. package/README.md +7 -2
  4. package/dist/config.d.ts +5 -3
  5. package/dist/config.js +5 -1
  6. package/dist/connectors/cast/index.d.ts +1 -1
  7. package/dist/connectors/cast/index.js +1 -1
  8. package/dist/connectors/cast/state.d.ts +17 -3
  9. package/dist/connectors/cast/state.js +26 -7
  10. package/dist/connectors/daikin/client.d.ts +45 -2
  11. package/dist/connectors/daikin/client.js +67 -2
  12. package/dist/connectors/daikin/index.d.ts +2 -2
  13. package/dist/connectors/daikin/index.js +4 -3
  14. package/dist/connectors/hue/ca.d.ts +2 -0
  15. package/dist/connectors/hue/ca.js +38 -0
  16. package/dist/connectors/hue/client.d.ts +81 -6
  17. package/dist/connectors/hue/client.js +217 -6
  18. package/dist/connectors/hue/color.d.ts +30 -0
  19. package/dist/connectors/hue/color.js +92 -0
  20. package/dist/connectors/hue/index.d.ts +15 -4
  21. package/dist/connectors/hue/index.js +25 -8
  22. package/dist/connectors/hue/schemas.d.ts +27 -0
  23. package/dist/connectors/hue/schemas.js +26 -0
  24. package/dist/connectors/hue/state.d.ts +10 -1
  25. package/dist/connectors/hue/state.js +20 -1
  26. package/dist/connectors/spotify/changes.d.ts +19 -5
  27. package/dist/connectors/spotify/changes.js +29 -7
  28. package/dist/connectors/spotify/index.d.ts +1 -1
  29. package/dist/connectors/spotify/index.js +8 -2
  30. package/dist/devices.d.ts +24 -0
  31. package/dist/devices.js +56 -2
  32. package/dist/discover.js +1 -1
  33. package/dist/finders-entry.d.ts +1 -1
  34. package/dist/finders-entry.js +1 -1
  35. package/dist/finders.d.ts +2 -1
  36. package/dist/finders.js +16 -5
  37. package/dist/home.d.ts +11 -2
  38. package/dist/home.js +98 -34
  39. package/dist/index.d.ts +1 -1
  40. package/dist/index.js +1 -1
  41. package/dist/node-fetch.d.ts +2 -2
  42. package/dist/node-fetch.js +3 -2
  43. package/docs/cli.md +13 -7
  44. package/docs/config.md +11 -3
  45. package/docs/decisions.md +12 -0
  46. package/docs/hue.md +161 -0
  47. package/docs/using.md +98 -3
  48. package/examples/home.jsonc +2 -1
  49. package/home.schema.json +10 -3
  50. package/llms.txt +19 -6
  51. package/package.json +1 -1
  52. package/skills/draw-home/SKILL.md +1 -1
  53. package/skills/use-home/SKILL.md +3 -3
  54. package/src/cli/cast.ts +51 -5
  55. package/src/cli/context.ts +2 -0
  56. package/src/cli/daikin.ts +71 -0
  57. package/src/cli/hue.ts +147 -59
  58. package/src/cli/main.ts +35 -10
  59. package/src/cli/probe.ts +21 -17
  60. package/src/cli/status.ts +61 -10
  61. package/src/config.ts +8 -2
  62. package/src/connectors/cast/README.md +6 -3
  63. package/src/connectors/cast/index.ts +7 -1
  64. package/src/connectors/cast/state.ts +32 -5
  65. package/src/connectors/daikin/README.md +16 -4
  66. package/src/connectors/daikin/client.ts +104 -3
  67. package/src/connectors/daikin/index.ts +8 -2
  68. package/src/connectors/hue/README.md +62 -17
  69. package/src/connectors/hue/ca.ts +39 -0
  70. package/src/connectors/hue/client.ts +362 -15
  71. package/src/connectors/hue/color.ts +123 -0
  72. package/src/connectors/hue/index.ts +44 -11
  73. package/src/connectors/hue/schemas.ts +26 -0
  74. package/src/connectors/hue/state.ts +27 -1
  75. package/src/connectors/spotify/README.md +8 -2
  76. package/src/connectors/spotify/changes.ts +38 -9
  77. package/src/connectors/spotify/index.ts +9 -1
  78. package/src/devices.ts +83 -3
  79. package/src/discover.ts +1 -2
  80. package/src/finders-entry.ts +1 -1
  81. package/src/finders.ts +18 -6
  82. package/src/home.ts +99 -39
  83. package/src/index.ts +7 -1
  84. package/src/node-fetch.ts +6 -2
@@ -325,10 +325,10 @@ export function castStatus(s: CastSnapshot): CastStatus[] {
325
325
  return castDevices(s).map(one);
326
326
  }
327
327
 
328
- /** A target `castFree` left out: `standby` is a TV that is off, which a clip may wake. */
328
+ /** 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. */
329
329
  export type CastSkipped = {
330
330
  name: string;
331
- why: "busy" | "offline" | "standby" | "unknown";
331
+ why: "busy" | "offline" | "standby" | "unknown" | "form";
332
332
  app?: string;
333
333
  };
334
334
 
@@ -340,11 +340,21 @@ export type CastSkipped = {
340
340
  * becomes its free members, named one by one (they then play out of step with each other).
341
341
  * - A device is never named beside a free group that holds it, nor two groups that share a member: the second becomes its
342
342
  * members not yet covered.
343
+ * - With `forms` (`["speaker", "display"]` keeps an announcement off TVs), a device of another form, or of a model this
344
+ * library doesn't know, is skipped with `why: "form"`, and a group holding one (or a member the snapshot doesn't have)
345
+ * becomes its other free members. An empty list leaves every device out.
343
346
  */
344
347
  export function castFree(
345
348
  s: CastSnapshot,
346
349
  targets: string[],
350
+ options: { forms?: readonly CastForm[] } = {},
347
351
  ): { free: string[]; skipped: CastSkipped[] } {
352
+ /** A device of a form asked for. A group has no form of its own: its members decide. */
353
+ const wanted = (id: string) => {
354
+ const d = s.devices[id];
355
+ if (!options.forms || d?.kind === "group") return true;
356
+ return d?.form !== undefined && options.forms.includes(d.form);
357
+ };
348
358
  const status = new Map(castStatus(s).map((c) => [c.id, c]));
349
359
  const free = new Map<string, CastStatus>();
350
360
  const covered = new Set<string>(); // members of the groups in `free`
@@ -354,15 +364,17 @@ export function castFree(
354
364
  const skip = (c: CastStatus) =>
355
365
  skipped.set(c.id, {
356
366
  name: c.name,
357
- why:
358
- c.state === "offline"
367
+ why: !wanted(c.id)
368
+ ? "form"
369
+ : c.state === "offline"
359
370
  ? "offline"
360
371
  : c.standby && c.state !== "busy"
361
372
  ? "standby"
362
373
  : "busy",
363
374
  ...(c.app ? { app: c.app } : {}),
364
375
  });
365
- const device = (c: CastStatus) => (ok(c) ? free.set(c.id, c) : skip(c));
376
+ const device = (c: CastStatus) =>
377
+ ok(c) && wanted(c.id) ? free.set(c.id, c) : skip(c);
366
378
  for (const target of targets) {
367
379
  const d = findCast(s.devices, target);
368
380
  const c = d && status.get(d.id);
@@ -376,6 +388,7 @@ export function castFree(
376
388
  else if (
377
389
  own !== "offline" &&
378
390
  up.every(ok) &&
391
+ (d.members ?? []).every(wanted) &&
379
392
  !members.some((m) => covered.has(m.id))
380
393
  ) {
381
394
  free.set(c.id, c);
@@ -390,3 +403,17 @@ export function castFree(
390
403
  skipped: [...skipped.values()],
391
404
  };
392
405
  }
406
+
407
+ /**
408
+ * Where an announcement can play right now, in one call: every speaker and display that is free, a whole group where all
409
+ * of it is, and never a TV. `castFree` over everything Cast has found, with `forms: ["speaker", "display"]`; `free` goes
410
+ * straight to `announceAll`.
411
+ */
412
+ export const castFreeSpeakers = (
413
+ s: CastSnapshot,
414
+ ): { free: string[]; skipped: CastSkipped[] } =>
415
+ castFree(
416
+ s,
417
+ castDevices(s).map((d) => d.id),
418
+ { forms: ["speaker", "display"] },
419
+ );
@@ -2,9 +2,9 @@
2
2
 
3
3
  Reads one Daikin AC over its local JSON `dsiot` API. `iot_class`: **local_polling**. KO's unit is a Daikin Australia **Alira X** with built-in Wi-Fi (likely; it reports a blank model string).
4
4
 
5
- - `client.ts`: `daikinClient({ host, fetch }).read()` returns `DaikinState` (power, mode, the selected mode's setpoint and fan speed, room °C, humidity, outside °C, `anomaly`). `setPower`, `setMode`, `setTarget`, `setFan` build write requests; `client.write(request)` sends one, through a write lane: 1 s apart and at most 20 a minute (guesses), a waiting write merging the next one field by field (`mergeWrites`). Every request times out at 10 s.
6
- - `index.ts`: `daikin({ host, fetch, clock })` reads now, then every **120 s** (Home Assistant's `update_interval`). A failed poll is `disconnected` and keeps the last reading with `asOf`; a 404 on `/dsiot/multireq` is a failed read too, saying "not a dsiot Daikin": the address may now be another device's, so discovery looks for the unit. There is no key, so nothing is terminal. Writes go through `daikin().client.write(…)`: one client for the integration's life, at the unit's current address.
7
- - `home status` reads `DAIKIN_HOST`, or a home config's address, with discovery by `mac` if the unit moves. There is no CLI command that writes.
5
+ - `client.ts`: `daikinClient({ host, fetch }).read()` returns `DaikinState` (power, mode, the selected mode's setpoint and fan speed, room °C, humidity, outside °C, `anomaly`). **`power(on)` and `mode(mode, targetC?)` are the writes most callers want.** `mode` selects the mode, turns the unit on and sets the setpoint in one request: the one given, else the library's default (`DEFAULT_TARGET_C`: cool 25, heat 27), else (auto) the unit's own; dry and fan take none. Both resolve with `{ state, confirmed }`: the unit read once a second until it reports what was asked, at most 5 s (`CONFIRM_MS`); unconfirmed is a result, not an error. A setpoint off the unit's range or a half degree throws before anything is sent. `setPower`, `setMode`, `setTarget`, `setFan` build write requests; `client.write(request)` sends one, through a write lane: 1 s apart and at most 20 a minute (guesses), a waiting write merging the next one field by field (`mergeWrites`). Every request times out at 10 s.
6
+ - `index.ts`: `daikin({ host, fetch, clock })` reads now, then every **120 s** (Home Assistant's `update_interval`). A failed poll is `disconnected` and keeps the last reading with `asOf`; a 404 on `/dsiot/multireq` is a failed read too, saying "not a dsiot Daikin": the address may now be another device's, so discovery looks for the unit. There is no key, so nothing is terminal. Writes go through `daikin().client` (`power`, `mode`, or `write` for a built request): one client for the integration's life, at the unit's current address.
7
+ - `home status` and `home daikin` read `DAIKIN_HOST`, or a home config's address (with discovery by `mac` if the unit moves); with neither, they take the first unit to answer the UDP broadcast, which adds 1.5 s. The broadcast goes out three times in that window: a single one missed KO's unit in 2 runs of 3 while mDNS searches ran beside it (2026-11-11); three found it 6 of 6. `home daikin on | off` and `home daikin cool | heat | auto [°C] | dry | fan` are the CLI writes, at `DAIKIN_HOST` or the first unit to answer the UDP broadcast, printing what the unit then reports.
8
8
 
9
9
  ## The protocol
10
10
 
@@ -39,7 +39,7 @@ One read-only request, saved with the MAC, SSID, `edid` and the unit's ASCII ids
39
39
  ## Unconfirmed
40
40
 
41
41
  - **The write lane's rates** (1 s apart, 20 a minute) are guesses. One client lives as long as the integration and reads the unit's address at each send, so a move keeps the lane's count, and a write still waiting goes to the new address.
42
- - **Writes other than power.** Only `setPower(true)` has been sent to KO's unit (2026-10-16, Bun 1.4.3, see below); mode, setpoint and fan writes are untried. The encodings come from pydaikin, which reports verified live writes on other BRP084 units. The two sources disagree in three places, plus one choice of our own, settled here as follows:
42
+ - **Writes other than power.** Only `setPower(true)` has been sent to KO's unit (2026-10-16 and 2026-11-11, see below); dry to cool at 25 °C worked (2026-11-11, below); heat, auto, fan mode and fan-speed writes are untried. The encodings come from pydaikin, which reports verified live writes on other BRP084 units. The two sources disagree in three places, plus one choice of our own, settled here as follows:
43
43
  - Setpoint width: pydaikin sends one byte, Alira two (`3200`). One byte, because the live field is one byte (`p_02` `mi 24`, `mx 40`).
44
44
  - Method: pydaikin POSTs, Alira PUTs. POST, the method reads already use.
45
45
  - Field: Alira always writes `p_03` and `p_0A`. Those are pydaikin's heat-mode setpoint and fan, and Alira reads by list position (its `[2]` and `[8]` are `p_03` and `p_0A`), so it appears written on a unit in heat. Per-mode fields here.
@@ -63,10 +63,22 @@ All setpoint and fan fields, read from `e_3001` with `md` (setpoint is one byte,
63
63
 
64
64
  Unmapped and unread: `p_27` (`0A00`, like auto), `p_34` (one signed byte, `00`, `mi FA`, `mx 00`), and the `p_05`–`p_08`, `p_20`–`p_25` fields (3- and 4-byte values, mostly `0F…`, likely schedules or limits). Meaning unknown.
65
65
 
66
+ ## Second write (2026-11-11)
67
+
68
+ `setPower(true)` at KO's request, through `daikinClient().write` and the rewritten write lane (Bun 1.4.4, from a Mac): accepted in 280 ms, and reads at once, 2 s and 5 s later all showed `on` (before: off, dry, room 25 °C). The unit was found by the UDP broadcast. Then `setMode("dry")` at KO's request: accepted in 291 ms and the unit read `on`, dry. It was already in dry, so this shows the write is accepted, not that a mode changes. `home daikin on` on the running unit printed its line and exited 0. `home probe` shows 404 on the three old `/common` and `/aircon` paths and an answer on `dsiot`, as expected for this unit.
69
+
70
+ ## First mode change and setpoint (2026-11-11)
71
+
72
+ `home daikin cool` at KO's request, the unit on in dry (Bun 1.4.4): one request with power, mode and the default setpoint; the command took 3.3 s in all, 1.5 s of it the UDP search, and printed `on · cool 25 °C`. A read some 15 s later showed the same, fan 3. So `mode` with a setpoint works on this unit, and the unit reports the change within the confirm wait. Still untried: heat, auto, fan mode, a fan-speed write, and `power(false)`.
73
+
66
74
  ## Read from a Pi (2026-11-10)
67
75
 
68
76
  `home daikin` on 0.1.2 (Bun 1.4.4; chota-bot's session): "connected · off · dry · room 26 °C · outdoor unit 28 °C". `home discover` found the unit by UDP. No write was sent.
69
77
 
78
+ ## Dry run (2026-11-12)
79
+
80
+ `power` and `mode` take `{ dryRun: true }`: the checks, one read, and `wouldSend` with what it would set; nothing sent. `home daikin cool --dry-run` and `heat --dry-run` on KO's unit (Bun 1.4.4) read it and printed 25 and 27; the unit stayed off.
81
+
70
82
  ## Sources
71
83
 
72
84
  Both Daikin protocol sources are GPL-3.0, so they are read for facts only (endpoints, field paths, value maps) and no code is copied; that keeps this package MIT.
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import type { Clock } from "../../clock.ts";
2
+ import { type Clock, realClock } from "../../clock.ts";
3
3
  import { ConnectorError, getJson } from "../../connector.ts";
4
4
  import { writeLane } from "../../lane.ts";
5
5
 
@@ -81,6 +81,37 @@ const TARGET_RANGE: Partial<Record<Mode, [number, number]>> = {
81
81
  auto: [18, 30],
82
82
  };
83
83
 
84
+ /**
85
+ * The setpoint a mode gets when the caller names none: this library's opinion (KO's: not too cold, properly warm).
86
+ * Without one, "cool" would mean whatever the remote last left. Auto keeps the unit's own.
87
+ */
88
+ export const DEFAULT_TARGET_C = {
89
+ cool: 25,
90
+ heat: 27,
91
+ } as const satisfies Partial<Record<Mode, number>>;
92
+
93
+ /**
94
+ * What a write left: the state once the unit reported the change (`confirmed`), or as it stood when `CONFIRM_MS` ran out
95
+ * without it. With `{ dryRun: true }` nothing is sent: `wouldSend` is the request and what it sets, `state` the unit as it
96
+ * reads now, and `confirmed` is false. Check `wouldSend` first.
97
+ */
98
+ export type DaikinApplied = {
99
+ state: DaikinState;
100
+ confirmed: boolean;
101
+ wouldSend?: DaikinRequest;
102
+ };
103
+ /** One write: `POST /dsiot/multireq` with `body`; `sets` is what it changes, in this library's words. */
104
+ export type DaikinRequest = {
105
+ method: "POST";
106
+ path: "/dsiot/multireq";
107
+ body: WriteRequest;
108
+ sets: { on?: boolean; mode?: Mode; targetC?: number };
109
+ };
110
+ /** `dryRun`: every check a write makes (the mode, the setpoint's range), one read for the state, and nothing sent. */
111
+ export type DaikinWriteOptions = { dryRun?: boolean };
112
+ /** How long `power` and `mode` wait for the unit to report what was asked, reading once a second. */
113
+ export const CONFIRM_MS = 5000;
114
+
84
115
  /** Little-endian hex to an integer, two's complement when `signed`: "2700" → 39, "EEFF" → -18. */
85
116
  export function hexLE(hex: string, signed = false): number | undefined {
86
117
  if (!/^(?:[0-9a-f]{2})+$/i.test(hex)) return undefined;
@@ -229,7 +260,7 @@ export function setFan(fan: Fan, mode: Mode): WriteRequest {
229
260
  return write([["e_3001", FAN[mode]!, key(FANS, fan)]]);
230
261
  }
231
262
 
232
- /** A client for one unit. Reads are op 2; `write` sends a body built by setPower, setMode, setTarget or setFan. */
263
+ /** A client for one unit. Reads are op 2. `power` and `mode` are the writes most callers want; `write` sends any body built by setPower, setMode, setTarget or setFan. */
233
264
  export function daikinClient(options: {
234
265
  host: string;
235
266
  fetch: typeof fetch;
@@ -251,7 +282,8 @@ export function daikinClient(options: {
251
282
  schema: Multi,
252
283
  error: DaikinError,
253
284
  });
254
- return {
285
+ const clock = options.clock ?? realClock;
286
+ const client = {
255
287
  /** The indoor and outdoor status in one request. A failed outdoor read leaves `outsideC` empty. */
256
288
  async read(signal?: AbortSignal): Promise<DaikinState> {
257
289
  const m = await multireq(
@@ -292,4 +324,73 @@ export function daikinClient(options: {
292
324
  );
293
325
  },
294
326
  };
327
+ /** Send `request`, then read once a second until the unit reports `asked` or `CONFIRM_MS` passes. Rejects if the write is refused, or if no read after it succeeds. */
328
+ async function apply(
329
+ request: WriteRequest,
330
+ sets: DaikinRequest["sets"],
331
+ asked: (s: DaikinState) => boolean,
332
+ options: DaikinWriteOptions | undefined,
333
+ ): Promise<DaikinApplied> {
334
+ if (options?.dryRun)
335
+ return {
336
+ state: await client.read(),
337
+ confirmed: false,
338
+ wouldSend: {
339
+ method: "POST",
340
+ path: "/dsiot/multireq",
341
+ body: request,
342
+ sets,
343
+ },
344
+ };
345
+ await client.write(request);
346
+ const until = clock.now() + CONFIRM_MS;
347
+ let state: DaikinState | undefined;
348
+ let failed: unknown;
349
+ // The write was accepted, so one dropped read must not turn it into a failure: keep reading until the time is up.
350
+ for (;;) {
351
+ try {
352
+ state = await client.read();
353
+ if (asked(state)) break;
354
+ } catch (error) {
355
+ failed = error;
356
+ }
357
+ if (clock.now() >= until) break;
358
+ await clock.sleep(1000);
359
+ }
360
+ if (!state) throw failed; // sent, but the unit answered no read since: nothing to report
361
+ return { state, confirmed: asked(state) };
362
+ }
363
+ return {
364
+ ...client,
365
+ /** Turn the unit on or off, as it was last left. Only when a person asks. Resolves with what the unit then reported. */
366
+ power: (
367
+ on: boolean,
368
+ options?: DaikinWriteOptions,
369
+ ): Promise<DaikinApplied> =>
370
+ apply(setPower(on), { on }, (s) => s.on === on, options),
371
+ /**
372
+ * Select a mode, turn the unit on and set its setpoint, in one request: `targetC`, else `DEFAULT_TARGET_C` (cool 25,
373
+ * heat 27), else (auto) the unit's own. Dry and fan take none, and a `targetC` for them throws before anything is
374
+ * sent, as one off the unit's range does. Only when a person asks. Resolves with what the unit then reported.
375
+ */
376
+ async mode(
377
+ mode: Mode,
378
+ targetC?: number,
379
+ options?: DaikinWriteOptions,
380
+ ): Promise<DaikinApplied> {
381
+ const c =
382
+ targetC ?? (DEFAULT_TARGET_C as Partial<Record<Mode, number>>)[mode];
383
+ return apply(
384
+ c === undefined
385
+ ? setMode(mode)
386
+ : mergeWrites(setMode(mode), setTarget(c, mode)),
387
+ { on: true, mode, ...(c === undefined ? {} : { targetC: c }) },
388
+ (s) =>
389
+ s.on === true &&
390
+ s.mode === mode &&
391
+ (c === undefined || s.targetC === c),
392
+ options,
393
+ );
394
+ },
395
+ };
295
396
  }
@@ -17,8 +17,13 @@ import {
17
17
  } from "./client.ts";
18
18
 
19
19
  export {
20
+ CONFIRM_MS,
21
+ type DaikinApplied,
22
+ type DaikinRequest,
23
+ type DaikinWriteOptions,
20
24
  DaikinError,
21
25
  type DaikinState,
26
+ DEFAULT_TARGET_C,
22
27
  daikinClient,
23
28
  type Fan,
24
29
  type Mode,
@@ -60,7 +65,7 @@ export type DaikinSnapshot = DaikinState & { asOf: string };
60
65
  export type DaikinClient = ReturnType<typeof daikinClient>;
61
66
 
62
67
  /**
63
- * One Daikin AC as a home integration: one read now, then every 120 s. Its writes are `client.write(setPower(true))`: one
68
+ * One Daikin AC as a home integration: one read now, then every 120 s. Its writes are `client.power(on)` and `client.mode(mode, targetC?)`: one
64
69
  * client for the integration's life, so the writes share its lane and follow the unit when it moves.
65
70
  */
66
71
  export function daikin(
@@ -148,7 +153,8 @@ export const manifest: Manifest<DaikinSnapshot> = {
148
153
  {
149
154
  name: "DAIKIN_HOST",
150
155
  example: "192.0.2.20",
151
- what: "the unit's address; `home probe` finds it by UDP broadcast",
156
+ optional: true,
157
+ what: "the unit's address; found by UDP broadcast when unset (1.5 s a run)",
152
158
  },
153
159
  ],
154
160
  lines: ({ state }) => (state ? [acText(state)] : []),
@@ -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
+ `;