@khalido/home 0.1.3 → 0.2.1
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 +56 -1
- package/README.md +7 -2
- package/dist/config.d.ts +5 -3
- package/dist/config.js +5 -1
- package/dist/connector.d.ts +2 -0
- package/dist/connector.js +3 -0
- package/dist/connectors/amber/client.d.ts +1 -1
- package/dist/connectors/amber/client.js +1 -1
- package/dist/connectors/amber/index.js +2 -2
- 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/enphase/client.d.ts +1 -1
- package/dist/connectors/enphase/client.js +1 -1
- package/dist/connectors/enphase/index.js +2 -1
- 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/events.d.ts +0 -2
- package/dist/connectors/hue/events.js +1 -2
- package/dist/connectors/hue/index.d.ts +15 -4
- package/dist/connectors/hue/index.js +27 -10
- 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/client.d.ts +41 -6
- package/dist/connectors/printers/client.js +81 -8
- package/dist/connectors/printers/codec.d.ts +17 -3
- package/dist/connectors/printers/codec.js +32 -8
- package/dist/connectors/printers/index.d.ts +2 -0
- package/dist/connectors/printers/index.js +4 -0
- package/dist/connectors/printers/raster.d.ts +30 -0
- package/dist/connectors/printers/raster.js +175 -0
- package/dist/connectors/printers/state.d.ts +2 -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 +1 -1
- package/dist/connectors/spotify/index.js +20 -3
- package/dist/connectors/up/client.d.ts +1 -1
- package/dist/connectors/up/client.js +1 -1
- package/dist/connectors/up/index.js +2 -2
- package/dist/connectors/weather/google.js +2 -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 +15 -7
- package/docs/config.md +11 -3
- package/docs/decisions.md +19 -0
- package/docs/hue.md +161 -0
- package/docs/ideas.md +2 -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/README.md +2 -0
- 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/enphase.ts +31 -5
- package/src/cli/hue.ts +147 -59
- package/src/cli/main.ts +43 -13
- package/src/cli/printers.ts +254 -3
- package/src/cli/probe.ts +21 -17
- package/src/cli/status.ts +61 -10
- package/src/config.ts +8 -2
- package/src/connector.ts +5 -0
- package/src/connectors/amber/README.md +1 -1
- package/src/connectors/amber/client.ts +1 -1
- package/src/connectors/amber/index.ts +2 -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/enphase/README.md +3 -1
- package/src/connectors/enphase/client.ts +1 -1
- package/src/connectors/enphase/index.ts +2 -1
- 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/events.ts +1 -4
- package/src/connectors/hue/index.ts +46 -13
- package/src/connectors/hue/schemas.ts +26 -0
- package/src/connectors/hue/state.ts +27 -1
- package/src/connectors/printers/README.md +9 -4
- package/src/connectors/printers/client.ts +186 -12
- package/src/connectors/printers/codec.ts +44 -10
- package/src/connectors/printers/index.ts +17 -0
- package/src/connectors/printers/raster.ts +215 -0
- package/src/connectors/printers/state.ts +2 -0
- package/src/connectors/spotify/README.md +10 -2
- package/src/connectors/spotify/changes.ts +38 -9
- package/src/connectors/spotify/index.ts +21 -2
- package/src/connectors/up/README.md +1 -1
- package/src/connectors/up/client.ts +1 -1
- package/src/connectors/up/index.ts +2 -2
- package/src/connectors/weather/google.ts +6 -2
- 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
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Signify's two root certificates for Hue bridges, so pinning needs no file: `bunPinnedFetch(bridgeId)` and
|
|
2
|
+
// `nodePinnedFetch(bridgeId)` trust these and nothing else. Public certificates, as Signify's "Using HTTPS" page gives
|
|
3
|
+
// them (login-gated); these bytes are the ones openHAB, hb-hue-tools, openhue-go and Raycast's Hue extension ship.
|
|
4
|
+
//
|
|
5
|
+
// CN=root-bridge, O=Philips Hue to 2038-01-19 the one in use: KO's square bridge (BSB002) chains to it
|
|
6
|
+
// SHA-256 F0:BD:8E:65:09:E8:2F:77:4D:63:BC:00:9D:53:88:C9:69:FE:3D:CF:7D:6D:54:1D:63:51:B7:2B:89:8D:8A:CF
|
|
7
|
+
// CN=Hue Root CA 01, O=Signify Hue to 2050-12-31 Signify's spare, likely behind an intermediate: untried here
|
|
8
|
+
// SHA-256 D8:B8:94:48:B2:AF:8E:16:76:18:5A:C0:72:19:EE:9D:CB:C8:F0:1C:12:2A:02:6A:2A:4B:7B:5C:FE:03:28:B8
|
|
9
|
+
//
|
|
10
|
+
// test/hue.test.ts checks both fingerprints, so an edit here fails.
|
|
11
|
+
/** Signify's root certificates for Hue bridges (PEM, two certificates): the default `ca` everywhere one is taken. */
|
|
12
|
+
export const HUE_BRIDGE_ROOT_CA = `-----BEGIN CERTIFICATE-----
|
|
13
|
+
MIICMjCCAdigAwIBAgIUO7FSLbaxikuXAljzVaurLXWmFw4wCgYIKoZIzj0EAwIw
|
|
14
|
+
OTELMAkGA1UEBhMCTkwxFDASBgNVBAoMC1BoaWxpcHMgSHVlMRQwEgYDVQQDDAty
|
|
15
|
+
b290LWJyaWRnZTAiGA8yMDE3MDEwMTAwMDAwMFoYDzIwMzgwMTE5MDMxNDA3WjA5
|
|
16
|
+
MQswCQYDVQQGEwJOTDEUMBIGA1UECgwLUGhpbGlwcyBIdWUxFDASBgNVBAMMC3Jv
|
|
17
|
+
b3QtYnJpZGdlMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEjNw2tx2AplOf9x86
|
|
18
|
+
aTdvEcL1FU65QDxziKvBpW9XXSIcibAeQiKxegpq8Exbr9v6LBnYbna2VcaK0G22
|
|
19
|
+
jOKkTqOBuTCBtjAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAdBgNV
|
|
20
|
+
HQ4EFgQUZ2ONTFrDT6o8ItRnKfqWKnHFGmQwdAYDVR0jBG0wa4AUZ2ONTFrDT6o8
|
|
21
|
+
ItRnKfqWKnHFGmShPaQ7MDkxCzAJBgNVBAYTAk5MMRQwEgYDVQQKDAtQaGlsaXBz
|
|
22
|
+
IEh1ZTEUMBIGA1UEAwwLcm9vdC1icmlkZ2WCFDuxUi22sYpLlwJY81Wrqy11phcO
|
|
23
|
+
MAoGCCqGSM49BAMCA0gAMEUCIEBYYEOsa07TH7E5MJnGw557lVkORgit2Rm1h3B2
|
|
24
|
+
sFgDAiEA1Fj/C3AN5psFMjo0//mrQebo0eKd3aWRx+pQY08mk48=
|
|
25
|
+
-----END CERTIFICATE-----
|
|
26
|
+
-----BEGIN CERTIFICATE-----
|
|
27
|
+
MIIBzDCCAXOgAwIBAgICEAAwCgYIKoZIzj0EAwIwPDELMAkGA1UEBhMCTkwxFDAS
|
|
28
|
+
BgNVBAoMC1NpZ25pZnkgSHVlMRcwFQYDVQQDDA5IdWUgUm9vdCBDQSAwMTAgFw0y
|
|
29
|
+
NTAyMjUwMDAwMDBaGA8yMDUwMTIzMTIzNTk1OVowPDELMAkGA1UEBhMCTkwxFDAS
|
|
30
|
+
BgNVBAoMC1NpZ25pZnkgSHVlMRcwFQYDVQQDDA5IdWUgUm9vdCBDQSAwMTBZMBMG
|
|
31
|
+
ByqGSM49AgEGCCqGSM49AwEHA0IABFfOO0jfSAUXGQ9kjEDzyBrcMQ3ItyA5krE+
|
|
32
|
+
cyvb1Y3xFti7KlAad8UOnAx0FBLn7HZrlmIwm1QnX0fK3LPM13mjYzBhMB0GA1Ud
|
|
33
|
+
DgQWBBTF1pSpsCASX/z0VHLigxU2CAaqoTAfBgNVHSMEGDAWgBTF1pSpsCASX/z0
|
|
34
|
+
VHLigxU2CAaqoTAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAKBggq
|
|
35
|
+
hkjOPQQDAgNHADBEAiAk7duT+IHbOGO4UUuGLAEpyYejGZK9Z7V9oSfnvuQ5BQIg
|
|
36
|
+
IYSgwwxHXm73/JgcU9lAM6c8Bmu3UE3kBIUwBs1qXFw=
|
|
37
|
+
-----END CERTIFICATE-----
|
|
38
|
+
`;
|
|
@@ -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);
|
|
@@ -7,8 +7,6 @@ export type SseMessage = {
|
|
|
7
7
|
};
|
|
8
8
|
/** Split an SSE byte stream into messages, one per blank-line-separated block. Comment lines (`:`) are skipped. */
|
|
9
9
|
export declare function parseSse(body: ReadableStream<Uint8Array>): AsyncGenerator<SseMessage>;
|
|
10
|
-
/** A 401 or 403: the key is wrong or revoked. */
|
|
11
|
-
export declare const isRejectedKey: (error: unknown) => boolean;
|
|
12
10
|
/** What the stream yields: a (re)connection, a batch of events, or a drop with the wait before the next try. */
|
|
13
11
|
export type StreamItem = {
|
|
14
12
|
kind: "connected";
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { realClock } from "../../clock.js";
|
|
3
|
+
import { isRejectedKey } from "../../connector.js";
|
|
3
4
|
import { HueError } from "./client.js";
|
|
4
5
|
import { HueEvent } from "./schemas.js";
|
|
5
6
|
/** Split an SSE byte stream into messages, one per blank-line-separated block. Comment lines (`:`) are skipped. */
|
|
@@ -50,8 +51,6 @@ const safeJson = (text) => {
|
|
|
50
51
|
return undefined;
|
|
51
52
|
}
|
|
52
53
|
};
|
|
53
|
-
/** A 401 or 403: the key is wrong or revoked. */
|
|
54
|
-
export const isRejectedKey = (error) => error instanceof HueError && (error.status === 401 || error.status === 403);
|
|
55
54
|
/**
|
|
56
55
|
* A connection with no bytes for this long is reconnected: a silent half-close otherwise looks connected forever (aiohue
|
|
57
56
|
* uses 90 s). The bridge sends nothing on its own, so a quiet house trips it too; the reconnect is a silent probe.
|
|
@@ -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. */
|