@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
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { type Rgb } from "./color.ts";
|
|
1
2
|
import { type Kind, Resource } from "./schemas.ts";
|
|
2
|
-
import type
|
|
3
|
+
import { type GroupView, type LightView } from "./state.ts";
|
|
4
|
+
import { type Clock } from "../../clock.ts";
|
|
3
5
|
import { ConnectorError } from "../../connector.ts";
|
|
4
6
|
/** What a Hue client needs: the bridge's LAN address, its application key, and a pinned `fetch`. */
|
|
5
7
|
export type HueOptions = {
|
|
@@ -8,11 +10,46 @@ export type HueOptions = {
|
|
|
8
10
|
fetch: typeof fetch;
|
|
9
11
|
clock?: Clock;
|
|
10
12
|
};
|
|
11
|
-
/** A failed bridge call. `code` is "link_button" when pairing waits on the bridge's button. */
|
|
13
|
+
/** A failed bridge call. `code` is "link_button" when pairing waits on the bridge's button, "invalid" for a change that can't be made (an unknown name, a value off the light's range) and "offline" for a light the bridge has lost, both caught before anything is sent. */
|
|
12
14
|
export declare class HueError extends ConnectorError {
|
|
13
|
-
readonly code?: "link_button";
|
|
15
|
+
readonly code?: "link_button" | "invalid" | "offline";
|
|
14
16
|
}
|
|
15
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* What to change on a light, or on a room or zone: only what is given is sent. `brightness` is 1–100, `color` a hex
|
|
19
|
+
* string or `{ r, g, b }`, `kelvin` a white temperature; `color` and `kelvin` are one or the other. Without `on`, a
|
|
20
|
+
* change turns the light on, since a light that is off takes no brightness or colour. `transitionMs` is the fade (the
|
|
21
|
+
* bridge's own is 400 ms; 0 is at once).
|
|
22
|
+
*/
|
|
23
|
+
export type LightChange = {
|
|
24
|
+
on?: boolean;
|
|
25
|
+
brightness?: number;
|
|
26
|
+
color?: string | Rgb;
|
|
27
|
+
kelvin?: number;
|
|
28
|
+
transitionMs?: number;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* What a write left: the light or group once the bridge reported the change (`confirmed`), or as it stood when
|
|
32
|
+
* `CONFIRM_MS` ran out. With `{ dryRun: true }` nothing is sent: `wouldSend` is the request the write would make, `state`
|
|
33
|
+
* is how things stand now, and `confirmed` is false. Check `wouldSend` first.
|
|
34
|
+
*/
|
|
35
|
+
export type HueApplied<V> = {
|
|
36
|
+
state: V;
|
|
37
|
+
confirmed: boolean;
|
|
38
|
+
wouldSend?: HueRequest;
|
|
39
|
+
};
|
|
40
|
+
/** One CLIP v2 write: `PUT /clip/v2/resource/<path>` with this body. */
|
|
41
|
+
export type HueRequest = {
|
|
42
|
+
method: "PUT";
|
|
43
|
+
path: string;
|
|
44
|
+
body: object;
|
|
45
|
+
};
|
|
46
|
+
/** `dryRun`: every check a write makes before sending (the name, the range, a lost light) and nothing sent. For testing a bot with people home. */
|
|
47
|
+
export type HueWriteOptions = {
|
|
48
|
+
dryRun?: boolean;
|
|
49
|
+
};
|
|
50
|
+
/** How long `light`, `room` and `scene` wait for the bridge to report what was asked, reading every half second. */
|
|
51
|
+
export declare const CONFIRM_MS = 3000;
|
|
52
|
+
/** Minimum gap between writes, per lane: Signify's guidance (Hue System Performance, read 2026-11-11), about 10 a second to lights and 1 a second to groups. */
|
|
16
53
|
export declare const WRITE_GAP_MS: {
|
|
17
54
|
readonly group: 1000;
|
|
18
55
|
readonly light: 100;
|
|
@@ -29,6 +66,24 @@ export declare function hueClient(options: HueOptions): {
|
|
|
29
66
|
get(kind?: Kind, signal?: AbortSignal): Promise<Resource[]>;
|
|
30
67
|
/** Recall a scene in its room or zone. */
|
|
31
68
|
recallScene(id: string): Promise<unknown[]>;
|
|
69
|
+
/** Change one light, by name or id. Only when a person asks. Resolves with the light as the bridge then reported it. */
|
|
70
|
+
light(target: string, change: LightChange, options?: HueWriteOptions): Promise<HueApplied<LightView>>;
|
|
71
|
+
/**
|
|
72
|
+
* Every light in the home off, with one write to the bridge's home group. Only when a person asks. Resolves with
|
|
73
|
+
* every light as the bridge then reported it, `confirmed` once none it can reach is on.
|
|
74
|
+
*/
|
|
75
|
+
allOff(options?: HueWriteOptions): Promise<HueApplied<LightView[]>>;
|
|
76
|
+
/**
|
|
77
|
+
* Change every light in a room or zone with one write, by name or id. Only when a person asks. Resolves with the
|
|
78
|
+
* group as the bridge then reported it; its brightness is the bridge's figure for the group, so a group holding a
|
|
79
|
+
* light that is off or unreachable may stay unconfirmed.
|
|
80
|
+
*/
|
|
81
|
+
room(target: string, change: LightChange, options?: HueWriteOptions): Promise<HueApplied<GroupView>>;
|
|
82
|
+
/**
|
|
83
|
+
* Recall a scene by name or id. A name several rooms use ("Relax") needs `room`. Only when a person asks. Resolves
|
|
84
|
+
* with the scene's room or zone, `confirmed` once the bridge reports the scene active.
|
|
85
|
+
*/
|
|
86
|
+
scene(target: string, room?: string, options?: HueWriteOptions): Promise<HueApplied<GroupView>>;
|
|
32
87
|
};
|
|
33
88
|
export type HueClient = ReturnType<typeof hueClient>;
|
|
34
89
|
/** Ask the bridge for an application key. One call: throws `HueError` with code "link_button" until KO presses it. */
|
|
@@ -36,13 +91,33 @@ export declare function pair(options: {
|
|
|
36
91
|
host: string;
|
|
37
92
|
fetch: typeof fetch;
|
|
38
93
|
deviceType: string;
|
|
94
|
+
signal?: AbortSignal;
|
|
95
|
+
}): Promise<{
|
|
96
|
+
key: string;
|
|
97
|
+
clientKey?: string;
|
|
98
|
+
}>;
|
|
99
|
+
/**
|
|
100
|
+
* `pair`, asked again every `everyMs` (2 s) until someone presses the bridge's button, for `timeoutMs` (30 s, as long as
|
|
101
|
+
* the button stays pressed). `onWaiting` is called once, when the bridge first says the button isn't pressed: the moment
|
|
102
|
+
* to tell the person. Throws the last `link_button` error when time runs out, any other error at once, and the signal's
|
|
103
|
+
* reason when aborted.
|
|
104
|
+
*/
|
|
105
|
+
export declare function pairWhenPressed(options: {
|
|
106
|
+
host: string;
|
|
107
|
+
fetch: typeof fetch;
|
|
108
|
+
deviceType: string;
|
|
109
|
+
timeoutMs?: number;
|
|
110
|
+
everyMs?: number;
|
|
111
|
+
onWaiting?: () => void;
|
|
112
|
+
clock?: Clock;
|
|
113
|
+
signal?: AbortSignal;
|
|
39
114
|
}): Promise<{
|
|
40
115
|
key: string;
|
|
41
116
|
clientKey?: string;
|
|
42
117
|
}>;
|
|
43
118
|
/**
|
|
44
|
-
* A `fetch` that trusts only `ca` and accepts only a certificate whose CN is `bridgeId`.
|
|
119
|
+
* A `fetch` that trusts only `ca` (Signify's roots, `HUE_BRIDGE_ROOT_CA`, when left out) and accepts only a certificate whose CN is `bridgeId`.
|
|
45
120
|
* Uses Bun's `tls` fetch option; Node ignores it and then rejects the bridge, so it fails closed there; Node uses `nodePinnedFetch` from `@khalido/home/node`.
|
|
46
121
|
*/
|
|
47
|
-
export declare function bunPinnedFetch(bridgeId: string, ca
|
|
122
|
+
export declare function bunPinnedFetch(bridgeId: string, ca?: string): typeof fetch;
|
|
48
123
|
export {};
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { colorToXy, kelvinToMirek } from "./color.js";
|
|
2
3
|
import { Envelope, PairResponse, Resource } from "./schemas.js";
|
|
4
|
+
import { groups, lights, scenes, snapshotFrom, homeGroupedLightId, } from "./state.js";
|
|
5
|
+
import { HUE_BRIDGE_ROOT_CA } from "./ca.js";
|
|
6
|
+
import { realClock } from "../../clock.js";
|
|
3
7
|
import { ConnectorError, fetchTimeout } from "../../connector.js";
|
|
4
8
|
import { writeLane } from "../../lane.js";
|
|
5
|
-
/** A failed bridge call. `code` is "link_button" when pairing waits on the bridge's button. */
|
|
9
|
+
/** A failed bridge call. `code` is "link_button" when pairing waits on the bridge's button, "invalid" for a change that can't be made (an unknown name, a value off the light's range) and "offline" for a light the bridge has lost, both caught before anything is sent. */
|
|
6
10
|
export class HueError extends ConnectorError {
|
|
7
11
|
}
|
|
8
|
-
/**
|
|
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);
|
|
@@ -5,7 +5,9 @@ import { type DeviceRecord, type Integration, type Part } from "../../home.ts";
|
|
|
5
5
|
import type { Manifest } from "../../manifest.ts";
|
|
6
6
|
import { type HueClient } from "./client.ts";
|
|
7
7
|
import { type GroupView, type HueSnapshot, type LightView } from "./state.ts";
|
|
8
|
-
export {
|
|
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,6 +41,15 @@ export type HueConfig = {
|
|
|
39
41
|
export declare function hue(config: HueConfig): Integration<HueSnapshot, "hue"> & {
|
|
40
42
|
readonly client: HueClient;
|
|
41
43
|
};
|
|
44
|
+
/** A light, room or zone in a line: "Kitchen: on · 40%", "Desk: on · 40% · 2700 K", "Strip: on · 80% · #ff8800", "Kitchen: off", or the name alone when the bridge hasn't said. */
|
|
45
|
+
export declare const hueText: (v: {
|
|
46
|
+
name?: string;
|
|
47
|
+
id: string;
|
|
48
|
+
on?: boolean;
|
|
49
|
+
brightness?: number;
|
|
50
|
+
kelvin?: number;
|
|
51
|
+
hex?: string;
|
|
52
|
+
}) => string;
|
|
42
53
|
/** A Hue light, or a room or zone, as a home device: `detail` is its `LightView` or `GroupView`. */
|
|
43
54
|
export type HueHomeDevice = DeviceRecord<"hue", "light", "on" | "off", LightView> | DeviceRecord<"hue", "group", "on" | "off", GroupView>;
|
|
44
55
|
/** Every light, then every room and zone. A light's `available` is its Zigbee link; a group's `members` are its lights. */
|
|
@@ -6,7 +6,9 @@ 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.
|
|
@@ -162,8 +166,20 @@ const view = (s) => ({
|
|
|
162
166
|
brightness,
|
|
163
167
|
})),
|
|
164
168
|
});
|
|
165
|
-
/** "Kitchen: on · 40%", "Kitchen: off", or the name alone when the bridge hasn't said. */
|
|
166
|
-
const
|
|
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
|
+
};
|
|
167
183
|
/** Every light, then every room and zone. A light's `available` is its Zigbee link; a group's `members` are its lights. */
|
|
168
184
|
export function hueHomeDevices(part) {
|
|
169
185
|
const s = part?.state;
|
|
@@ -181,7 +197,7 @@ export function hueHomeDevices(part) {
|
|
|
181
197
|
...(l.reachable === undefined ? {} : { available: l.reachable }),
|
|
182
198
|
...on(l),
|
|
183
199
|
asOf: s.asOf,
|
|
184
|
-
text:
|
|
200
|
+
text: hueText(l),
|
|
185
201
|
detail: l,
|
|
186
202
|
})),
|
|
187
203
|
...groups(s).map((g) => ({
|
|
@@ -191,7 +207,7 @@ export function hueHomeDevices(part) {
|
|
|
191
207
|
kind: "group",
|
|
192
208
|
...on(g),
|
|
193
209
|
asOf: s.asOf,
|
|
194
|
-
text:
|
|
210
|
+
text: hueText(g),
|
|
195
211
|
members: g.lightIds,
|
|
196
212
|
detail: g,
|
|
197
213
|
})),
|
|
@@ -223,7 +239,8 @@ export const manifest = {
|
|
|
223
239
|
{
|
|
224
240
|
name: "HUE_CA_FILE",
|
|
225
241
|
example: "/path/to/hue-root-ca.pem",
|
|
226
|
-
|
|
242
|
+
optional: true,
|
|
243
|
+
what: "a root CA to trust instead of Signify's two, which the package carries",
|
|
227
244
|
},
|
|
228
245
|
],
|
|
229
246
|
files: ["ca"],
|
|
@@ -232,7 +249,7 @@ export const manifest = {
|
|
|
232
249
|
if (!state)
|
|
233
250
|
return [];
|
|
234
251
|
const { lightsOn } = view(state);
|
|
235
|
-
return [`${lightsOn} lights on`, ...groups(state).map(
|
|
252
|
+
return [`${lightsOn} lights on`, ...groups(state).map(hueText)];
|
|
236
253
|
},
|
|
237
254
|
record: ({ state }) => (state ? view(state) : {}),
|
|
238
255
|
};
|