@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,6 +1,17 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { colorToXy, kelvinToMirek, type Rgb, type XY } from "./color.ts";
|
|
2
3
|
import { Envelope, type Kind, PairResponse, Resource } from "./schemas.ts";
|
|
3
|
-
import
|
|
4
|
+
import {
|
|
5
|
+
type GroupView,
|
|
6
|
+
groups,
|
|
7
|
+
type LightView,
|
|
8
|
+
lights,
|
|
9
|
+
scenes,
|
|
10
|
+
snapshotFrom,
|
|
11
|
+
homeGroupedLightId,
|
|
12
|
+
} from "./state.ts";
|
|
13
|
+
import { HUE_BRIDGE_ROOT_CA } from "./ca.ts";
|
|
14
|
+
import { type Clock, realClock } from "../../clock.ts";
|
|
4
15
|
import { ConnectorError, fetchTimeout } from "../../connector.ts";
|
|
5
16
|
import { writeLane } from "../../lane.ts";
|
|
6
17
|
|
|
@@ -12,12 +23,43 @@ export type HueOptions = {
|
|
|
12
23
|
clock?: Clock;
|
|
13
24
|
};
|
|
14
25
|
|
|
15
|
-
/** A failed bridge call. `code` is "link_button" when pairing waits on the bridge's button. */
|
|
26
|
+
/** A failed bridge call. `code` is "link_button" when pairing waits on the bridge's button, "invalid" for a change that can't be made (an unknown name, a value off the light's range) and "offline" for a light the bridge has lost, both caught before anything is sent. */
|
|
16
27
|
export class HueError extends ConnectorError {
|
|
17
|
-
declare readonly code?: "link_button";
|
|
28
|
+
declare readonly code?: "link_button" | "invalid" | "offline";
|
|
18
29
|
}
|
|
19
30
|
|
|
20
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* What to change on a light, or on a room or zone: only what is given is sent. `brightness` is 1–100, `color` a hex
|
|
33
|
+
* string or `{ r, g, b }`, `kelvin` a white temperature; `color` and `kelvin` are one or the other. Without `on`, a
|
|
34
|
+
* change turns the light on, since a light that is off takes no brightness or colour. `transitionMs` is the fade (the
|
|
35
|
+
* bridge's own is 400 ms; 0 is at once).
|
|
36
|
+
*/
|
|
37
|
+
export type LightChange = {
|
|
38
|
+
on?: boolean;
|
|
39
|
+
brightness?: number;
|
|
40
|
+
color?: string | Rgb;
|
|
41
|
+
kelvin?: number;
|
|
42
|
+
transitionMs?: number;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* What a write left: the light or group once the bridge reported the change (`confirmed`), or as it stood when
|
|
47
|
+
* `CONFIRM_MS` ran out. With `{ dryRun: true }` nothing is sent: `wouldSend` is the request the write would make, `state`
|
|
48
|
+
* is how things stand now, and `confirmed` is false. Check `wouldSend` first.
|
|
49
|
+
*/
|
|
50
|
+
export type HueApplied<V> = {
|
|
51
|
+
state: V;
|
|
52
|
+
confirmed: boolean;
|
|
53
|
+
wouldSend?: HueRequest;
|
|
54
|
+
};
|
|
55
|
+
/** One CLIP v2 write: `PUT /clip/v2/resource/<path>` with this body. */
|
|
56
|
+
export type HueRequest = { method: "PUT"; path: string; body: object };
|
|
57
|
+
/** `dryRun`: every check a write makes before sending (the name, the range, a lost light) and nothing sent. For testing a bot with people home. */
|
|
58
|
+
export type HueWriteOptions = { dryRun?: boolean };
|
|
59
|
+
/** How long `light`, `room` and `scene` wait for the bridge to report what was asked, reading every half second. */
|
|
60
|
+
export const CONFIRM_MS = 3000;
|
|
61
|
+
|
|
62
|
+
/** Minimum gap between writes, per lane: Signify's guidance (Hue System Performance, read 2026-11-11), about 10 a second to lights and 1 a second to groups. */
|
|
21
63
|
export const WRITE_GAP_MS = { group: 1000, light: 100 } as const;
|
|
22
64
|
type Lane = keyof typeof WRITE_GAP_MS;
|
|
23
65
|
|
|
@@ -83,7 +125,8 @@ export function hueClient(options: HueOptions) {
|
|
|
83
125
|
mergeBody,
|
|
84
126
|
);
|
|
85
127
|
|
|
86
|
-
|
|
128
|
+
const clock = options.clock ?? realClock;
|
|
129
|
+
const base = {
|
|
87
130
|
put,
|
|
88
131
|
/** Every resource of one kind, or every resource on the bridge when `kind` is left out (one call). */
|
|
89
132
|
async get(kind?: Kind, signal?: AbortSignal): Promise<Resource[]> {
|
|
@@ -103,6 +146,266 @@ export function hueClient(options: HueOptions) {
|
|
|
103
146
|
return put("scene", id, { recall: { action: "active" } });
|
|
104
147
|
},
|
|
105
148
|
};
|
|
149
|
+
|
|
150
|
+
/** Whether two of these labels are the same, so only an id tells them apart. */
|
|
151
|
+
const same = (labels: string[]) => new Set(labels).size < labels.length;
|
|
152
|
+
const invalid = (message: string) =>
|
|
153
|
+
new HueError(message, { code: "invalid" });
|
|
154
|
+
/** One of `items` by id or by name (any case). A miss, or a name two of them share, throws listing what exists. */
|
|
155
|
+
function pick<T extends { id: string; name?: string }>(
|
|
156
|
+
items: T[],
|
|
157
|
+
target: string,
|
|
158
|
+
what: string,
|
|
159
|
+
label: (item: T) => string = (i) => i.name ?? i.id,
|
|
160
|
+
): T {
|
|
161
|
+
const wanted = target.trim().toLowerCase();
|
|
162
|
+
const found = items.filter(
|
|
163
|
+
(i) => i.id === target || i.name?.toLowerCase() === wanted,
|
|
164
|
+
);
|
|
165
|
+
if (found.length === 1) return found[0]!;
|
|
166
|
+
throw invalid(
|
|
167
|
+
found.length
|
|
168
|
+
? `"${target}" names ${found.length}: ${same(found.map(label)) ? found.map((i) => `${label(i)} (${i.id})`).join(", ") : found.map(label).join(", ")}`
|
|
169
|
+
: `no ${what} "${target}"; have: ${items.map(label).join(", ") || "none"}`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
const read = async () => snapshotFrom(await base.get(), "");
|
|
173
|
+
|
|
174
|
+
/** The CLIP body for `change`, checked against what the light (or a group's lights) can take. Throws before anything is sent. */
|
|
175
|
+
function bodyFor(
|
|
176
|
+
change: LightChange,
|
|
177
|
+
name: string,
|
|
178
|
+
members: LightView[],
|
|
179
|
+
isOn: boolean | undefined,
|
|
180
|
+
): Record<string, unknown> {
|
|
181
|
+
const { on, brightness, color, kelvin, transitionMs } = change;
|
|
182
|
+
const look =
|
|
183
|
+
brightness !== undefined || color !== undefined || kelvin !== undefined;
|
|
184
|
+
if (on === undefined && !look) throw invalid("nothing to change");
|
|
185
|
+
if (on === false && look)
|
|
186
|
+
throw invalid("off takes no brightness or colour");
|
|
187
|
+
if (color !== undefined && kelvin !== undefined)
|
|
188
|
+
throw invalid("a colour or a white temperature, not both");
|
|
189
|
+
const body: Record<string, unknown> = {};
|
|
190
|
+
if (on !== undefined) body.on = { on };
|
|
191
|
+
else if (isOn !== true) body.on = { on: true }; // off, or a group where some may be
|
|
192
|
+
if (brightness !== undefined) {
|
|
193
|
+
if (!(brightness >= 1 && brightness <= 100))
|
|
194
|
+
throw invalid(`brightness is 1–100, not ${brightness}`);
|
|
195
|
+
body.dimming = { brightness };
|
|
196
|
+
}
|
|
197
|
+
if (color !== undefined) {
|
|
198
|
+
const gamut = members.find((l) => l.gamut)?.gamut;
|
|
199
|
+
if (!gamut) throw invalid(`${name} has no colour light`);
|
|
200
|
+
try {
|
|
201
|
+
body.color = { xy: colorToXy(color, gamut) };
|
|
202
|
+
} catch (error) {
|
|
203
|
+
throw invalid((error as Error).message); // a bad colour, as every other change that can't be made
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (kelvin !== undefined) {
|
|
207
|
+
const ranges = members.flatMap((l) =>
|
|
208
|
+
l.kelvinRange ? [l.kelvinRange] : [],
|
|
209
|
+
);
|
|
210
|
+
if (!ranges.length) throw invalid(`${name} takes no white temperature`);
|
|
211
|
+
const low = Math.min(...ranges.map((r) => r[0]));
|
|
212
|
+
const high = Math.max(...ranges.map((r) => r[1]));
|
|
213
|
+
if (!(kelvin >= low && kelvin <= high))
|
|
214
|
+
throw invalid(
|
|
215
|
+
`${kelvin} K is outside ${name}'s range, ${low}–${high} K`,
|
|
216
|
+
);
|
|
217
|
+
body.color_temperature = { mirek: kelvinToMirek(kelvin) };
|
|
218
|
+
}
|
|
219
|
+
if (transitionMs !== undefined) {
|
|
220
|
+
if (!(Number.isInteger(transitionMs) && transitionMs >= 0))
|
|
221
|
+
throw invalid(
|
|
222
|
+
`transitionMs is a whole number of ms, not ${transitionMs}`,
|
|
223
|
+
);
|
|
224
|
+
body.dynamics = { duration: transitionMs };
|
|
225
|
+
}
|
|
226
|
+
return body;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Read every half second until `view()` gives a state that is as `asked`, or `CONFIRM_MS` passes. A failed read is not a failed write. */
|
|
230
|
+
async function confirm<V>(
|
|
231
|
+
view: () => Promise<V | undefined>,
|
|
232
|
+
asked: (v: V) => boolean,
|
|
233
|
+
): Promise<HueApplied<V>> {
|
|
234
|
+
const until = clock.now() + CONFIRM_MS;
|
|
235
|
+
let state: V | undefined;
|
|
236
|
+
let failed: unknown;
|
|
237
|
+
for (;;) {
|
|
238
|
+
try {
|
|
239
|
+
state = (await view()) ?? state;
|
|
240
|
+
if (state !== undefined && asked(state)) break;
|
|
241
|
+
} catch (error) {
|
|
242
|
+
failed = error;
|
|
243
|
+
}
|
|
244
|
+
if (clock.now() >= until) break;
|
|
245
|
+
await clock.sleep(500);
|
|
246
|
+
}
|
|
247
|
+
if (state === undefined)
|
|
248
|
+
throw failed ?? new HueError("sent, but it is no longer on the bridge");
|
|
249
|
+
return { state, confirmed: asked(state) };
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Whether a view shows what the body asked: on, brightness within a point, and on a light the white temperature within
|
|
253
|
+
* 2 mirek or the colour within 0.02 in `xy`. A room reports neither, so its colour is not compared (its `on` is).
|
|
254
|
+
*/
|
|
255
|
+
const shows =
|
|
256
|
+
(body: Record<string, unknown>, light: boolean) =>
|
|
257
|
+
(v: Partial<LightView>): boolean => {
|
|
258
|
+
const on = (body.on as { on: boolean } | undefined)?.on;
|
|
259
|
+
const brightness = (body.dimming as { brightness: number } | undefined)
|
|
260
|
+
?.brightness;
|
|
261
|
+
const mirek = (body.color_temperature as { mirek: number } | undefined)
|
|
262
|
+
?.mirek;
|
|
263
|
+
const xy = (body.color as { xy: XY } | undefined)?.xy;
|
|
264
|
+
return (
|
|
265
|
+
(on === undefined || v.on === on) &&
|
|
266
|
+
(brightness === undefined ||
|
|
267
|
+
(v.brightness !== undefined &&
|
|
268
|
+
Math.abs(v.brightness - brightness) <= 1)) &&
|
|
269
|
+
(!light ||
|
|
270
|
+
mirek === undefined ||
|
|
271
|
+
(v.kelvin !== undefined &&
|
|
272
|
+
Math.abs(kelvinToMirek(v.kelvin) - mirek) <= 2)) &&
|
|
273
|
+
(!light ||
|
|
274
|
+
xy === undefined ||
|
|
275
|
+
(v.xy !== undefined &&
|
|
276
|
+
Math.abs(v.xy.x - xy.x) <= 0.02 &&
|
|
277
|
+
Math.abs(v.xy.y - xy.y) <= 0.02))
|
|
278
|
+
);
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
/** Send, or with `dryRun` say what would be sent and how `now` stands. */
|
|
282
|
+
async function send<V>(
|
|
283
|
+
options: HueWriteOptions | undefined,
|
|
284
|
+
kind: Kind,
|
|
285
|
+
id: string,
|
|
286
|
+
body: object,
|
|
287
|
+
now: V | undefined,
|
|
288
|
+
after: () => Promise<HueApplied<V>>,
|
|
289
|
+
): Promise<HueApplied<V>> {
|
|
290
|
+
if (options?.dryRun) {
|
|
291
|
+
if (now === undefined)
|
|
292
|
+
throw new HueError("its room or zone is not on the bridge");
|
|
293
|
+
return {
|
|
294
|
+
state: now,
|
|
295
|
+
confirmed: false,
|
|
296
|
+
wouldSend: { method: "PUT", path: `${kind}/${id}`, body },
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
await put(kind, id, body);
|
|
300
|
+
return after();
|
|
301
|
+
}
|
|
302
|
+
return {
|
|
303
|
+
...base,
|
|
304
|
+
/** Change one light, by name or id. Only when a person asks. Resolves with the light as the bridge then reported it. */
|
|
305
|
+
async light(
|
|
306
|
+
target: string,
|
|
307
|
+
change: LightChange,
|
|
308
|
+
options?: HueWriteOptions,
|
|
309
|
+
): Promise<HueApplied<LightView>> {
|
|
310
|
+
const l = pick(lights(await read()), target, "light");
|
|
311
|
+
// The bridge takes a write to a light it has lost and answers "may not have effect": say so before sending.
|
|
312
|
+
if (l.reachable === false)
|
|
313
|
+
throw new HueError(
|
|
314
|
+
`${l.name ?? l.id} is unreachable: the bridge has lost it (is it switched off at the wall?)`,
|
|
315
|
+
{ code: "offline" },
|
|
316
|
+
);
|
|
317
|
+
const body = bodyFor(change, l.name ?? l.id, [l], l.on);
|
|
318
|
+
return send(options, "light", l.id, body, l, () =>
|
|
319
|
+
confirm(
|
|
320
|
+
async () => lights(await read()).find((x) => x.id === l.id),
|
|
321
|
+
shows(body, true),
|
|
322
|
+
),
|
|
323
|
+
);
|
|
324
|
+
},
|
|
325
|
+
/**
|
|
326
|
+
* Every light in the home off, with one write to the bridge's home group. Only when a person asks. Resolves with
|
|
327
|
+
* every light as the bridge then reported it, `confirmed` once none it can reach is on.
|
|
328
|
+
*/
|
|
329
|
+
async allOff(options?: HueWriteOptions): Promise<HueApplied<LightView[]>> {
|
|
330
|
+
const s = await read();
|
|
331
|
+
const id = homeGroupedLightId(s);
|
|
332
|
+
if (!id) throw invalid("the bridge has no home group");
|
|
333
|
+
return send(
|
|
334
|
+
options,
|
|
335
|
+
"grouped_light",
|
|
336
|
+
id,
|
|
337
|
+
{ on: { on: false } },
|
|
338
|
+
lights(s),
|
|
339
|
+
() =>
|
|
340
|
+
confirm(
|
|
341
|
+
async () => lights(await read()),
|
|
342
|
+
(all) => all.every((l) => !l.on || l.reachable === false),
|
|
343
|
+
),
|
|
344
|
+
);
|
|
345
|
+
},
|
|
346
|
+
/**
|
|
347
|
+
* Change every light in a room or zone with one write, by name or id. Only when a person asks. Resolves with the
|
|
348
|
+
* group as the bridge then reported it; its brightness is the bridge's figure for the group, so a group holding a
|
|
349
|
+
* light that is off or unreachable may stay unconfirmed.
|
|
350
|
+
*/
|
|
351
|
+
async room(
|
|
352
|
+
target: string,
|
|
353
|
+
change: LightChange,
|
|
354
|
+
options?: HueWriteOptions,
|
|
355
|
+
): Promise<HueApplied<GroupView>> {
|
|
356
|
+
const s = await read();
|
|
357
|
+
const g = pick(groups(s), target, "room or zone");
|
|
358
|
+
const name = g.name ?? g.id;
|
|
359
|
+
if (!g.groupedLightId) throw invalid(`${name} has no lights`);
|
|
360
|
+
const members = lights(s).filter((l) => g.lightIds.includes(l.id));
|
|
361
|
+
const body = bodyFor(change, name, members, undefined);
|
|
362
|
+
return send(options, "grouped_light", g.groupedLightId, body, g, () =>
|
|
363
|
+
confirm(
|
|
364
|
+
async () => groups(await read()).find((x) => x.id === g.id),
|
|
365
|
+
shows(body, false),
|
|
366
|
+
),
|
|
367
|
+
);
|
|
368
|
+
},
|
|
369
|
+
/**
|
|
370
|
+
* Recall a scene by name or id. A name several rooms use ("Relax") needs `room`. Only when a person asks. Resolves
|
|
371
|
+
* with the scene's room or zone, `confirmed` once the bridge reports the scene active.
|
|
372
|
+
*/
|
|
373
|
+
async scene(
|
|
374
|
+
target: string,
|
|
375
|
+
room?: string,
|
|
376
|
+
options?: HueWriteOptions,
|
|
377
|
+
): Promise<HueApplied<GroupView>> {
|
|
378
|
+
const s = await read();
|
|
379
|
+
const all = groups(s);
|
|
380
|
+
const within =
|
|
381
|
+
room === undefined ? undefined : pick(all, room, "room or zone");
|
|
382
|
+
const roomName = (id?: string) =>
|
|
383
|
+
all.find((g) => g.id === id)?.name ?? "no room";
|
|
384
|
+
const sc = pick(
|
|
385
|
+
scenes(s).filter((x) => !within || x.groupId === within.id),
|
|
386
|
+
target,
|
|
387
|
+
"scene",
|
|
388
|
+
(x) => `${x.name ?? x.id} in ${roomName(x.groupId)}`,
|
|
389
|
+
);
|
|
390
|
+
let active = false;
|
|
391
|
+
return send(
|
|
392
|
+
options,
|
|
393
|
+
"scene",
|
|
394
|
+
sc.id,
|
|
395
|
+
{ recall: { action: "active" } },
|
|
396
|
+
all.find((g) => g.id === sc.groupId),
|
|
397
|
+
() =>
|
|
398
|
+
confirm(
|
|
399
|
+
async () => {
|
|
400
|
+
const now = await read();
|
|
401
|
+
active = scenes(now).find((x) => x.id === sc.id)?.active === true;
|
|
402
|
+
return groups(now).find((x) => x.id === sc.groupId);
|
|
403
|
+
},
|
|
404
|
+
() => active,
|
|
405
|
+
),
|
|
406
|
+
);
|
|
407
|
+
},
|
|
408
|
+
};
|
|
106
409
|
}
|
|
107
410
|
export type HueClient = ReturnType<typeof hueClient>;
|
|
108
411
|
|
|
@@ -111,15 +414,21 @@ export async function pair(options: {
|
|
|
111
414
|
host: string;
|
|
112
415
|
fetch: typeof fetch;
|
|
113
416
|
deviceType: string;
|
|
417
|
+
signal?: AbortSignal;
|
|
114
418
|
}): Promise<{ key: string; clientKey?: string }> {
|
|
115
|
-
const res = await fetchTimeout(
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
419
|
+
const res = await fetchTimeout(
|
|
420
|
+
options.fetch,
|
|
421
|
+
`https://${options.host}/api`,
|
|
422
|
+
{
|
|
423
|
+
method: "POST",
|
|
424
|
+
headers: { "content-type": "application/json" },
|
|
425
|
+
body: JSON.stringify({
|
|
426
|
+
devicetype: options.deviceType,
|
|
427
|
+
generateclientkey: true,
|
|
428
|
+
}),
|
|
429
|
+
},
|
|
430
|
+
{ signal: options.signal },
|
|
431
|
+
);
|
|
123
432
|
const parsed = PairResponse.safeParse(
|
|
124
433
|
await res.json().catch(() => undefined),
|
|
125
434
|
);
|
|
@@ -138,10 +447,48 @@ export async function pair(options: {
|
|
|
138
447
|
}
|
|
139
448
|
|
|
140
449
|
/**
|
|
141
|
-
*
|
|
450
|
+
* `pair`, asked again every `everyMs` (2 s) until someone presses the bridge's button, for `timeoutMs` (30 s, as long as
|
|
451
|
+
* the button stays pressed). `onWaiting` is called once, when the bridge first says the button isn't pressed: the moment
|
|
452
|
+
* to tell the person. Throws the last `link_button` error when time runs out, any other error at once, and the signal's
|
|
453
|
+
* reason when aborted.
|
|
454
|
+
*/
|
|
455
|
+
export async function pairWhenPressed(options: {
|
|
456
|
+
host: string;
|
|
457
|
+
fetch: typeof fetch;
|
|
458
|
+
deviceType: string;
|
|
459
|
+
timeoutMs?: number;
|
|
460
|
+
everyMs?: number;
|
|
461
|
+
onWaiting?: () => void;
|
|
462
|
+
clock?: Clock;
|
|
463
|
+
signal?: AbortSignal;
|
|
464
|
+
}): Promise<{ key: string; clientKey?: string }> {
|
|
465
|
+
const clock = options.clock ?? realClock;
|
|
466
|
+
const until = clock.now() + (options.timeoutMs ?? 30_000);
|
|
467
|
+
let waiting = false;
|
|
468
|
+
while (true) {
|
|
469
|
+
try {
|
|
470
|
+
return await pair(options);
|
|
471
|
+
} catch (error) {
|
|
472
|
+
const pressed = !(
|
|
473
|
+
error instanceof HueError && error.code === "link_button"
|
|
474
|
+
);
|
|
475
|
+
if (pressed || clock.now() >= until) throw error;
|
|
476
|
+
if (!waiting) options.onWaiting?.();
|
|
477
|
+
waiting = true;
|
|
478
|
+
}
|
|
479
|
+
await clock.sleep(options.everyMs ?? 2000, options.signal);
|
|
480
|
+
options.signal?.throwIfAborted();
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* A `fetch` that trusts only `ca` (Signify's roots, `HUE_BRIDGE_ROOT_CA`, when left out) and accepts only a certificate whose CN is `bridgeId`.
|
|
142
486
|
* Uses Bun's `tls` fetch option; Node ignores it and then rejects the bridge, so it fails closed there; Node uses `nodePinnedFetch` from `@khalido/home/node`.
|
|
143
487
|
*/
|
|
144
|
-
export function bunPinnedFetch(
|
|
488
|
+
export function bunPinnedFetch(
|
|
489
|
+
bridgeId: string,
|
|
490
|
+
ca: string = HUE_BRIDGE_ROOT_CA,
|
|
491
|
+
): typeof fetch {
|
|
145
492
|
const checkServerIdentity = (
|
|
146
493
|
_host: string,
|
|
147
494
|
cert: { subject?: { CN?: string } },
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { ConnectorError } from "../../connector.ts";
|
|
2
|
+
|
|
3
|
+
// Colour for Hue lights. A light's colour is an `xy` point in the CIE 1931 space, and each light can show the points
|
|
4
|
+
// inside its gamut triangle, which the bridge reports per light. The steps are the standard sRGB ones (gamma, the D65
|
|
5
|
+
// matrix), as Signify's "Color Conversion Formulas" page describes; that page's SDK extract is not copied.
|
|
6
|
+
|
|
7
|
+
export type XY = { x: number; y: number };
|
|
8
|
+
export type Gamut = { red: XY; green: XY; blue: XY };
|
|
9
|
+
export type Rgb = { r: number; g: number; b: number };
|
|
10
|
+
|
|
11
|
+
/** A colour as a caller gives it: "#ff8800", "ff8800", "#f80", or `{ r, g, b }` each 0–255. Throws on anything else. */
|
|
12
|
+
export function parseColor(color: string | Rgb): Rgb {
|
|
13
|
+
if (typeof color !== "string") {
|
|
14
|
+
const ok = [color.r, color.g, color.b].every(
|
|
15
|
+
(v) => Number.isFinite(v) && v >= 0 && v <= 255,
|
|
16
|
+
);
|
|
17
|
+
if (!ok)
|
|
18
|
+
throw new ConnectorError("a colour's r, g and b are each 0–255", {
|
|
19
|
+
code: "invalid",
|
|
20
|
+
});
|
|
21
|
+
return color;
|
|
22
|
+
}
|
|
23
|
+
const hex = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(color.trim())?.[1];
|
|
24
|
+
if (!hex)
|
|
25
|
+
throw new ConnectorError(`"${color}" is not a colour like #ff8800`, {
|
|
26
|
+
code: "invalid",
|
|
27
|
+
});
|
|
28
|
+
const full = hex.length === 3 ? [...hex].map((c) => c + c).join("") : hex;
|
|
29
|
+
const n = Number.parseInt(full, 16);
|
|
30
|
+
return { r: n >> 16, g: (n >> 8) & 255, b: n & 255 };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const linear = (v: number) =>
|
|
34
|
+
v > 0.04045 ? ((v + 0.055) / 1.055) ** 2.4 : v / 12.92;
|
|
35
|
+
const gamma = (v: number) =>
|
|
36
|
+
v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055;
|
|
37
|
+
|
|
38
|
+
const cross = (a: XY, b: XY) => a.x * b.y - a.y * b.x;
|
|
39
|
+
const minus = (a: XY, b: XY): XY => ({ x: a.x - b.x, y: a.y - b.y });
|
|
40
|
+
|
|
41
|
+
/** Whether `p` is inside the gamut triangle. */
|
|
42
|
+
export function inGamut(p: XY, g: Gamut): boolean {
|
|
43
|
+
const v1 = minus(g.green, g.red);
|
|
44
|
+
const v2 = minus(g.blue, g.red);
|
|
45
|
+
const q = minus(p, g.red);
|
|
46
|
+
const s = cross(q, v2) / cross(v1, v2);
|
|
47
|
+
const t = cross(v1, q) / cross(v1, v2);
|
|
48
|
+
return s >= 0 && t >= 0 && s + t <= 1;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The nearest point to `p` on the segment a–b. */
|
|
52
|
+
function onSegment(a: XY, b: XY, p: XY): XY {
|
|
53
|
+
const ab = minus(b, a);
|
|
54
|
+
const t = Math.min(
|
|
55
|
+
1,
|
|
56
|
+
Math.max(
|
|
57
|
+
0,
|
|
58
|
+
((p.x - a.x) * ab.x + (p.y - a.y) * ab.y) / (ab.x ** 2 + ab.y ** 2),
|
|
59
|
+
),
|
|
60
|
+
);
|
|
61
|
+
return { x: a.x + ab.x * t, y: a.y + ab.y * t };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** `p` if the light can show it, else the nearest point on its gamut's edge: the colour it can show that is closest. */
|
|
65
|
+
export function nearestInGamut(p: XY, g: Gamut): XY {
|
|
66
|
+
if (inGamut(p, g)) return p;
|
|
67
|
+
const edges = [
|
|
68
|
+
onSegment(g.red, g.green, p),
|
|
69
|
+
onSegment(g.green, g.blue, p),
|
|
70
|
+
onSegment(g.blue, g.red, p),
|
|
71
|
+
];
|
|
72
|
+
const far = (q: XY) => (q.x - p.x) ** 2 + (q.y - p.y) ** 2;
|
|
73
|
+
return edges.reduce((best, q) => (far(q) < far(best) ? q : best));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A colour's `xy`, moved inside `gamut` when one is given. Brightness is not part of it: black has no colour and throws
|
|
78
|
+
* (turn the light off instead), and a dark red and a bright red are the same point.
|
|
79
|
+
*/
|
|
80
|
+
export function colorToXy(color: string | Rgb, gamut?: Gamut): XY {
|
|
81
|
+
const { r, g, b } = parseColor(color);
|
|
82
|
+
const [lr, lg, lb] = [r, g, b].map((v) => linear(v / 255)) as [
|
|
83
|
+
number,
|
|
84
|
+
number,
|
|
85
|
+
number,
|
|
86
|
+
];
|
|
87
|
+
const X = lr * 0.4124 + lg * 0.3576 + lb * 0.1805;
|
|
88
|
+
const Y = lr * 0.2126 + lg * 0.7152 + lb * 0.0722;
|
|
89
|
+
const Z = lr * 0.0193 + lg * 0.1192 + lb * 0.9505;
|
|
90
|
+
const sum = X + Y + Z;
|
|
91
|
+
if (sum === 0)
|
|
92
|
+
throw new ConnectorError("black is not a colour a light can show", {
|
|
93
|
+
code: "invalid",
|
|
94
|
+
});
|
|
95
|
+
const xy = { x: X / sum, y: Y / sum };
|
|
96
|
+
const at = gamut ? nearestInGamut(xy, gamut) : xy;
|
|
97
|
+
return { x: round4(at.x), y: round4(at.y) };
|
|
98
|
+
}
|
|
99
|
+
const round4 = (v: number) => Math.round(v * 1e4) / 1e4;
|
|
100
|
+
|
|
101
|
+
/** An `xy` as a hex colour for a screen, at full brightness: what the light's colour looks like, not how bright it is. */
|
|
102
|
+
export function xyToHex(xy: XY): string {
|
|
103
|
+
if (xy.y <= 0) return "#000000";
|
|
104
|
+
const X = xy.x / xy.y;
|
|
105
|
+
const Z = (1 - xy.x - xy.y) / xy.y;
|
|
106
|
+
const rgb = [
|
|
107
|
+
X * 3.2406 - 1.5372 - Z * 0.4986,
|
|
108
|
+
-X * 0.9689 + 1.8758 + Z * 0.0415,
|
|
109
|
+
X * 0.0557 - 0.204 + Z * 1.057,
|
|
110
|
+
].map((v) => Math.max(0, v));
|
|
111
|
+
const max = Math.max(...rgb);
|
|
112
|
+
return `#${rgb
|
|
113
|
+
.map((v) =>
|
|
114
|
+
Math.round(255 * Math.min(1, Math.max(0, gamma(max > 0 ? v / max : 0))))
|
|
115
|
+
.toString(16)
|
|
116
|
+
.padStart(2, "0"),
|
|
117
|
+
)
|
|
118
|
+
.join("")}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Colour temperature: Hue counts in mirek, a million over the kelvin. 2700 K is 370 mirek. */
|
|
122
|
+
export const kelvinToMirek = (kelvin: number) => Math.round(1e6 / kelvin);
|
|
123
|
+
export const mirekToKelvin = (mirek: number) => Math.round(1e6 / mirek);
|