@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.
- package/.env.example +2 -2
- package/CHANGELOG.md +41 -0
- package/README.md +7 -2
- package/dist/config.d.ts +5 -3
- package/dist/config.js +5 -1
- package/dist/connectors/cast/index.d.ts +1 -1
- package/dist/connectors/cast/index.js +1 -1
- package/dist/connectors/cast/state.d.ts +17 -3
- package/dist/connectors/cast/state.js +26 -7
- package/dist/connectors/daikin/client.d.ts +45 -2
- package/dist/connectors/daikin/client.js +67 -2
- package/dist/connectors/daikin/index.d.ts +2 -2
- package/dist/connectors/daikin/index.js +4 -3
- package/dist/connectors/hue/ca.d.ts +2 -0
- package/dist/connectors/hue/ca.js +38 -0
- package/dist/connectors/hue/client.d.ts +81 -6
- package/dist/connectors/hue/client.js +217 -6
- package/dist/connectors/hue/color.d.ts +30 -0
- package/dist/connectors/hue/color.js +92 -0
- package/dist/connectors/hue/index.d.ts +15 -4
- package/dist/connectors/hue/index.js +25 -8
- package/dist/connectors/hue/schemas.d.ts +27 -0
- package/dist/connectors/hue/schemas.js +26 -0
- package/dist/connectors/hue/state.d.ts +10 -1
- package/dist/connectors/hue/state.js +20 -1
- package/dist/connectors/spotify/changes.d.ts +19 -5
- package/dist/connectors/spotify/changes.js +29 -7
- package/dist/connectors/spotify/index.d.ts +1 -1
- package/dist/connectors/spotify/index.js +8 -2
- package/dist/devices.d.ts +24 -0
- package/dist/devices.js +56 -2
- package/dist/discover.js +1 -1
- package/dist/finders-entry.d.ts +1 -1
- package/dist/finders-entry.js +1 -1
- package/dist/finders.d.ts +2 -1
- package/dist/finders.js +16 -5
- package/dist/home.d.ts +11 -2
- package/dist/home.js +98 -34
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/node-fetch.d.ts +2 -2
- package/dist/node-fetch.js +3 -2
- package/docs/cli.md +13 -7
- package/docs/config.md +11 -3
- package/docs/decisions.md +12 -0
- package/docs/hue.md +161 -0
- package/docs/using.md +98 -3
- package/examples/home.jsonc +2 -1
- package/home.schema.json +10 -3
- package/llms.txt +19 -6
- package/package.json +1 -1
- package/skills/draw-home/SKILL.md +1 -1
- package/skills/use-home/SKILL.md +3 -3
- package/src/cli/cast.ts +51 -5
- package/src/cli/context.ts +2 -0
- package/src/cli/daikin.ts +71 -0
- package/src/cli/hue.ts +147 -59
- package/src/cli/main.ts +35 -10
- package/src/cli/probe.ts +21 -17
- package/src/cli/status.ts +61 -10
- package/src/config.ts +8 -2
- package/src/connectors/cast/README.md +6 -3
- package/src/connectors/cast/index.ts +7 -1
- package/src/connectors/cast/state.ts +32 -5
- package/src/connectors/daikin/README.md +16 -4
- package/src/connectors/daikin/client.ts +104 -3
- package/src/connectors/daikin/index.ts +8 -2
- package/src/connectors/hue/README.md +62 -17
- package/src/connectors/hue/ca.ts +39 -0
- package/src/connectors/hue/client.ts +362 -15
- package/src/connectors/hue/color.ts +123 -0
- package/src/connectors/hue/index.ts +44 -11
- package/src/connectors/hue/schemas.ts +26 -0
- package/src/connectors/hue/state.ts +27 -1
- package/src/connectors/spotify/README.md +8 -2
- package/src/connectors/spotify/changes.ts +38 -9
- package/src/connectors/spotify/index.ts +9 -1
- package/src/devices.ts +83 -3
- package/src/discover.ts +1 -2
- package/src/finders-entry.ts +1 -1
- package/src/finders.ts +18 -6
- package/src/home.ts +99 -39
- package/src/index.ts +7 -1
- package/src/node-fetch.ts +6 -2
package/dist/home.js
CHANGED
|
@@ -36,24 +36,75 @@ function stamped(part, before, now) {
|
|
|
36
36
|
}
|
|
37
37
|
/**
|
|
38
38
|
* Load a home: run each integration, keep one snapshot, call `onChange` with it and what changed after every report.
|
|
39
|
-
* `clock` stamps each part's `lastGoodAt` and `downSince`.
|
|
39
|
+
* `clock` stamps each part's `lastGoodAt` and `downSince`. `add` and `remove` change the set while it runs.
|
|
40
40
|
*/
|
|
41
41
|
export function createHome(integrations, onChange, options = {}) {
|
|
42
42
|
const clock = options.clock ?? realClock;
|
|
43
|
-
|
|
43
|
+
const list = [...integrations]; // the integrations now: `add` and `remove` change it
|
|
44
|
+
const parts = () => snapshot;
|
|
45
|
+
let snapshot = Object.fromEntries(list.map((i) => [i.name, { status: "connecting" }]));
|
|
44
46
|
const waiting = new Map(); // refresh() callers, resolved by the integration's next report
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
|
|
47
|
+
const runs = new Map(); // the ones running: before start or after its run ended, a refresh resolves at once, nothing will report
|
|
48
|
+
const latest = new WeakMap(); // each integration's newest run: a report from an older run of the same object is dropped
|
|
49
|
+
let started; // start()'s signal, while it runs
|
|
50
|
+
let changing = Promise.resolve(); // add and remove, one at a time: two on one name must not overlap
|
|
51
|
+
let changes = 0; // those under way or queued: start outlasts a replace of its only integration
|
|
52
|
+
const change = (fn) => {
|
|
53
|
+
changes++;
|
|
54
|
+
const done = changing.then(fn).finally(() => changes--);
|
|
55
|
+
changing = done.catch(() => { });
|
|
56
|
+
return done;
|
|
57
|
+
};
|
|
58
|
+
const heard = (name) => {
|
|
59
|
+
for (const resolve of waiting.get(name) ?? [])
|
|
60
|
+
resolve();
|
|
61
|
+
waiting.delete(name);
|
|
62
|
+
};
|
|
63
|
+
// A report counts only from the integration now listed under its name: one replaced or removed may still be finishing.
|
|
64
|
+
const set = (i, run, reported) => {
|
|
65
|
+
const name = i.name;
|
|
66
|
+
const before = parts()[name];
|
|
67
|
+
if (latest.get(i) !== run || !list.includes(i) || !before)
|
|
68
|
+
return;
|
|
48
69
|
const part = stamped(reported, before, asOf(clock));
|
|
49
70
|
snapshot = { ...snapshot, [name]: part };
|
|
50
71
|
Promise.try(() => onChange?.(snapshot, { name, before, after: part })).catch((error) => console.error(`onChange threw on ${name}: ${errorText(error)}`)); // the caller's bug, thrown or async, not the device's: the integration keeps running
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
72
|
+
heard(name);
|
|
73
|
+
};
|
|
74
|
+
/** Run one integration until the home's signal or its own `stop` aborts. One failing marks itself unavailable. */
|
|
75
|
+
const launch = (i, signal) => {
|
|
76
|
+
const stop = new AbortController();
|
|
77
|
+
const run = { stop, done: Promise.resolve() };
|
|
78
|
+
runs.set(i, run);
|
|
79
|
+
latest.set(i, run);
|
|
80
|
+
run.done = Promise.try(() => i.run(AbortSignal.any([signal, stop.signal]), (part) => set(i, run, part)))
|
|
81
|
+
.catch((error) => set(i, run, {
|
|
82
|
+
status: "unavailable",
|
|
83
|
+
reason: "crashed",
|
|
84
|
+
detail: errorText(error),
|
|
85
|
+
state: parts()[i.name]?.state, // the last good state stays, as it does through a failed read
|
|
86
|
+
}))
|
|
87
|
+
.finally(() => {
|
|
88
|
+
if (runs.get(i) !== run)
|
|
89
|
+
return;
|
|
90
|
+
runs.delete(i);
|
|
91
|
+
if (list.includes(i))
|
|
92
|
+
heard(i.name); // nothing more will report
|
|
93
|
+
});
|
|
94
|
+
};
|
|
95
|
+
/** Take one out of the list and the snapshot, and wait for its run to end. */
|
|
96
|
+
const drop = async (name) => {
|
|
97
|
+
const at = list.findIndex((i) => i.name === name);
|
|
98
|
+
if (at < 0)
|
|
99
|
+
return false;
|
|
100
|
+
const [old] = list.splice(at, 1);
|
|
101
|
+
const { [name]: _, ...rest } = parts();
|
|
102
|
+
snapshot = rest;
|
|
103
|
+
heard(name);
|
|
104
|
+
const run = runs.get(old);
|
|
105
|
+
run?.stop.abort();
|
|
106
|
+
await run?.done;
|
|
107
|
+
return true;
|
|
57
108
|
};
|
|
58
109
|
return {
|
|
59
110
|
/** The latest snapshot. Never touches a device. */
|
|
@@ -61,19 +112,17 @@ export function createHome(integrations, onChange, options = {}) {
|
|
|
61
112
|
/** One integration's part, typed by its name: `home.part("weather")?.state?.blocks`. */
|
|
62
113
|
part: (name) => snapshot[name],
|
|
63
114
|
/** One integration by name, for its writes: `home.integration("daikin")?.client.write(setPower(true))`. */
|
|
64
|
-
integration: (name) =>
|
|
115
|
+
integration: (name) => list.find((i) => i.name === name),
|
|
65
116
|
/**
|
|
66
117
|
* Read now: one integration by name, or every one. Results arrive through the snapshot and `onChange` as usual; the
|
|
67
118
|
* promise resolves once each target has reported (its next update, good or bad), or at once for one that isn't running.
|
|
68
119
|
*/
|
|
69
120
|
refresh(name) {
|
|
70
|
-
const targets = name === undefined
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
if (!targets.length)
|
|
74
|
-
throw new Error(`no integration "${name}"; have ${integrations.map((i) => i.name).join(", ")}`);
|
|
121
|
+
const targets = name === undefined ? [...list] : list.filter((i) => i.name === name);
|
|
122
|
+
if (!targets.length && name !== undefined)
|
|
123
|
+
throw new Error(`no integration "${name}"; have ${list.map((i) => i.name).join(", ")}`);
|
|
75
124
|
const reported = targets.map((i) => new Promise((resolve) => {
|
|
76
|
-
if (
|
|
125
|
+
if (!runs.has(i))
|
|
77
126
|
return resolve();
|
|
78
127
|
waiting.set(i.name, [...(waiting.get(i.name) ?? []), resolve]);
|
|
79
128
|
}));
|
|
@@ -81,29 +130,44 @@ export function createHome(integrations, onChange, options = {}) {
|
|
|
81
130
|
i.refresh();
|
|
82
131
|
return Promise.all(reported).then(() => undefined);
|
|
83
132
|
},
|
|
84
|
-
/**
|
|
133
|
+
/**
|
|
134
|
+
* Add an integration while the home runs, or before it starts: a bridge paired just now, a key that arrived late. One
|
|
135
|
+
* already there under that name is stopped first and its part dropped, so the new one starts from `connecting` and
|
|
136
|
+
* the two never run together. Resolves once the new one is running; its reports arrive as any other's. Calls on
|
|
137
|
+
* one home run one at a time. Once `start`'s signal has aborted, an added one waits for the next `start`.
|
|
138
|
+
*/
|
|
139
|
+
add: (integration) => change(async () => {
|
|
140
|
+
await drop(integration.name);
|
|
141
|
+
list.push(integration);
|
|
142
|
+
const live = started && !started.aborted ? started : undefined;
|
|
143
|
+
snapshot = {
|
|
144
|
+
...snapshot,
|
|
145
|
+
[integration.name]: {
|
|
146
|
+
status: "connecting",
|
|
147
|
+
...(live ? { downSince: asOf(clock) } : {}),
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
if (live)
|
|
151
|
+
launch(integration, live);
|
|
152
|
+
}),
|
|
153
|
+
/** Stop one integration and drop its part from the snapshot. Resolves once its run has ended; false if there was none by that name. */
|
|
154
|
+
remove: (name) => change(() => drop(name)),
|
|
155
|
+
/** Run every integration, and any added meanwhile, until `signal` aborts. One failing marks itself unavailable; the rest carry on. */
|
|
85
156
|
async start(signal) {
|
|
86
|
-
|
|
157
|
+
started = signal;
|
|
87
158
|
const now = asOf(clock); // the wait for a first report counts from here, not from createHome
|
|
88
|
-
snapshot = Object.fromEntries(Object.entries(
|
|
159
|
+
snapshot = Object.fromEntries(Object.entries(parts()).map(([name, p]) => [
|
|
89
160
|
name,
|
|
90
161
|
p.status === "connecting"
|
|
91
162
|
? { ...p, downSince: p.downSince ?? now }
|
|
92
163
|
: p,
|
|
93
164
|
]));
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}))
|
|
101
|
-
.finally(() => {
|
|
102
|
-
finished.add(i.name);
|
|
103
|
-
for (const resolve of waiting.get(i.name) ?? [])
|
|
104
|
-
resolve(); // nothing more will report
|
|
105
|
-
waiting.delete(i.name);
|
|
106
|
-
})));
|
|
165
|
+
for (const i of list)
|
|
166
|
+
launch(i, signal); // a second start, after the first has stopped, runs them all again
|
|
167
|
+
// Until every run has ended, those added meanwhile too.
|
|
168
|
+
while (runs.size || changes)
|
|
169
|
+
await Promise.all([...[...runs.values()].map((r) => r.done), changing]);
|
|
170
|
+
// `started` stays: one added after every run has ended still runs, until the signal aborts.
|
|
107
171
|
},
|
|
108
172
|
};
|
|
109
173
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { type Change, createHome, type DeviceRecord, deviceRecords, envName, filePath, settle, type HomeSnapshot, type Integration, type Part, type Reason, type StateOf, type Status, } from "./home.ts";
|
|
2
2
|
export { DOWN_POLLS, type Health, health, PUSH_DOWN_MS, STALE_POLLS, } from "./health.ts";
|
|
3
|
-
export { type HomeDevice, homeDevices } from "./devices.ts";
|
|
3
|
+
export { byRoom, type HomeDevice, homeDevices, type RoomDevices, spotifyElsewhere, } from "./devices.ts";
|
|
4
4
|
export { asOf, type Clock, realClock } from "./clock.ts";
|
|
5
5
|
export { type ConnectorCode, ConnectorError } from "./connector.ts";
|
|
6
6
|
export { bestWindow, type EnergyNow, energyNow, gridText, type PriceWindow, solarLevel, } from "./energy.ts";
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Each connector has its own entry point (`@khalido/home/hue` and so on). Every export is named; test/exports.test.ts lists them.
|
|
3
3
|
export { createHome, deviceRecords, envName, filePath, settle, } from "./home.js";
|
|
4
4
|
export { DOWN_POLLS, health, PUSH_DOWN_MS, STALE_POLLS, } from "./health.js";
|
|
5
|
-
export { homeDevices } from "./devices.js";
|
|
5
|
+
export { byRoom, homeDevices, spotifyElsewhere, } from "./devices.js";
|
|
6
6
|
export { asOf, realClock } from "./clock.js";
|
|
7
7
|
export { ConnectorError } from "./connector.js";
|
|
8
8
|
export { bestWindow, energyNow, gridText, solarLevel, } from "./energy.js";
|
package/dist/node-fetch.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
/** A `fetch` that trusts only `ca` and accepts only a certificate whose CN is `bridgeId`: Node's `bunPinnedFetch`. */
|
|
2
|
-
export declare function nodePinnedFetch(bridgeId: string, ca
|
|
1
|
+
/** A `fetch` that trusts only `ca` (Signify's roots when left out) and accepts only a certificate whose CN is `bridgeId`: Node's `bunPinnedFetch`. */
|
|
2
|
+
export declare function nodePinnedFetch(bridgeId: string, ca?: string): typeof fetch;
|
|
3
3
|
/** A `fetch` for the Enphase gateway that skips certificate verification, for its requests only: Node's `bunGatewayFetch`. */
|
|
4
4
|
export declare function nodeGatewayFetch(): typeof fetch;
|
|
5
5
|
/** What `loadHome` takes as `tls` under Node. */
|
package/dist/node-fetch.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { Agent, fetch as undiciFetch } from "undici";
|
|
2
|
+
import { HUE_BRIDGE_ROOT_CA } from "./connectors/hue/ca.js";
|
|
2
3
|
// `@khalido/home/node`: the fetches Hue and Enphase need under Node, where Bun's `tls` fetch option does not exist. Node's
|
|
3
4
|
// global `fetch` takes no CA, so these use undici's own `fetch` and `Agent` (one undici on both sides). Outside the
|
|
4
5
|
// runtime-neutral build, like cast/tls.ts; a Node consumer passes `NODE_TLS` to `loadHome`, or one fetch to `hue()` or `enphase()`.
|
|
5
6
|
/** undici's `fetch` through `dispatcher`. Takes a URL, not a `Request`, and returns undici's own `Response`. */
|
|
6
7
|
const through = (dispatcher) => ((input, init) => undiciFetch(input, { ...init, dispatcher }));
|
|
7
|
-
/** A `fetch` that trusts only `ca` and accepts only a certificate whose CN is `bridgeId`: Node's `bunPinnedFetch`. */
|
|
8
|
-
export function nodePinnedFetch(bridgeId, ca) {
|
|
8
|
+
/** A `fetch` that trusts only `ca` (Signify's roots when left out) and accepts only a certificate whose CN is `bridgeId`: Node's `bunPinnedFetch`. */
|
|
9
|
+
export function nodePinnedFetch(bridgeId, ca = HUE_BRIDGE_ROOT_CA) {
|
|
9
10
|
const checkServerIdentity = (_host, cert) => {
|
|
10
11
|
const cn = [cert.subject?.CN].flat()[0];
|
|
11
12
|
return cn?.toLowerCase() === bridgeId.toLowerCase()
|
package/docs/cli.md
CHANGED
|
@@ -3,11 +3,16 @@
|
|
|
3
3
|
```sh
|
|
4
4
|
home <connector> [--json] # one connector's lines: amber, cast, daikin, enphase, hue, printers, spotify, up, weather
|
|
5
5
|
home [status|doctor] [--json] # each integration: connected or not, which env names it still needs, rooms
|
|
6
|
+
home devices [--json] # every device the connectors read, one line each: its state, its room (with a config), and why when it isn't available
|
|
6
7
|
home up transactions | categories | uncategorised | spend | categorise | tag | webhooks # Up Bank's lists and reports; categorise and tag write
|
|
7
|
-
home lights off # one write to the whole-home group
|
|
8
|
-
home
|
|
9
|
-
home
|
|
10
|
-
home
|
|
8
|
+
home lights off # one write to the whole-home group; prints how many lights are off, or how many the bridge still reports on
|
|
9
|
+
home light <name> [on|off] [<1-100>] [<kelvin>k] [<hex colour>] # one light: `home light "Desk lamp" 40 2700k`, `home light Shelf ff8800`
|
|
10
|
+
home room <name> [on|off] [<1-100>] [<kelvin>k] [<hex colour>] # every light in a room or zone, one write
|
|
11
|
+
home scene <name> [in <room>] # a scene; name the room when several have one by that name
|
|
12
|
+
home pair # waits 60 s for the bridge's button, counting down; prints the env lines to set
|
|
13
|
+
home daikin on | off # power only: the AC comes back as it was last left
|
|
14
|
+
home daikin cool | heat | auto [°C] | dry | fan # mode, on, and the setpoint: the one given, else cool 25 and heat 27; prints what the unit then reports. Live on KO's unit: on, and dry to cool at 25 (2026-11-11); the rest untried
|
|
15
|
+
# --dry-run on a hue or daikin write (lights, light, room, scene, daikin …): check it and print what would be sent; nothing is sent. Refused on every other command
|
|
11
16
|
home enphase login # once a year: mints the Enphase owner token into ~/.config/home/ (0600)
|
|
12
17
|
home discover [--json] [--passes n] # which smart-home devices answer here (mDNS + Daikin UDP), which this package supports, then with a config how they match it; two passes 5 s apart
|
|
13
18
|
home cache [clear] # the addresses kept between runs, or delete them so the next run discovers afresh
|
|
@@ -19,6 +24,7 @@ home cast announce --to <name> [--to <name>…] (--say "<text>" | --file <path>)
|
|
|
19
24
|
home cast volume <name> <0-100>
|
|
20
25
|
home cast mute <name> on|off
|
|
21
26
|
home cast stop <name> # end the running app; the device returns to its idle screen
|
|
27
|
+
# volume, mute and stop print the device's line once it reports the change (within 2 s), else "sent; the device has not reported it yet"
|
|
22
28
|
home spotify activate | pause | play [link] | next | volume <0-100> # the Soloist speaker at SOLOIST_WS; volume held to SOLOIST_MAX_VOLUME; prints what Soloist then reported
|
|
23
29
|
```
|
|
24
30
|
|
|
@@ -26,16 +32,16 @@ home spotify activate | pause | play [link] | next | volume <0-100> # the Soloi
|
|
|
26
32
|
|
|
27
33
|
- **stdout is data, stderr everything else.** Notes such as "nothing to categorise" or "no webhooks" go to stderr, so `home … | jq` never sees them.
|
|
28
34
|
- **Bun on PATH.** The command starts with `#!/usr/bin/env bun`. A non-login SSH shell may not have `~/.bun/bin` on its PATH: run `PATH="$HOME/.bun/bin:$PATH" home`.
|
|
29
|
-
- **`--json` shapes.** `home --json` is one object keyed by connector, with `now` (`energyNow`) first. `home <connector> --json` is that connector's object: `status`, `needs`, `access`, `detail`, `reason` (why it is `unavailable`, when known: `auth`, `config` or `crashed`), `anomaly`, then its state (the weather adds `line`, its text). Lists (`home up transactions`, `uncategorised`, `spend --by merchant`) are arrays, `[]` when empty.
|
|
35
|
+
- **`--json` shapes.** `home --json` is one object keyed by connector, with `now` (`energyNow`) first. `home devices --json` is the array `homeDevices` returns; a connector that isn't connected is named on stderr, and the command exits 0. `home <connector> --json` is that connector's object: `status`, `needs`, `access`, `detail`, `reason` (why it is `unavailable`, when known: `auth`, `config` or `crashed`), `anomaly`, then its state (the weather adds `line`, its text). Lists (`home up transactions`, `uncategorised`, `spend --by merchant`) are arrays, `[]` when empty.
|
|
30
36
|
- **Errors under `--json`** are one object on stderr: `{"error": "…", "code": "usage" | "auth" | "network" | "error", "fix": "home enphase login"}`; `fix` is there only when one command resolves it. In text: `home: <error> (run \`<fix>\`)`.
|
|
31
37
|
- **Exit codes.** 0 success, empty results included. 1 a failure, and `home <connector>` when that connector is not `connected`, so a script can test one. 2 a wrong command, verb or argument. `home` alone lists every connector and exits 0 whatever their state.
|
|
32
38
|
|
|
33
39
|
With `--config <home.jsonc>` or `HOME_CONFIG`, `status` runs the integrations the config describes until each has answered (at most 15 s) and `probe` takes its addresses from it; keys still come from the shell, by the names in the file. Without one, it reads `HUE_*` and the other names in `.env.example` from the shell, and parses arguments with [cac](https://github.com/cacjs/cac), which also generates `home --help`. Every connector has a verb of its own: `home amber`, `home cast`, `home hue` print only that connector's lines (`--json` for its record), and the verbs that need more sit under it (`home hue pair`, `home enphase login`, `home cast announce …`). The commands come from each connector's manifest (`src/manifest.ts`), as does `.env.example` (`bun run gen`). With no `HUE_BRIDGE_HOST` it finds the bridge by mDNS (`dns-sd`, macOS) every time and caches nothing. `home` with no arguments is `status`:
|
|
34
40
|
|
|
35
41
|
```
|
|
36
|
-
hue not set up · needs HUE_APPLICATION_KEY
|
|
42
|
+
hue not set up · needs HUE_APPLICATION_KEY (local HTTPS, key from `home pair`)
|
|
37
43
|
daikin connected · off · cool 25 °C · room 24 °C · outdoor unit 19.5 °C (local HTTP JSON (dsiot), no key)
|
|
38
44
|
weather connected · Sydney - Observatory Hill (1.1 km) 20:20: 16.6 °C, feels 16.7, 93%, 23.6 mm since 9am to 20:23 · today showers rain 80%, tomorrow 14–19 °C cloudy rain 30% (bom) · cloud 100%, UV 0, 0 W/m² (open-meteo) · sun -17°, setting, rises 06:15 (BOM anonymous FTP, Open-Meteo, Google with GOOGLE_WEATHER_KEY; sun computed)
|
|
39
45
|
```
|
|
40
46
|
|
|
41
|
-
Startup is about 10 ms and 38 MB RSS for `home status` without a key; with Enphase, Amber and the Daikin live it takes 0.4–0.8 s, the devices read in parallel (Bun 1.4.2, Apple M5, measured 2026-10-13). The weather line adds 1.8–2.0 s, nearly all of it BOM's FTP (two files, one connection each). Cast takes about 0.3 s: `dns-sd` answers from the system's mDNS cache in 4–13 ms, the browse waits 200 ms of quiet for stragglers, each resolve ends on its TXT line, and seven sockets connect and answer in 50–80 ms (`home status` from a Cast-only config: 0.33–0.44 s, was 3.2 s with fixed 1.5 s windows; Bun 1.4.2, measured 2026-10-13). A configured entry discovery misses costs the second pass, 5 s more. `status` reads addresses from the environment
|
|
47
|
+
Startup is about 10 ms and 38 MB RSS for `home status` without a key; with Enphase, Amber and the Daikin live it takes 0.4–0.8 s, the devices read in parallel (Bun 1.4.2, Apple M5, measured 2026-10-13). The weather line adds 1.8–2.0 s, nearly all of it BOM's FTP (two files, one connection each). Cast takes about 0.3 s: `dns-sd` answers from the system's mDNS cache in 4–13 ms, the browse waits 200 ms of quiet for stragglers, each resolve ends on its TXT line, and seven sockets connect and answer in 50–80 ms (`home status` from a Cast-only config: 0.33–0.44 s, was 3.2 s with fixed 1.5 s windows; Bun 1.4.2, measured 2026-10-13). A configured entry discovery misses costs the second pass, 5 s more. `status` reads addresses from the environment, except Cast, which has none to read, and the Daikin, found by UDP broadcast when `DAIKIN_HOST` is unset (1.5 s); `discover` and `probe` do the other network searches. A full-screen TUI was considered (OpenTUI 0.6.2, 52 MB of native binaries) and skipped for now.
|
package/docs/config.md
CHANGED
|
@@ -7,9 +7,13 @@ import { readFileSync } from "node:fs";
|
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
8
|
import { dirname, resolve } from "node:path";
|
|
9
9
|
import { createHome, loadHome, parseJsonc } from "@khalido/home";
|
|
10
|
+
import { serveClip } from "@khalido/home/cast/serve";
|
|
11
|
+
import { tlsConnect } from "@khalido/home/cast/tls";
|
|
12
|
+
import { FINDERS, fileCache } from "@khalido/home/finders";
|
|
10
13
|
import { ftpFetch } from "@khalido/home/weather/ftp";
|
|
11
14
|
|
|
12
15
|
const path = "/path/to/home.jsonc";
|
|
16
|
+
const cachePath = "/path/to/addresses.json";
|
|
13
17
|
const readFile = (p: string) =>
|
|
14
18
|
readFileSync(
|
|
15
19
|
resolve(dirname(path), p.replace(/^~(?=\/)/, homedir())),
|
|
@@ -19,15 +23,19 @@ const home = createHome(
|
|
|
19
23
|
loadHome(parseJsonc(readFileSync(path, "utf8")), {
|
|
20
24
|
env: process.env,
|
|
21
25
|
readFile,
|
|
22
|
-
ftpFetch: ftpFetch(),
|
|
26
|
+
ftpFetch: ftpFetch(), // BOM's weather
|
|
27
|
+
finders: FINDERS, // finds Cast devices, and any device that moves
|
|
28
|
+
castConnect: tlsConnect, // Cast's socket; a config with Cast is refused without it
|
|
29
|
+
castServe: serveClip, // only to announce a clip from bytes, not a URL
|
|
30
|
+
cache: fileCache(cachePath), // addresses kept between runs
|
|
23
31
|
}),
|
|
24
32
|
);
|
|
25
33
|
```
|
|
26
34
|
|
|
27
|
-
`loadHome` runs on Bun and Node. Hue and Enphase need a fetch that handles their certificates: Bun's `tls` fetch option by default, and under Node `tls: NODE_TLS` from `@khalido/home/node`. Under Node, a config with Hue or Enphase and no `tls` is a problem `loadHome` names, not a silent failure at the first read.
|
|
35
|
+
Only `env` is always needed: `readFile` for a config that names a file, `ftpFetch` for BOM, `finders` and `castConnect` for Cast. `loadHome` runs on Bun and Node. Hue and Enphase need a fetch that handles their certificates: Bun's `tls` fetch option by default, and under Node `tls: NODE_TLS` from `@khalido/home/node`. Under Node, a config with Hue or Enphase and no `tls` is a problem `loadHome` names, not a silent failure at the first read.
|
|
28
36
|
|
|
29
37
|
- **A device entry is only how to reach it and where it is**: its identity (the Hue `bridgeId`, a Daikin's `mac`, the Enphase `serial`, a Cast `castId`), its last known `host`, a key's env name, a file's path, its `room`. What a device reports about itself (model, firmware) comes live; a note about it is a comment.
|
|
30
|
-
- **Secrets are env variable names** (`"key": "AMBER_KEY"`), resolved against the `env` the caller passes; a pasted key fails the schema.
|
|
38
|
+
- **Secrets are env variable names** (`"key": "AMBER_KEY"`), resolved against the `env` the caller passes; a pasted key fails the schema. `env` is any record, not only `process.env`: a key the app keeps in its own store (a Hue key paired from a chat) goes in as `env: { ...process.env, HUE_APPLICATION_KEY: stored }`. Files (Enphase's `token`; a Hue `ca` only to replace Signify's roots, which the package carries) are paths the caller reads, relative to the config or from `~/`, which the caller expands (`home discover --write` writes Enphase's token as `~/.config/home/enphase-token.json`); the library reads nothing.
|
|
31
39
|
- **Problems name the field**: an unknown connector, a missing env name or a device in a missing room is an error, never a skip. `checkHome` returns them all; `loadHome` throws them all. Unknown keys pass through for a consumer's own settings.
|
|
32
40
|
- **One device per connector** for now (each integration is named by its connector), except printers (one entry each, by `uuid`) and Cast: one entry per device and speaker group (`name`, the Google Home name, and `castId`, the mDNS id), all read by one `cast` integration that finds every address by discovery, since a group's moves with its leader. A `spotify` entry is only its Soloist `url` (and `maxVolume`): a socket on the same machine, with nothing to discover.
|
|
33
41
|
- **Identity, not address.** A device is recognised by the id its own discovery reports; `host` is a cache. `home discover` lists what answers (mDNS, Daikin's UDP broadcast), in two passes 5 s apart since both drop answers now and then: the first pass prints at once, the second only what it adds (`--passes 1` for a quick look; `--json` waits for both); with a config (`--config` or `HOME_CONFIG`) it then matches those against it by identity and prints new devices, moved addresses and conflicts (a different device at an entry's host). `home discover --write <file>` adds the new ones (each with an id no entry has), updates moved hosts and refreshes the `name` of a Cast entry renamed in the Google Home app through `jsonc-parser`'s edits, every comment and other line left as it was, and writes only a result that passes `checkPlan`. Nothing else writes the file; the library never does. Two devices with one id are a config problem.
|
package/docs/decisions.md
CHANGED
|
@@ -153,3 +153,15 @@ KO's decisions on this package, newest block last. They override every other doc
|
|
|
153
153
|
- **`Part.reason`, only on `unavailable`** (KO, 2026-11-09). An admin page had to match `detail` text like "pair again" to know a person must re-key. `reason` is `auth` (a key or token rejected or unusable), `config` (a setting or file is wrong) or `crashed` (a `run` threw), for code to switch on; `detail` stays the text for people. `disconnected` gets none: it already says unreachable. Additive, so a patch. `config` came a day later (KO, 2026-11-10), so "re-key", "fix a setting" and "report a bug" are three answers; `crashed` stays though nothing reads it yet. Weather has one too: each source says why it stopped (`stopReason`), the part is `auth` if any stopped source is, else `config`, and `state.sources.<name>.reason` shows a dead key while another source keeps the part `connected`.
|
|
154
154
|
- **Google's key goes in the `X-Goog-Api-Key` header, not the URL** (KO, 2026-11-10), so nothing that prints a URL can print it. Its rejection is read from the error body: a bad key and a bad latitude are both HTTP 400, and only the key's carries `API_KEY_INVALID` (live, 2026-11-10).
|
|
155
155
|
- **Every device answers the same few questions** (KO, 2026-11-10: "nice and consistent so we can easily and consistently add devices"). `DeviceRecord` is identity, availability, one word of state and one line of text, the same for a light, a speaker and a printer; `homeDevices(snapshot, config?)` lists them all. Everything particular to a kind stays in the connector's own typed row, carried as `detail`, so the common record never grows a field only one kind has. Three levels, each inside the last: the house list, the connector's typed row, the raw snapshot. Each connector exports its own typed function, listed by hand in `src/devices.ts`, and not a manifest field: `MANIFESTS` is untyped, and `detail` must narrow by `connector`. The list is read-only: a write means something different for a light, a speaker and an air conditioner, so there is no generic `turnOn`. Not done, for a 0.2.0: Hue's `reachable` becoming `available`, and its groups joining its lights in one call.
|
|
156
|
+
|
|
157
|
+
# 2026-11-11
|
|
158
|
+
|
|
159
|
+
- **Signify's two root certificates ship in the package** (KO, 2026-11-11). Signify's own page tells apps to bundle both and validate against them, and openHAB, Homebridge's tools, openhue-go and Raycast do. `ca` stays as an override. No trust-on-first-use and no way to turn validation off: self-signed bridges were updated in May 2025, and Home Assistant's `ssl=False` is what this avoids. `docs/hue.md` has the detail.
|
|
160
|
+
- **`pairWhenPressed` polls for 30 s; `pair` stays one request.** A consumer pairing from a chat wants "press the button" and then a key, not its own retry loop. 2 s between asks is this library's choice (openHAB uses 0.5 s, openhue-cli 1 s, Homebridge 15 s).
|
|
161
|
+
- **Hue discovery stays mDNS only.** Signify's cloud endpoint (`discovery.meethue.com`) would also find a bridge, but nothing here calls a cloud to reach a local device.
|
|
162
|
+
- **The Daikin's default setpoints live in the library: cool 25, heat 27** (KO, 2026-11-11: "I like having opinionated defaults"). `mode("cool")` always sets one, since the unit keeps a setpoint per mode and would otherwise give whatever the remote last left; `power(true)` is the way to get it "as it was". A caller that wants another number passes it.
|
|
163
|
+
- **Daikin's `power` and `mode` resolve with `{ state, confirmed }`**, as spotify's writes do: the unit is read until it shows what was asked, so a bot can tell "done" from "sent".
|
|
164
|
+
- **Hue's writes are by name, on the client, and read before they write** (KO, 2026-11-11, after chota asked for what the CLI had). `light`, `room` and `scene` resolve a name against a fresh read, check the change against that light's own gamut and kelvin range, send only what was asked, and resolve `{ state, confirmed }` as the Daikin's do. A read costs about 0.3 s (289 ms for 177 resources, Bun 1.4.4, 2026-11-11); it buys a miss that lists what exists and no table of bulb models. A colour off the gamut moves to the nearest the light can show, as Signify's page says to; a kelvin off the range throws, since there is no "nearest" a person would expect.
|
|
165
|
+
- **Spotify playing on another device reads as `elsewhere`, not `playing`** (KO, 2026-11-11; chota raised it three times). Soloist reports the account's playback, not the box's, so `playing` while inactive is a silent speaker. `speakerState`, `spotifyText` and `spotifyChanges` all go through one test, `playingElsewhere`. Paused while inactive is left as Soloist says: nothing live shows whose pause it is. Soloist never names the other device, so where is a join with Cast by track name (`spotifyElsewhere`), passed to `spotifyText(state, on)` by `homeDevices`, which sees both parts; the speaker's own part can't know it, so `home status` says "another Spotify device".
|
|
166
|
+
- **`home.add` replaces by name and starts the part over** (KO, 2026-11-11; chota pairs Hue from a chat). The old run ends before the new one starts, so a device never has two streams; the new part is a bare `connecting`, since the old state belonged to another key or address. No type widening: a `loadHome` home is already typed for every connector, so `add` takes the home's own integration type. `remove` fires no `onChange`: a change needs an `after`.
|
|
167
|
+
- **Dry run is an option on each write, not a separate client** (KO, 2026-11-12; chota asked, since KO's rule is no device write without his OK at the time). It runs the read and every check a real write runs, so a wrong name or an unreachable light fails the same way, and returns the request instead of sending it, in the same `{ state, confirmed }` shape with `wouldSend` added. Hue and Daikin first, chota's lights and aircon tools; Cast and Spotify when a tool needs them.
|
package/docs/hue.md
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# Hue: certificates, discovery, write rates, rooms, colour and pairing
|
|
2
|
+
|
|
3
|
+
What Signify asks of an app on the bridge's local API, what other projects do, and what this library does. Signify's developer pages need a free Hue developer login; KO read them on 2026-11-11 and the points below are from those, in our words. The connector's own file is [src/connectors/hue/README.md](../src/connectors/hue/README.md).
|
|
4
|
+
|
|
5
|
+
## Links
|
|
6
|
+
|
|
7
|
+
| Page | For | Login |
|
|
8
|
+
| --------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | ----- |
|
|
9
|
+
| [Bridge release notes](https://www.philips-hue.com/en-au/support/release-notes/bridge) | what each bridge software version changed; check after an update | no |
|
|
10
|
+
| [Using HTTPS](https://developers.meethue.com/develop/application-design-guidance/using-https/) | the two root certificates, CN check, self-signed bridges | yes |
|
|
11
|
+
| [Hue Bridge Discovery](https://developers.meethue.com/develop/application-design-guidance/hue-bridge-discovery/) | mDNS, the cloud endpoint, validating a bridge | yes |
|
|
12
|
+
| [Hue System Performance](https://developers.meethue.com/develop/application-design-guidance/hue-system-performance/) | latency, how many commands a second | yes |
|
|
13
|
+
| [Color Conversion Formulas](https://developers.meethue.com/develop/application-design-guidance/color-conversion-formulas-rgb-to-xy-and-back/) | RGB to `xy` and back, the gamuts | yes |
|
|
14
|
+
| [API v2: getting started](https://developers.meethue.com/develop/hue-api-v2/getting-started/) | pairing, the key header, resource URLs | yes |
|
|
15
|
+
| [API v2: core concepts](https://developers.meethue.com/develop/hue-api-v2/core-concepts/) | the resource model, the event stream | yes |
|
|
16
|
+
| [openhue-api](https://github.com/openhue/openhue-api) | an OpenAPI spec of CLIP v2 (Apache-2.0), no login | no |
|
|
17
|
+
| [IANA: `hue` service](https://www.iana.org/assignments/service-names-port-numbers/service-names-port-numbers.xhtml?&search=hue) | the registered mDNS service and its TXT keys | no |
|
|
18
|
+
|
|
19
|
+
## Certificates
|
|
20
|
+
|
|
21
|
+
Signify's rules ([Using HTTPS](https://developers.meethue.com/develop/application-design-guidance/using-https/)):
|
|
22
|
+
|
|
23
|
+
- The local API is HTTPS only, port 443. No fallback to HTTP: a fake bridge could ask for one and collect the key.
|
|
24
|
+
- Signify has two private root certificates for bridges: one in use, and a second it may switch to. An app bundles both and validates the bridge's certificate against them.
|
|
25
|
+
- The certificate's Common Name is the bridge id. Check it, since the certificate carries no address.
|
|
26
|
+
- Today the root signs a bridge's certificate directly. Under the second root there will likely be an intermediate, which the bridge sends in the handshake, so the two roots are still all an app carries.
|
|
27
|
+
- Self-signed certificates are gone: the few early bridges that had them were updated (the release notes put this at software 1971060010, 22 May 2025). A bridge that fails validation needs its firmware updated. Trust-on-first-use code can be removed.
|
|
28
|
+
- Turning validation off is for development only.
|
|
29
|
+
- Keep the connection alive between requests.
|
|
30
|
+
|
|
31
|
+
| Root | Valid to | SHA-256 of the DER |
|
|
32
|
+
| ---------------------------------- | ---------- | ------------------------------------------------------------------------------------------------- |
|
|
33
|
+
| `CN=root-bridge, O=Philips Hue` | 2038-01-19 | `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` |
|
|
34
|
+
| `CN=Hue Root CA 01, O=Signify Hue` | 2050-12-31 | `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` |
|
|
35
|
+
|
|
36
|
+
**What this library does.** Both roots are in the package as `HUE_BRIDGE_ROOT_CA` (`src/connectors/hue/ca.ts`), byte for byte the bundle on Signify's page (compared 2026-11-11), and a test holds the two fingerprints. `bunPinnedFetch(bridgeId)` and `nodePinnedFetch(bridgeId)` trust those two and nothing else, and accept only a certificate whose CN is the bridge id. A caller can still pass its own `ca`. There is no trust-on-first-use and no way to turn validation off.
|
|
37
|
+
|
|
38
|
+
**Live, 2026-11-11, KO's BSB002 on software 1978545030 (Bun 1.4.4), reads of `/api/0/config` only:**
|
|
39
|
+
|
|
40
|
+
- Its certificate is `CN=<bridge id>`, issued 9 April 2025 by `root-bridge`, with no intermediate.
|
|
41
|
+
- The bundle: accepted. The two roots in the other order: accepted, so Bun reads both from one PEM string. The second root alone: refused ("unable to verify the first certificate"). Another bridge id: refused by the CN check.
|
|
42
|
+
|
|
43
|
+
**Untried:** a Bridge Pro (BSB003). Others report it chains to the second root through an intermediate (hb-hue-tools, openHAB #20400); nothing here has met one. Node's fetch against a real bridge.
|
|
44
|
+
|
|
45
|
+
**What others do** (their sources read 2026-11-11):
|
|
46
|
+
|
|
47
|
+
| Project | Approach |
|
|
48
|
+
| ------------------------- | ----------------------------------------------------------------------------------------------- |
|
|
49
|
+
| Home Assistant (aiohue) | validation off (`ssl=False`) |
|
|
50
|
+
| node-hue-api, openhue-cli | validation off; node-hue-api compares the CN to the bridge id it read over that same connection |
|
|
51
|
+
| openHAB hue binding | both roots bundled; an option trusts the bridge's own certificate instead |
|
|
52
|
+
| hb-hue-tools (Homebridge) | both roots bundled, CN and serial checked against the bridge id |
|
|
53
|
+
| openhue-go | both roots bundled, chain checked, no CN check |
|
|
54
|
+
| Raycast's Hue extension | both roots bundled, CN checked; self-signed support dropped June 2025 |
|
|
55
|
+
| Hue-Sync | the first root as a file; the user points `NODE_EXTRA_CA_CERTS` at it |
|
|
56
|
+
|
|
57
|
+
Everyone who bundles carries the same bytes and cites Signify's page. Signify's page tells apps to bundle them; no licence text for the certificates was found.
|
|
58
|
+
|
|
59
|
+
## Discovery
|
|
60
|
+
|
|
61
|
+
Signify's rules ([Hue Bridge Discovery](https://developers.meethue.com/develop/application-design-guidance/hue-bridge-discovery/)):
|
|
62
|
+
|
|
63
|
+
- Discover only at first install, or when the stored address stops answering. Store the address; check it at each start.
|
|
64
|
+
- **mDNS** first: service `_hue._tcp`, with `bridgeid` and `modelid` in the TXT record. SSDP is gone.
|
|
65
|
+
- **The cloud endpoint**, `https://discovery.meethue.com`: the bridges seen from this network's public address, each with its id and LAN address. At most one request per 15 minutes per client. Empty if the bridge has never been online.
|
|
66
|
+
- **A typed address** as the fallback.
|
|
67
|
+
- Validate what was found with `GET https://<address>/api/0/config`, which needs no key: name, `bridgeid`, `modelid`, software version.
|
|
68
|
+
- Several bridges: list them by id for the person to pick, or start pairing on all and let the button press pick.
|
|
69
|
+
|
|
70
|
+
**What this library does.** mDNS only (`FINDERS.hue`), matched by bridge id. The address lives in the config or the caller's address cache; `hue()` searches again only after three failed reconnects, and follows the bridge to a new address. `home probe` is the `/api/0/config` check. A typed address is `host` in the config or `HUE_BRIDGE_HOST`.
|
|
71
|
+
|
|
72
|
+
**Not done:**
|
|
73
|
+
|
|
74
|
+
- The cloud endpoint. It would find a bridge where mDNS is blocked, at the cost of one request to Signify; nothing here calls a cloud to reach a local device. Worth adding to `home discover` only if mDNS fails someone.
|
|
75
|
+
- More than one bridge: the CLI takes the first mDNS answer. A config can list several by `bridgeId`.
|
|
76
|
+
|
|
77
|
+
## Write rates
|
|
78
|
+
|
|
79
|
+
Signify's rules ([Hue System Performance](https://developers.meethue.com/develop/application-design-guidance/hue-system-performance/)):
|
|
80
|
+
|
|
81
|
+
- **About 10 commands a second to lights, 100 ms apart; at most 1 a second to groups.** Send fewer where you can: the Zigbee network is shared with switches and the bridge's own housekeeping.
|
|
82
|
+
- The bridge turns each command into one Zigbee message per thing changed, and the network carries about 25 a second. Brightness alone is one message, brightness and colour two, those plus on/off three. So send only what changes: no `on` to a light already on.
|
|
83
|
+
- Latency to one light, wired, one hop: about 55 ms for one message, 95 ms for two, 125 ms for three.
|
|
84
|
+
- A group command is a Zigbee broadcast, limited to about one a second, and broadcasts slow single-light commands too.
|
|
85
|
+
- Past the limit the bridge buffers without saying so and latency grows to seconds; when the buffer fills it drops the command and answers an error (type 901, "Internal error, 503").
|
|
86
|
+
- Other apps share the same budget. A light several hops away is slower.
|
|
87
|
+
- Fast updates to many lights for more than a few seconds belong on the Entertainment streaming API, not this one.
|
|
88
|
+
|
|
89
|
+
**What this library does.** Two write lanes per client (`WRITE_GAP_MS` in `client.ts`): `light` writes 100 ms apart, `grouped_light` and `scene` writes 1 s apart, both Signify's figures. A write to a resource whose last write is still waiting merges into it, so a burst cannot build a backlog. Prefer one `grouped_light` or scene write over many light writes.
|
|
90
|
+
|
|
91
|
+
**Not done:** a 503 (error 901) is reported as a failed write, not retried or slowed for. Whether a merged write carries a field the light already has (an `on` to a light that is on) is the caller's to avoid. No Entertainment API.
|
|
92
|
+
|
|
93
|
+
**Transition time.** A change fades over 400 ms unless the write says otherwise, and the setting does not stick: each write that wants another time must carry it (v2: `dynamics.duration`, in ms). Right for everyday use; an app that wants a light to respond at once sends 0. Latency above is measured with 0. `transitionMs` on a light or room change sets it; without one a write takes the bridge's 400 ms.
|
|
94
|
+
|
|
95
|
+
## Rooms, zones and scenes
|
|
96
|
+
|
|
97
|
+
People group their lights into rooms in the Hue app, so a home app should use those rooms and not make its own. Signify's [groups and scenes guide](https://developers.meethue.com/develop/application-design-guidance/hue-groups-rooms-and-scene-control/) is written for API v1; the points that carry over to v2:
|
|
98
|
+
|
|
99
|
+
- **A write to a group changes its lights together**, with no popcorn effect, and costs one broadcast (the one-a-second limit above).
|
|
100
|
+
- **A light is in at most one room.** Zones (v2) are the free groupings; a light can be in several. The whole-home group is made by the bridge.
|
|
101
|
+
- **Each light does what it can:** a dimmable light ignores a colour sent to its group.
|
|
102
|
+
- **A light that is off takes no brightness or colour change.** Turn it on in the same write.
|
|
103
|
+
- **Colour has one mode at a time.** If a write carries more than one, `xy` wins over colour temperature.
|
|
104
|
+
- **A scene belongs to a room or zone and stores each light's state.** Recalling it changes only the lights that are in both.
|
|
105
|
+
|
|
106
|
+
In v2 a room or zone is a `room` or `zone` resource whose `grouped_light` service takes the writes, and the whole home is `bridge_home`.
|
|
107
|
+
|
|
108
|
+
**What this library does.** Reads rooms, zones (`groups(state)`, each with its lights and its grouped light's on/off and brightness), the whole-home group (`homeGroupedLightId`) and scenes (`scenes(state)`). The config's `hueRoom` ties a Hue room to a room on the plan, and `homeDevices` gives each light its room. Writes: `home lights off`, `home room <name> [on|off] [<1-100>] [<kelvin>k] [<hex colour>]`, `home scene <name>`, each one group write; from code, the typed writes below, or `client.put("grouped_light", id, body)` and `client.recallScene(id)` for anything else.
|
|
109
|
+
|
|
110
|
+
Typed writes by name: `client.light(name, change)`, `client.room(name, change)`, `client.scene(name, room?)`, each resolving `{ state, confirmed }` (the connector README has the rules).
|
|
111
|
+
|
|
112
|
+
**Not done:** creating or editing rooms and scenes, which stays in the Hue app. The typed writes ran on KO's bridge on 2026-11-11: every one confirmed in about a second, a fade included (the connector README has the run).
|
|
113
|
+
|
|
114
|
+
## Colour
|
|
115
|
+
|
|
116
|
+
Signify's rules ([Color Conversion Formulas](https://developers.meethue.com/develop/application-design-guidance/color-conversion-formulas-rgb-to-xy-and-back/)):
|
|
117
|
+
|
|
118
|
+
- A light's colour is an `xy` point in the CIE colour space, so one `xy` is the same colour on every light that can show it. Brightness is separate.
|
|
119
|
+
- Each light can show the colours inside a triangle, its gamut. Three are named: C for newer lights (red 0.6915, 0.3038; green 0.17, 0.7; blue 0.1532, 0.0475), B for the older bulb, A for the legacy LivingColors lamps and strips. **API v2 gives each light's gamut on its `light` resource**, so nothing needs a table of models.
|
|
120
|
+
- RGB to `xy`: scale to 0–1, undo the sRGB gamma, convert to XYZ with the D65 matrix, then `x = X / (X + Y + Z)` and `y = Y / (X + Y + Z)`; `Y` is the brightness. If the point is outside the light's gamut, move it to the nearest point on the triangle.
|
|
121
|
+
- `xy` to RGB, for showing a light's colour on a screen, is the same in reverse, with the brightness as `Y`.
|
|
122
|
+
- A colour given as hue, saturation and value goes to RGB first.
|
|
123
|
+
- White is a colour temperature in mirek (`color_temperature.mirek`, 153 cool to 500 warm on most lights), not an `xy`.
|
|
124
|
+
|
|
125
|
+
**What this library does.** `light(name, { color })` and `room(name, { color })` take a hex string or `{ r, g, b }` and send its `xy`, moved inside the light's own gamut; `kelvin` is a white temperature checked against the light's own range, never rounded into it. The state has each light's `kelvin`, `kelvinRange`, `xy`, `gamut` and a `hex` for a screen. Brightness stays separate: a dark red and a bright red are one colour. KO's bridge does report it (2026-11-11): each light's `color.xy`, its `gamut` and `gamut_type`, and `color_temperature.mirek` with the light's own `mirek_minimum` and `mirek_maximum`. The conversions are public maths; Signify's page also carries an iOS SDK extract "as is", which is read for the method and not copied.
|
|
126
|
+
|
|
127
|
+
## Pairing
|
|
128
|
+
|
|
129
|
+
`POST https://<address>/api` with `{ devicetype, generateclientkey: true }`. Until the link button is pressed the bridge answers error 101; for about 30 s after, it answers with the application key. The key keeps working until it is deleted on the bridge. `devicetype` is `app#instance`, at most 40 characters.
|
|
130
|
+
|
|
131
|
+
**What this library does.**
|
|
132
|
+
|
|
133
|
+
- `pair({ host, fetch, deviceType })`: one request. Throws `HueError` with `code: "link_button"` until the button is pressed.
|
|
134
|
+
- `pairWhenPressed({ …, timeoutMs?, everyMs?, onWaiting?, signal? })`: asks every 2 s for 30 s and returns `{ key }`. `onWaiting()` takes nothing and is called once, when the bridge first says the button isn't pressed; if it was already pressed, it is never called. After the time it throws the `link_button` error: nothing was changed on the bridge, so tell the person and ask again.
|
|
135
|
+
- `home pair` uses it with 60 s, names the bridge it found, counts down on a terminal, and prints the env lines. With no press it says so and exits 1.
|
|
136
|
+
- The library never stores the key.
|
|
137
|
+
|
|
138
|
+
A consumer pairing from a chat:
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
import { FINDERS } from "@khalido/home/finders";
|
|
142
|
+
import { bunPinnedFetch, pairWhenPressed } from "@khalido/home/hue";
|
|
143
|
+
|
|
144
|
+
const [bridge] = await FINDERS.hue(); // mDNS: needs avahi-utils on Linux, else throws MissingTool
|
|
145
|
+
if (!bridge) throw new Error("no Hue bridge answered");
|
|
146
|
+
const { key } = await pairWhenPressed({
|
|
147
|
+
host: bridge.host,
|
|
148
|
+
fetch: bunPinnedFetch(bridge.id),
|
|
149
|
+
deviceType: "chota#pi",
|
|
150
|
+
onWaiting: () => say("Press the button on the Hue bridge."),
|
|
151
|
+
});
|
|
152
|
+
// store `key` as a secret; then hue({ host: bridge.host, bridgeId: bridge.id, key })
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`FINDERS.hue()` returns every bridge that answers; this takes the first. A home with two should list them by id and let the person choose. To use the key from a home config, pass it in `env` under the name the config gives (`env: { ...process.env, HUE_APPLICATION_KEY: key }`), and `home.add` the Hue integration that builds ([using.md](using.md) has the three lines).
|
|
156
|
+
|
|
157
|
+
That is Bun. Under Node, `nodePinnedFetch(bridge.id)` from `@khalido/home/node` takes the place of `bunPinnedFetch`.
|
|
158
|
+
|
|
159
|
+
**What others do:** Home Assistant asks the person to press, then makes one request per click of Submit. openHAB asks every 0.5 s for 5 minutes, Homebridge every 15 s with no limit, openhue-cli every second with no limit. Home Assistant and openHAB leave out `generateclientkey`.
|
|
160
|
+
|
|
161
|
+
**Live, 2026-11-11, KO's BSB002 (Bun 1.4.4):** `home pair` gave the `link_button` error after its wait with no press, and a key on the next run once the button was pressed. The key then read the bridge: 177 resources in 286 ms, all parsed (the connector README has the rest).
|