@khalido/home 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +2 -2
- package/CHANGELOG.md +74 -2
- package/README.md +7 -2
- package/dist/clock.js +2 -0
- package/dist/config.d.ts +5 -3
- package/dist/config.js +5 -1
- package/dist/connector.d.ts +9 -2
- package/dist/connectors/amber/index.js +1 -0
- package/dist/connectors/cast/index.d.ts +8 -6
- package/dist/connectors/cast/index.js +30 -4
- package/dist/connectors/cast/state.d.ts +66 -0
- package/dist/connectors/cast/state.js +141 -0
- package/dist/connectors/cast/writes.d.ts +0 -2
- package/dist/connectors/cast/writes.js +25 -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 +8 -4
- package/dist/connectors/daikin/index.js +25 -4
- package/dist/connectors/enphase/index.d.ts +5 -1
- package/dist/connectors/enphase/index.js +34 -2
- 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 +21 -6
- package/dist/connectors/hue/index.js +59 -9
- 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/printers/index.d.ts +6 -2
- package/dist/connectors/printers/index.js +23 -0
- package/dist/connectors/spotify/changes.d.ts +19 -5
- package/dist/connectors/spotify/changes.js +29 -7
- package/dist/connectors/spotify/index.d.ts +7 -2
- package/dist/connectors/spotify/index.js +34 -3
- package/dist/connectors/up/index.js +1 -0
- package/dist/connectors/weather/bom.js +1 -0
- package/dist/connectors/weather/google.js +52 -9
- package/dist/connectors/weather/index.d.ts +2 -1
- package/dist/connectors/weather/index.js +7 -0
- package/dist/connectors/weather/source.d.ts +2 -0
- package/dist/devices.d.ts +51 -0
- package/dist/devices.js +115 -0
- 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/health.d.ts +2 -1
- package/dist/health.js +2 -1
- package/dist/home.d.ts +52 -5
- package/dist/home.js +114 -33
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -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 +18 -5
- package/docs/decisions.md +16 -0
- package/docs/hue.md +161 -0
- package/docs/using.md +115 -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 +10 -10
- package/src/cli/cast.ts +51 -5
- package/src/cli/context.ts +2 -0
- package/src/cli/daikin.ts +71 -0
- package/src/cli/enphase.ts +5 -1
- 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 +87 -13
- package/src/clock.ts +1 -0
- package/src/config.ts +8 -2
- package/src/connector.ts +18 -2
- package/src/connectors/amber/index.ts +1 -0
- package/src/connectors/cast/README.md +10 -3
- package/src/connectors/cast/index.ts +65 -7
- package/src/connectors/cast/state.ts +204 -0
- package/src/connectors/cast/writes.ts +29 -8
- package/src/connectors/daikin/README.md +20 -4
- package/src/connectors/daikin/client.ts +104 -3
- package/src/connectors/daikin/index.ts +49 -4
- package/src/connectors/enphase/index.ts +49 -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 +94 -12
- package/src/connectors/hue/schemas.ts +26 -0
- package/src/connectors/hue/state.ts +27 -1
- package/src/connectors/printers/README.md +4 -2
- package/src/connectors/printers/index.ts +43 -1
- package/src/connectors/spotify/README.md +21 -3
- package/src/connectors/spotify/changes.ts +38 -9
- package/src/connectors/spotify/index.ts +55 -3
- package/src/connectors/up/index.ts +1 -0
- package/src/connectors/weather/README.md +1 -1
- package/src/connectors/weather/bom.ts +1 -0
- package/src/connectors/weather/google.ts +66 -10
- package/src/connectors/weather/index.ts +16 -2
- package/src/connectors/weather/source.ts +2 -0
- package/src/devices.ts +182 -0
- package/src/discover.ts +1 -2
- package/src/finders-entry.ts +1 -1
- package/src/finders.ts +18 -6
- package/src/health.ts +4 -2
- package/src/home.ts +166 -39
- package/src/index.ts +11 -1
- package/src/node-fetch.ts +6 -2
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { colorToXy, kelvinToMirek } from "./color.js";
|
|
2
3
|
import { Envelope, PairResponse, Resource } from "./schemas.js";
|
|
4
|
+
import { groups, lights, scenes, snapshotFrom, homeGroupedLightId, } from "./state.js";
|
|
5
|
+
import { HUE_BRIDGE_ROOT_CA } from "./ca.js";
|
|
6
|
+
import { realClock } from "../../clock.js";
|
|
3
7
|
import { ConnectorError, fetchTimeout } from "../../connector.js";
|
|
4
8
|
import { writeLane } from "../../lane.js";
|
|
5
|
-
/** A failed bridge call. `code` is "link_button" when pairing waits on the bridge's button. */
|
|
9
|
+
/** A failed bridge call. `code` is "link_button" when pairing waits on the bridge's button, "invalid" for a change that can't be made (an unknown name, a value off the light's range) and "offline" for a light the bridge has lost, both caught before anything is sent. */
|
|
6
10
|
export class HueError extends ConnectorError {
|
|
7
11
|
}
|
|
8
|
-
/**
|
|
12
|
+
/** How long `light`, `room` and `scene` wait for the bridge to report what was asked, reading every half second. */
|
|
13
|
+
export const CONFIRM_MS = 3000;
|
|
14
|
+
/** Minimum gap between writes, per lane: Signify's guidance (Hue System Performance, read 2026-11-11), about 10 a second to lights and 1 a second to groups. */
|
|
9
15
|
export const WRITE_GAP_MS = { group: 1000, light: 100 };
|
|
10
16
|
/** Which lane a write goes in: `light` writes are fast, everything else (grouped_light, scene) is a group write. */
|
|
11
17
|
export const laneFor = (kind) => kind === "light" ? "light" : "group";
|
|
@@ -44,7 +50,8 @@ export function hueClient(options) {
|
|
|
44
50
|
}
|
|
45
51
|
/** Change one resource, e.g. `put("grouped_light", id, { on: { on: false } })`. Spaced per lane; writes to one resource waiting their turn merge. */
|
|
46
52
|
const put = (kind, id, body) => lanes[laneFor(kind)].send(`${kind}/${id}`, body, (b) => call("PUT", `/clip/v2/resource/${kind}/${id}`, b), mergeBody);
|
|
47
|
-
|
|
53
|
+
const clock = options.clock ?? realClock;
|
|
54
|
+
const base = {
|
|
48
55
|
put,
|
|
49
56
|
/** Every resource of one kind, or every resource on the bridge when `kind` is left out (one call). */
|
|
50
57
|
async get(kind, signal) {
|
|
@@ -57,6 +64,184 @@ export function hueClient(options) {
|
|
|
57
64
|
return put("scene", id, { recall: { action: "active" } });
|
|
58
65
|
},
|
|
59
66
|
};
|
|
67
|
+
/** Whether two of these labels are the same, so only an id tells them apart. */
|
|
68
|
+
const same = (labels) => new Set(labels).size < labels.length;
|
|
69
|
+
const invalid = (message) => new HueError(message, { code: "invalid" });
|
|
70
|
+
/** One of `items` by id or by name (any case). A miss, or a name two of them share, throws listing what exists. */
|
|
71
|
+
function pick(items, target, what, label = (i) => i.name ?? i.id) {
|
|
72
|
+
const wanted = target.trim().toLowerCase();
|
|
73
|
+
const found = items.filter((i) => i.id === target || i.name?.toLowerCase() === wanted);
|
|
74
|
+
if (found.length === 1)
|
|
75
|
+
return found[0];
|
|
76
|
+
throw invalid(found.length
|
|
77
|
+
? `"${target}" names ${found.length}: ${same(found.map(label)) ? found.map((i) => `${label(i)} (${i.id})`).join(", ") : found.map(label).join(", ")}`
|
|
78
|
+
: `no ${what} "${target}"; have: ${items.map(label).join(", ") || "none"}`);
|
|
79
|
+
}
|
|
80
|
+
const read = async () => snapshotFrom(await base.get(), "");
|
|
81
|
+
/** The CLIP body for `change`, checked against what the light (or a group's lights) can take. Throws before anything is sent. */
|
|
82
|
+
function bodyFor(change, name, members, isOn) {
|
|
83
|
+
const { on, brightness, color, kelvin, transitionMs } = change;
|
|
84
|
+
const look = brightness !== undefined || color !== undefined || kelvin !== undefined;
|
|
85
|
+
if (on === undefined && !look)
|
|
86
|
+
throw invalid("nothing to change");
|
|
87
|
+
if (on === false && look)
|
|
88
|
+
throw invalid("off takes no brightness or colour");
|
|
89
|
+
if (color !== undefined && kelvin !== undefined)
|
|
90
|
+
throw invalid("a colour or a white temperature, not both");
|
|
91
|
+
const body = {};
|
|
92
|
+
if (on !== undefined)
|
|
93
|
+
body.on = { on };
|
|
94
|
+
else if (isOn !== true)
|
|
95
|
+
body.on = { on: true }; // off, or a group where some may be
|
|
96
|
+
if (brightness !== undefined) {
|
|
97
|
+
if (!(brightness >= 1 && brightness <= 100))
|
|
98
|
+
throw invalid(`brightness is 1–100, not ${brightness}`);
|
|
99
|
+
body.dimming = { brightness };
|
|
100
|
+
}
|
|
101
|
+
if (color !== undefined) {
|
|
102
|
+
const gamut = members.find((l) => l.gamut)?.gamut;
|
|
103
|
+
if (!gamut)
|
|
104
|
+
throw invalid(`${name} has no colour light`);
|
|
105
|
+
try {
|
|
106
|
+
body.color = { xy: colorToXy(color, gamut) };
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
throw invalid(error.message); // a bad colour, as every other change that can't be made
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (kelvin !== undefined) {
|
|
113
|
+
const ranges = members.flatMap((l) => l.kelvinRange ? [l.kelvinRange] : []);
|
|
114
|
+
if (!ranges.length)
|
|
115
|
+
throw invalid(`${name} takes no white temperature`);
|
|
116
|
+
const low = Math.min(...ranges.map((r) => r[0]));
|
|
117
|
+
const high = Math.max(...ranges.map((r) => r[1]));
|
|
118
|
+
if (!(kelvin >= low && kelvin <= high))
|
|
119
|
+
throw invalid(`${kelvin} K is outside ${name}'s range, ${low}–${high} K`);
|
|
120
|
+
body.color_temperature = { mirek: kelvinToMirek(kelvin) };
|
|
121
|
+
}
|
|
122
|
+
if (transitionMs !== undefined) {
|
|
123
|
+
if (!(Number.isInteger(transitionMs) && transitionMs >= 0))
|
|
124
|
+
throw invalid(`transitionMs is a whole number of ms, not ${transitionMs}`);
|
|
125
|
+
body.dynamics = { duration: transitionMs };
|
|
126
|
+
}
|
|
127
|
+
return body;
|
|
128
|
+
}
|
|
129
|
+
/** Read every half second until `view()` gives a state that is as `asked`, or `CONFIRM_MS` passes. A failed read is not a failed write. */
|
|
130
|
+
async function confirm(view, asked) {
|
|
131
|
+
const until = clock.now() + CONFIRM_MS;
|
|
132
|
+
let state;
|
|
133
|
+
let failed;
|
|
134
|
+
for (;;) {
|
|
135
|
+
try {
|
|
136
|
+
state = (await view()) ?? state;
|
|
137
|
+
if (state !== undefined && asked(state))
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
failed = error;
|
|
142
|
+
}
|
|
143
|
+
if (clock.now() >= until)
|
|
144
|
+
break;
|
|
145
|
+
await clock.sleep(500);
|
|
146
|
+
}
|
|
147
|
+
if (state === undefined)
|
|
148
|
+
throw failed ?? new HueError("sent, but it is no longer on the bridge");
|
|
149
|
+
return { state, confirmed: asked(state) };
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Whether a view shows what the body asked: on, brightness within a point, and on a light the white temperature within
|
|
153
|
+
* 2 mirek or the colour within 0.02 in `xy`. A room reports neither, so its colour is not compared (its `on` is).
|
|
154
|
+
*/
|
|
155
|
+
const shows = (body, light) => (v) => {
|
|
156
|
+
const on = body.on?.on;
|
|
157
|
+
const brightness = body.dimming
|
|
158
|
+
?.brightness;
|
|
159
|
+
const mirek = body.color_temperature
|
|
160
|
+
?.mirek;
|
|
161
|
+
const xy = body.color?.xy;
|
|
162
|
+
return ((on === undefined || v.on === on) &&
|
|
163
|
+
(brightness === undefined ||
|
|
164
|
+
(v.brightness !== undefined &&
|
|
165
|
+
Math.abs(v.brightness - brightness) <= 1)) &&
|
|
166
|
+
(!light ||
|
|
167
|
+
mirek === undefined ||
|
|
168
|
+
(v.kelvin !== undefined &&
|
|
169
|
+
Math.abs(kelvinToMirek(v.kelvin) - mirek) <= 2)) &&
|
|
170
|
+
(!light ||
|
|
171
|
+
xy === undefined ||
|
|
172
|
+
(v.xy !== undefined &&
|
|
173
|
+
Math.abs(v.xy.x - xy.x) <= 0.02 &&
|
|
174
|
+
Math.abs(v.xy.y - xy.y) <= 0.02)));
|
|
175
|
+
};
|
|
176
|
+
/** Send, or with `dryRun` say what would be sent and how `now` stands. */
|
|
177
|
+
async function send(options, kind, id, body, now, after) {
|
|
178
|
+
if (options?.dryRun) {
|
|
179
|
+
if (now === undefined)
|
|
180
|
+
throw new HueError("its room or zone is not on the bridge");
|
|
181
|
+
return {
|
|
182
|
+
state: now,
|
|
183
|
+
confirmed: false,
|
|
184
|
+
wouldSend: { method: "PUT", path: `${kind}/${id}`, body },
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
await put(kind, id, body);
|
|
188
|
+
return after();
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
...base,
|
|
192
|
+
/** Change one light, by name or id. Only when a person asks. Resolves with the light as the bridge then reported it. */
|
|
193
|
+
async light(target, change, options) {
|
|
194
|
+
const l = pick(lights(await read()), target, "light");
|
|
195
|
+
// The bridge takes a write to a light it has lost and answers "may not have effect": say so before sending.
|
|
196
|
+
if (l.reachable === false)
|
|
197
|
+
throw new HueError(`${l.name ?? l.id} is unreachable: the bridge has lost it (is it switched off at the wall?)`, { code: "offline" });
|
|
198
|
+
const body = bodyFor(change, l.name ?? l.id, [l], l.on);
|
|
199
|
+
return send(options, "light", l.id, body, l, () => confirm(async () => lights(await read()).find((x) => x.id === l.id), shows(body, true)));
|
|
200
|
+
},
|
|
201
|
+
/**
|
|
202
|
+
* Every light in the home off, with one write to the bridge's home group. Only when a person asks. Resolves with
|
|
203
|
+
* every light as the bridge then reported it, `confirmed` once none it can reach is on.
|
|
204
|
+
*/
|
|
205
|
+
async allOff(options) {
|
|
206
|
+
const s = await read();
|
|
207
|
+
const id = homeGroupedLightId(s);
|
|
208
|
+
if (!id)
|
|
209
|
+
throw invalid("the bridge has no home group");
|
|
210
|
+
return send(options, "grouped_light", id, { on: { on: false } }, lights(s), () => confirm(async () => lights(await read()), (all) => all.every((l) => !l.on || l.reachable === false)));
|
|
211
|
+
},
|
|
212
|
+
/**
|
|
213
|
+
* Change every light in a room or zone with one write, by name or id. Only when a person asks. Resolves with the
|
|
214
|
+
* group as the bridge then reported it; its brightness is the bridge's figure for the group, so a group holding a
|
|
215
|
+
* light that is off or unreachable may stay unconfirmed.
|
|
216
|
+
*/
|
|
217
|
+
async room(target, change, options) {
|
|
218
|
+
const s = await read();
|
|
219
|
+
const g = pick(groups(s), target, "room or zone");
|
|
220
|
+
const name = g.name ?? g.id;
|
|
221
|
+
if (!g.groupedLightId)
|
|
222
|
+
throw invalid(`${name} has no lights`);
|
|
223
|
+
const members = lights(s).filter((l) => g.lightIds.includes(l.id));
|
|
224
|
+
const body = bodyFor(change, name, members, undefined);
|
|
225
|
+
return send(options, "grouped_light", g.groupedLightId, body, g, () => confirm(async () => groups(await read()).find((x) => x.id === g.id), shows(body, false)));
|
|
226
|
+
},
|
|
227
|
+
/**
|
|
228
|
+
* Recall a scene by name or id. A name several rooms use ("Relax") needs `room`. Only when a person asks. Resolves
|
|
229
|
+
* with the scene's room or zone, `confirmed` once the bridge reports the scene active.
|
|
230
|
+
*/
|
|
231
|
+
async scene(target, room, options) {
|
|
232
|
+
const s = await read();
|
|
233
|
+
const all = groups(s);
|
|
234
|
+
const within = room === undefined ? undefined : pick(all, room, "room or zone");
|
|
235
|
+
const roomName = (id) => all.find((g) => g.id === id)?.name ?? "no room";
|
|
236
|
+
const sc = pick(scenes(s).filter((x) => !within || x.groupId === within.id), target, "scene", (x) => `${x.name ?? x.id} in ${roomName(x.groupId)}`);
|
|
237
|
+
let active = false;
|
|
238
|
+
return send(options, "scene", sc.id, { recall: { action: "active" } }, all.find((g) => g.id === sc.groupId), () => confirm(async () => {
|
|
239
|
+
const now = await read();
|
|
240
|
+
active = scenes(now).find((x) => x.id === sc.id)?.active === true;
|
|
241
|
+
return groups(now).find((x) => x.id === sc.groupId);
|
|
242
|
+
}, () => active));
|
|
243
|
+
},
|
|
244
|
+
};
|
|
60
245
|
}
|
|
61
246
|
/** Ask the bridge for an application key. One call: throws `HueError` with code "link_button" until KO presses it. */
|
|
62
247
|
export async function pair(options) {
|
|
@@ -67,7 +252,7 @@ export async function pair(options) {
|
|
|
67
252
|
devicetype: options.deviceType,
|
|
68
253
|
generateclientkey: true,
|
|
69
254
|
}),
|
|
70
|
-
});
|
|
255
|
+
}, { signal: options.signal });
|
|
71
256
|
const parsed = PairResponse.safeParse(await res.json().catch(() => undefined));
|
|
72
257
|
if (!parsed.success)
|
|
73
258
|
throw new HueError(`unexpected answer to POST /api (HTTP ${res.status})`, {
|
|
@@ -83,10 +268,36 @@ export async function pair(options) {
|
|
|
83
268
|
});
|
|
84
269
|
}
|
|
85
270
|
/**
|
|
86
|
-
*
|
|
271
|
+
* `pair`, asked again every `everyMs` (2 s) until someone presses the bridge's button, for `timeoutMs` (30 s, as long as
|
|
272
|
+
* the button stays pressed). `onWaiting` is called once, when the bridge first says the button isn't pressed: the moment
|
|
273
|
+
* to tell the person. Throws the last `link_button` error when time runs out, any other error at once, and the signal's
|
|
274
|
+
* reason when aborted.
|
|
275
|
+
*/
|
|
276
|
+
export async function pairWhenPressed(options) {
|
|
277
|
+
const clock = options.clock ?? realClock;
|
|
278
|
+
const until = clock.now() + (options.timeoutMs ?? 30_000);
|
|
279
|
+
let waiting = false;
|
|
280
|
+
while (true) {
|
|
281
|
+
try {
|
|
282
|
+
return await pair(options);
|
|
283
|
+
}
|
|
284
|
+
catch (error) {
|
|
285
|
+
const pressed = !(error instanceof HueError && error.code === "link_button");
|
|
286
|
+
if (pressed || clock.now() >= until)
|
|
287
|
+
throw error;
|
|
288
|
+
if (!waiting)
|
|
289
|
+
options.onWaiting?.();
|
|
290
|
+
waiting = true;
|
|
291
|
+
}
|
|
292
|
+
await clock.sleep(options.everyMs ?? 2000, options.signal);
|
|
293
|
+
options.signal?.throwIfAborted();
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* A `fetch` that trusts only `ca` (Signify's roots, `HUE_BRIDGE_ROOT_CA`, when left out) and accepts only a certificate whose CN is `bridgeId`.
|
|
87
298
|
* Uses Bun's `tls` fetch option; Node ignores it and then rejects the bridge, so it fails closed there; Node uses `nodePinnedFetch` from `@khalido/home/node`.
|
|
88
299
|
*/
|
|
89
|
-
export function bunPinnedFetch(bridgeId, ca) {
|
|
300
|
+
export function bunPinnedFetch(bridgeId, ca = HUE_BRIDGE_ROOT_CA) {
|
|
90
301
|
const checkServerIdentity = (_host, cert) => cert.subject?.CN?.toLowerCase() === bridgeId.toLowerCase()
|
|
91
302
|
? undefined
|
|
92
303
|
: new Error(`Hue bridge certificate is for ${cert.subject?.CN ?? "nobody"}, expected ${bridgeId}`);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type XY = {
|
|
2
|
+
x: number;
|
|
3
|
+
y: number;
|
|
4
|
+
};
|
|
5
|
+
export type Gamut = {
|
|
6
|
+
red: XY;
|
|
7
|
+
green: XY;
|
|
8
|
+
blue: XY;
|
|
9
|
+
};
|
|
10
|
+
export type Rgb = {
|
|
11
|
+
r: number;
|
|
12
|
+
g: number;
|
|
13
|
+
b: number;
|
|
14
|
+
};
|
|
15
|
+
/** A colour as a caller gives it: "#ff8800", "ff8800", "#f80", or `{ r, g, b }` each 0–255. Throws on anything else. */
|
|
16
|
+
export declare function parseColor(color: string | Rgb): Rgb;
|
|
17
|
+
/** Whether `p` is inside the gamut triangle. */
|
|
18
|
+
export declare function inGamut(p: XY, g: Gamut): boolean;
|
|
19
|
+
/** `p` if the light can show it, else the nearest point on its gamut's edge: the colour it can show that is closest. */
|
|
20
|
+
export declare function nearestInGamut(p: XY, g: Gamut): XY;
|
|
21
|
+
/**
|
|
22
|
+
* A colour's `xy`, moved inside `gamut` when one is given. Brightness is not part of it: black has no colour and throws
|
|
23
|
+
* (turn the light off instead), and a dark red and a bright red are the same point.
|
|
24
|
+
*/
|
|
25
|
+
export declare function colorToXy(color: string | Rgb, gamut?: Gamut): XY;
|
|
26
|
+
/** An `xy` as a hex colour for a screen, at full brightness: what the light's colour looks like, not how bright it is. */
|
|
27
|
+
export declare function xyToHex(xy: XY): string;
|
|
28
|
+
/** Colour temperature: Hue counts in mirek, a million over the kelvin. 2700 K is 370 mirek. */
|
|
29
|
+
export declare const kelvinToMirek: (kelvin: number) => number;
|
|
30
|
+
export declare const mirekToKelvin: (mirek: number) => number;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { ConnectorError } from "../../connector.js";
|
|
2
|
+
/** A colour as a caller gives it: "#ff8800", "ff8800", "#f80", or `{ r, g, b }` each 0–255. Throws on anything else. */
|
|
3
|
+
export function parseColor(color) {
|
|
4
|
+
if (typeof color !== "string") {
|
|
5
|
+
const ok = [color.r, color.g, color.b].every((v) => Number.isFinite(v) && v >= 0 && v <= 255);
|
|
6
|
+
if (!ok)
|
|
7
|
+
throw new ConnectorError("a colour's r, g and b are each 0–255", {
|
|
8
|
+
code: "invalid",
|
|
9
|
+
});
|
|
10
|
+
return color;
|
|
11
|
+
}
|
|
12
|
+
const hex = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(color.trim())?.[1];
|
|
13
|
+
if (!hex)
|
|
14
|
+
throw new ConnectorError(`"${color}" is not a colour like #ff8800`, {
|
|
15
|
+
code: "invalid",
|
|
16
|
+
});
|
|
17
|
+
const full = hex.length === 3 ? [...hex].map((c) => c + c).join("") : hex;
|
|
18
|
+
const n = Number.parseInt(full, 16);
|
|
19
|
+
return { r: n >> 16, g: (n >> 8) & 255, b: n & 255 };
|
|
20
|
+
}
|
|
21
|
+
const linear = (v) => v > 0.04045 ? ((v + 0.055) / 1.055) ** 2.4 : v / 12.92;
|
|
22
|
+
const gamma = (v) => v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055;
|
|
23
|
+
const cross = (a, b) => a.x * b.y - a.y * b.x;
|
|
24
|
+
const minus = (a, b) => ({ x: a.x - b.x, y: a.y - b.y });
|
|
25
|
+
/** Whether `p` is inside the gamut triangle. */
|
|
26
|
+
export function inGamut(p, g) {
|
|
27
|
+
const v1 = minus(g.green, g.red);
|
|
28
|
+
const v2 = minus(g.blue, g.red);
|
|
29
|
+
const q = minus(p, g.red);
|
|
30
|
+
const s = cross(q, v2) / cross(v1, v2);
|
|
31
|
+
const t = cross(v1, q) / cross(v1, v2);
|
|
32
|
+
return s >= 0 && t >= 0 && s + t <= 1;
|
|
33
|
+
}
|
|
34
|
+
/** The nearest point to `p` on the segment a–b. */
|
|
35
|
+
function onSegment(a, b, p) {
|
|
36
|
+
const ab = minus(b, a);
|
|
37
|
+
const t = Math.min(1, Math.max(0, ((p.x - a.x) * ab.x + (p.y - a.y) * ab.y) / (ab.x ** 2 + ab.y ** 2)));
|
|
38
|
+
return { x: a.x + ab.x * t, y: a.y + ab.y * t };
|
|
39
|
+
}
|
|
40
|
+
/** `p` if the light can show it, else the nearest point on its gamut's edge: the colour it can show that is closest. */
|
|
41
|
+
export function nearestInGamut(p, g) {
|
|
42
|
+
if (inGamut(p, g))
|
|
43
|
+
return p;
|
|
44
|
+
const edges = [
|
|
45
|
+
onSegment(g.red, g.green, p),
|
|
46
|
+
onSegment(g.green, g.blue, p),
|
|
47
|
+
onSegment(g.blue, g.red, p),
|
|
48
|
+
];
|
|
49
|
+
const far = (q) => (q.x - p.x) ** 2 + (q.y - p.y) ** 2;
|
|
50
|
+
return edges.reduce((best, q) => (far(q) < far(best) ? q : best));
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* A colour's `xy`, moved inside `gamut` when one is given. Brightness is not part of it: black has no colour and throws
|
|
54
|
+
* (turn the light off instead), and a dark red and a bright red are the same point.
|
|
55
|
+
*/
|
|
56
|
+
export function colorToXy(color, gamut) {
|
|
57
|
+
const { r, g, b } = parseColor(color);
|
|
58
|
+
const [lr, lg, lb] = [r, g, b].map((v) => linear(v / 255));
|
|
59
|
+
const X = lr * 0.4124 + lg * 0.3576 + lb * 0.1805;
|
|
60
|
+
const Y = lr * 0.2126 + lg * 0.7152 + lb * 0.0722;
|
|
61
|
+
const Z = lr * 0.0193 + lg * 0.1192 + lb * 0.9505;
|
|
62
|
+
const sum = X + Y + Z;
|
|
63
|
+
if (sum === 0)
|
|
64
|
+
throw new ConnectorError("black is not a colour a light can show", {
|
|
65
|
+
code: "invalid",
|
|
66
|
+
});
|
|
67
|
+
const xy = { x: X / sum, y: Y / sum };
|
|
68
|
+
const at = gamut ? nearestInGamut(xy, gamut) : xy;
|
|
69
|
+
return { x: round4(at.x), y: round4(at.y) };
|
|
70
|
+
}
|
|
71
|
+
const round4 = (v) => Math.round(v * 1e4) / 1e4;
|
|
72
|
+
/** An `xy` as a hex colour for a screen, at full brightness: what the light's colour looks like, not how bright it is. */
|
|
73
|
+
export function xyToHex(xy) {
|
|
74
|
+
if (xy.y <= 0)
|
|
75
|
+
return "#000000";
|
|
76
|
+
const X = xy.x / xy.y;
|
|
77
|
+
const Z = (1 - xy.x - xy.y) / xy.y;
|
|
78
|
+
const rgb = [
|
|
79
|
+
X * 3.2406 - 1.5372 - Z * 0.4986,
|
|
80
|
+
-X * 0.9689 + 1.8758 + Z * 0.0415,
|
|
81
|
+
X * 0.0557 - 0.204 + Z * 1.057,
|
|
82
|
+
].map((v) => Math.max(0, v));
|
|
83
|
+
const max = Math.max(...rgb);
|
|
84
|
+
return `#${rgb
|
|
85
|
+
.map((v) => Math.round(255 * Math.min(1, Math.max(0, gamma(max > 0 ? v / max : 0))))
|
|
86
|
+
.toString(16)
|
|
87
|
+
.padStart(2, "0"))
|
|
88
|
+
.join("")}`;
|
|
89
|
+
}
|
|
90
|
+
/** Colour temperature: Hue counts in mirek, a million over the kelvin. 2700 K is 370 mirek. */
|
|
91
|
+
export const kelvinToMirek = (kelvin) => Math.round(1e6 / kelvin);
|
|
92
|
+
export const mirekToKelvin = (mirek) => Math.round(1e6 / mirek);
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { type Clock } from "../../clock.ts";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { type Finder } from "../../discover.ts";
|
|
4
|
-
import { type Integration } from "../../home.ts";
|
|
4
|
+
import { type DeviceRecord, type Integration, type Part } from "../../home.ts";
|
|
5
5
|
import type { Manifest } from "../../manifest.ts";
|
|
6
6
|
import { type HueClient } from "./client.ts";
|
|
7
|
-
import { type HueSnapshot } from "./state.ts";
|
|
8
|
-
export {
|
|
7
|
+
import { type GroupView, type HueSnapshot, type LightView } from "./state.ts";
|
|
8
|
+
export { HUE_BRIDGE_ROOT_CA } from "./ca.ts";
|
|
9
|
+
export { colorToXy, type Gamut, kelvinToMirek, mirekToKelvin, type Rgb, type XY, xyToHex, } from "./color.ts";
|
|
10
|
+
export { bunPinnedFetch, CONFIRM_MS, type HueApplied, type HueRequest, type HueWriteOptions, type LightChange, type HueClient, HueError, type HueOptions, hueClient, pair, pairWhenPressed, } from "./client.ts";
|
|
9
11
|
export type { HueEvent, Resource } from "./schemas.ts";
|
|
10
12
|
export { type GroupView, groups, type HueSnapshot, homeGroupedLightId, type LightView, lights, scenes, } from "./state.ts";
|
|
11
13
|
/** A Hue bridge in a home config: what it takes to reach it. Its model and software come live into the snapshot. */
|
|
@@ -14,17 +16,17 @@ export declare const configSchema: z.ZodObject<{
|
|
|
14
16
|
host: z.ZodString;
|
|
15
17
|
bridgeId: z.ZodString;
|
|
16
18
|
key: z.ZodString;
|
|
17
|
-
ca: z.ZodString
|
|
19
|
+
ca: z.ZodOptional<z.ZodString>;
|
|
18
20
|
}, z.core.$loose>;
|
|
19
21
|
/**
|
|
20
|
-
* Hue settings for a home. Leave out `fetch` in Bun and it pins to `
|
|
22
|
+
* Hue settings for a home. Leave out `fetch` in Bun and it pins to `bridgeId` and `ca` (Signify's roots, `HUE_BRIDGE_ROOT_CA`, when left out); in Node pass a pinned one.
|
|
21
23
|
* `find` (mDNS, matched by `bridgeId`) finds the bridge again after three failed reconnects; `idleMs` is the stream's watchdog.
|
|
22
24
|
*/
|
|
23
25
|
export type HueConfig = {
|
|
24
26
|
host: string;
|
|
25
27
|
bridgeId: string;
|
|
26
28
|
key: string;
|
|
27
|
-
ca
|
|
29
|
+
ca?: string;
|
|
28
30
|
fetch?: typeof fetch;
|
|
29
31
|
clock?: Clock;
|
|
30
32
|
find?: Finder;
|
|
@@ -39,4 +41,17 @@ export type HueConfig = {
|
|
|
39
41
|
export declare function hue(config: HueConfig): Integration<HueSnapshot, "hue"> & {
|
|
40
42
|
readonly client: HueClient;
|
|
41
43
|
};
|
|
44
|
+
/** A light, room or zone in a line: "Kitchen: on · 40%", "Desk: on · 40% · 2700 K", "Strip: on · 80% · #ff8800", "Kitchen: off", or the name alone when the bridge hasn't said. */
|
|
45
|
+
export declare const hueText: (v: {
|
|
46
|
+
name?: string;
|
|
47
|
+
id: string;
|
|
48
|
+
on?: boolean;
|
|
49
|
+
brightness?: number;
|
|
50
|
+
kelvin?: number;
|
|
51
|
+
hex?: string;
|
|
52
|
+
}) => string;
|
|
53
|
+
/** A Hue light, or a room or zone, as a home device: `detail` is its `LightView` or `GroupView`. */
|
|
54
|
+
export type HueHomeDevice = DeviceRecord<"hue", "light", "on" | "off", LightView> | DeviceRecord<"hue", "group", "on" | "off", GroupView>;
|
|
55
|
+
/** Every light, then every room and zone. A light's `available` is its Zigbee link; a group's `members` are its lights. */
|
|
56
|
+
export declare function hueHomeDevices(part: Part<HueSnapshot> | undefined): HueHomeDevice[];
|
|
42
57
|
export declare const manifest: Manifest<HueSnapshot>;
|
|
@@ -2,11 +2,13 @@ import { asOf, realClock } from "../../clock.js";
|
|
|
2
2
|
import { errorText } from "../../connector.js";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { lostDetail, relocator } from "../../discover.js";
|
|
5
|
-
import { envName, filePath, } from "../../home.js";
|
|
5
|
+
import { envName, filePath, deviceRecords, } from "../../home.js";
|
|
6
6
|
import { bunPinnedFetch, hueClient } from "./client.js";
|
|
7
7
|
import { hueEvents, isRejectedKey } from "./events.js";
|
|
8
8
|
import { groups, lights, reduce, snapshotFrom, } from "./state.js";
|
|
9
|
-
export {
|
|
9
|
+
export { HUE_BRIDGE_ROOT_CA } from "./ca.js";
|
|
10
|
+
export { colorToXy, kelvinToMirek, mirekToKelvin, xyToHex, } from "./color.js";
|
|
11
|
+
export { bunPinnedFetch, CONFIRM_MS, HueError, hueClient, pair, pairWhenPressed, } from "./client.js";
|
|
10
12
|
export { groups, homeGroupedLightId, lights, scenes, } from "./state.js";
|
|
11
13
|
/** A Hue bridge in a home config: what it takes to reach it. Its model and software come live into the snapshot. */
|
|
12
14
|
export const configSchema = z.looseObject({
|
|
@@ -16,7 +18,9 @@ export const configSchema = z.looseObject({
|
|
|
16
18
|
.string()
|
|
17
19
|
.describe("the bridge id, the CN its certificate must carry: its identity"),
|
|
18
20
|
key: envName.describe("env name holding the application key from `home pair`"),
|
|
19
|
-
ca: filePath
|
|
21
|
+
ca: filePath
|
|
22
|
+
.optional()
|
|
23
|
+
.describe("a root CA (PEM) to trust instead of Signify's two, which the package carries; relative to the config file"),
|
|
20
24
|
});
|
|
21
25
|
/**
|
|
22
26
|
* Hue as a home integration: a full read on every (re)connect, then patched from the event stream; `refresh` re-reads it all.
|
|
@@ -137,6 +141,7 @@ export function hue(config) {
|
|
|
137
141
|
if (isRejectedKey(error))
|
|
138
142
|
return update({
|
|
139
143
|
status: "unavailable",
|
|
144
|
+
reason: "auth",
|
|
140
145
|
detail: "the bridge rejected the key; pair again",
|
|
141
146
|
state: snapshot,
|
|
142
147
|
});
|
|
@@ -161,6 +166,53 @@ const view = (s) => ({
|
|
|
161
166
|
brightness,
|
|
162
167
|
})),
|
|
163
168
|
});
|
|
169
|
+
/** A light, room or zone in a line: "Kitchen: on · 40%", "Desk: on · 40% · 2700 K", "Strip: on · 80% · #ff8800", "Kitchen: off", or the name alone when the bridge hasn't said. */
|
|
170
|
+
export const hueText = (v) => {
|
|
171
|
+
if (v.on === undefined)
|
|
172
|
+
return v.name ?? v.id;
|
|
173
|
+
const parts = v.on
|
|
174
|
+
? [
|
|
175
|
+
"on",
|
|
176
|
+
v.brightness === undefined ? "" : `${Math.round(v.brightness)}%`,
|
|
177
|
+
// A colour light showing a white reports both; the white is what it is showing.
|
|
178
|
+
v.kelvin !== undefined ? `${v.kelvin} K` : (v.hex ?? ""),
|
|
179
|
+
]
|
|
180
|
+
: ["off"];
|
|
181
|
+
return `${v.name ?? v.id}: ${parts.filter(Boolean).join(" · ")}`;
|
|
182
|
+
};
|
|
183
|
+
/** Every light, then every room and zone. A light's `available` is its Zigbee link; a group's `members` are its lights. */
|
|
184
|
+
export function hueHomeDevices(part) {
|
|
185
|
+
const s = part?.state;
|
|
186
|
+
if (!s)
|
|
187
|
+
return [];
|
|
188
|
+
const on = (v) => v.on === undefined
|
|
189
|
+
? {}
|
|
190
|
+
: { state: v.on ? "on" : "off" };
|
|
191
|
+
return deviceRecords(part, [
|
|
192
|
+
...lights(s).map((l) => ({
|
|
193
|
+
connector: "hue",
|
|
194
|
+
id: l.id,
|
|
195
|
+
name: l.name ?? l.id,
|
|
196
|
+
kind: "light",
|
|
197
|
+
...(l.reachable === undefined ? {} : { available: l.reachable }),
|
|
198
|
+
...on(l),
|
|
199
|
+
asOf: s.asOf,
|
|
200
|
+
text: hueText(l),
|
|
201
|
+
detail: l,
|
|
202
|
+
})),
|
|
203
|
+
...groups(s).map((g) => ({
|
|
204
|
+
connector: "hue",
|
|
205
|
+
id: g.id,
|
|
206
|
+
name: g.name ?? g.id,
|
|
207
|
+
kind: "group",
|
|
208
|
+
...on(g),
|
|
209
|
+
asOf: s.asOf,
|
|
210
|
+
text: hueText(g),
|
|
211
|
+
members: g.lightIds,
|
|
212
|
+
detail: g,
|
|
213
|
+
})),
|
|
214
|
+
]);
|
|
215
|
+
}
|
|
164
216
|
export const manifest = {
|
|
165
217
|
name: "hue",
|
|
166
218
|
title: "Philips Hue",
|
|
@@ -187,7 +239,8 @@ export const manifest = {
|
|
|
187
239
|
{
|
|
188
240
|
name: "HUE_CA_FILE",
|
|
189
241
|
example: "/path/to/hue-root-ca.pem",
|
|
190
|
-
|
|
242
|
+
optional: true,
|
|
243
|
+
what: "a root CA to trust instead of Signify's two, which the package carries",
|
|
191
244
|
},
|
|
192
245
|
],
|
|
193
246
|
files: ["ca"],
|
|
@@ -195,11 +248,8 @@ export const manifest = {
|
|
|
195
248
|
lines({ state }) {
|
|
196
249
|
if (!state)
|
|
197
250
|
return [];
|
|
198
|
-
const { lightsOn
|
|
199
|
-
return [
|
|
200
|
-
`${lightsOn} lights on`,
|
|
201
|
-
...rooms.map((r) => `${r.name}: ${r.on ? `on${r.brightness === undefined ? "" : ` · ${Math.round(r.brightness)}%`}` : "off"}`),
|
|
202
|
-
];
|
|
251
|
+
const { lightsOn } = view(state);
|
|
252
|
+
return [`${lightsOn} lights on`, ...groups(state).map(hueText)];
|
|
203
253
|
},
|
|
204
254
|
record: ({ state }) => (state ? view(state) : {}),
|
|
205
255
|
};
|
|
@@ -19,6 +19,33 @@ export declare const Resource: z.ZodObject<{
|
|
|
19
19
|
dimming: z.ZodOptional<z.ZodObject<{
|
|
20
20
|
brightness: z.ZodNumber;
|
|
21
21
|
}, z.core.$loose>>;
|
|
22
|
+
color: z.ZodCatch<z.ZodOptional<z.ZodObject<{
|
|
23
|
+
xy: z.ZodCatch<z.ZodOptional<z.ZodObject<{
|
|
24
|
+
x: z.ZodNumber;
|
|
25
|
+
y: z.ZodNumber;
|
|
26
|
+
}, z.core.$loose>>>;
|
|
27
|
+
gamut: z.ZodCatch<z.ZodOptional<z.ZodObject<{
|
|
28
|
+
red: z.ZodObject<{
|
|
29
|
+
x: z.ZodNumber;
|
|
30
|
+
y: z.ZodNumber;
|
|
31
|
+
}, z.core.$loose>;
|
|
32
|
+
green: z.ZodObject<{
|
|
33
|
+
x: z.ZodNumber;
|
|
34
|
+
y: z.ZodNumber;
|
|
35
|
+
}, z.core.$loose>;
|
|
36
|
+
blue: z.ZodObject<{
|
|
37
|
+
x: z.ZodNumber;
|
|
38
|
+
y: z.ZodNumber;
|
|
39
|
+
}, z.core.$loose>;
|
|
40
|
+
}, z.core.$loose>>>;
|
|
41
|
+
}, z.core.$loose>>>;
|
|
42
|
+
color_temperature: z.ZodCatch<z.ZodOptional<z.ZodObject<{
|
|
43
|
+
mirek: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
|
44
|
+
mirek_schema: z.ZodOptional<z.ZodObject<{
|
|
45
|
+
mirek_minimum: z.ZodOptional<z.ZodNumber>;
|
|
46
|
+
mirek_maximum: z.ZodOptional<z.ZodNumber>;
|
|
47
|
+
}, z.core.$loose>>;
|
|
48
|
+
}, z.core.$loose>>>;
|
|
22
49
|
children: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
23
50
|
rid: z.ZodString;
|
|
24
51
|
rtype: z.ZodString;
|
|
@@ -4,6 +4,7 @@ import { z } from "zod";
|
|
|
4
4
|
// field is unconfirmed from the primary source. Only the fields the snapshot reads are parsed;
|
|
5
5
|
// all are optional and unknown fields pass through, so a schema change degrades, never throws.
|
|
6
6
|
const ref = z.object({ rid: z.string(), rtype: z.string() });
|
|
7
|
+
const xy = z.looseObject({ x: z.number(), y: z.number() });
|
|
7
8
|
/** The resource kinds this library reads. */
|
|
8
9
|
export const KINDS = [
|
|
9
10
|
"light",
|
|
@@ -22,6 +23,31 @@ export const Resource = z.looseObject({
|
|
|
22
23
|
metadata: z.looseObject({ name: z.string().optional() }).optional(),
|
|
23
24
|
on: z.looseObject({ on: z.boolean() }).optional(),
|
|
24
25
|
dimming: z.looseObject({ brightness: z.number() }).optional(),
|
|
26
|
+
// A light's colour and the triangle of colours it can show; absent on a white light. A shape this doesn't expect
|
|
27
|
+
// reads as absent, so one odd light never fails the whole read.
|
|
28
|
+
color: z
|
|
29
|
+
.looseObject({
|
|
30
|
+
xy: xy.optional().catch(undefined),
|
|
31
|
+
gamut: z
|
|
32
|
+
.looseObject({ red: xy, green: xy, blue: xy })
|
|
33
|
+
.optional()
|
|
34
|
+
.catch(undefined),
|
|
35
|
+
})
|
|
36
|
+
.optional()
|
|
37
|
+
.catch(undefined),
|
|
38
|
+
// mirek is null while the light shows a colour; the schema is this light's own range.
|
|
39
|
+
color_temperature: z
|
|
40
|
+
.looseObject({
|
|
41
|
+
mirek: z.number().nullable().optional(),
|
|
42
|
+
mirek_schema: z
|
|
43
|
+
.looseObject({
|
|
44
|
+
mirek_minimum: z.number().optional(),
|
|
45
|
+
mirek_maximum: z.number().optional(),
|
|
46
|
+
})
|
|
47
|
+
.optional(),
|
|
48
|
+
})
|
|
49
|
+
.optional()
|
|
50
|
+
.catch(undefined),
|
|
25
51
|
children: z.array(ref).optional(),
|
|
26
52
|
services: z.array(ref).optional(),
|
|
27
53
|
group: ref.optional(), // scene: the room or zone it belongs to
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type Gamut, type XY } from "./color.ts";
|
|
1
2
|
import type { HueEvent, Resource } from "./schemas.ts";
|
|
2
3
|
/** Every resource on the bridge by id, from the last full read plus every event since. `asOf` is local time it was last known fresh. */
|
|
3
4
|
export type HueSnapshot = {
|
|
@@ -17,8 +18,16 @@ export type LightView = {
|
|
|
17
18
|
on?: boolean;
|
|
18
19
|
brightness?: number;
|
|
19
20
|
reachable?: boolean;
|
|
21
|
+
/** White temperature in kelvin, while the light shows a white; its range is `kelvinRange` (warmest, coolest). */
|
|
22
|
+
kelvin?: number;
|
|
23
|
+
kelvinRange?: [number, number];
|
|
24
|
+
/** The colour it shows: the bridge's `xy`, and `hex` for a screen (the hue at full brightness). Colour lights only. */
|
|
25
|
+
xy?: XY;
|
|
26
|
+
hex?: string;
|
|
27
|
+
/** The colours it can show. Present on a colour light, so its presence says "takes a colour". */
|
|
28
|
+
gamut?: Gamut;
|
|
20
29
|
};
|
|
21
|
-
/** Every light, with on, brightness and
|
|
30
|
+
/** Every light, with on, brightness, reachability, and its white temperature and colour where it has them. */
|
|
22
31
|
export declare function lights(s: HueSnapshot): LightView[];
|
|
23
32
|
/** A room or zone as a row shows it, with its grouped light's state. */
|
|
24
33
|
export type GroupView = {
|