@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,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import type
|
|
2
|
+
import { type Clock } from "../../clock.ts";
|
|
3
3
|
import { ConnectorError } from "../../connector.ts";
|
|
4
4
|
/** A failed Daikin call. `status` is the HTTP status, or the dsiot `rsc` when HTTP succeeded. */
|
|
5
5
|
export declare class DaikinError extends ConnectorError {
|
|
@@ -23,6 +23,41 @@ type Node = {
|
|
|
23
23
|
pch?: Node[];
|
|
24
24
|
};
|
|
25
25
|
declare const Node: z.ZodType<Node>;
|
|
26
|
+
/**
|
|
27
|
+
* The setpoint a mode gets when the caller names none: this library's opinion (KO's: not too cold, properly warm).
|
|
28
|
+
* Without one, "cool" would mean whatever the remote last left. Auto keeps the unit's own.
|
|
29
|
+
*/
|
|
30
|
+
export declare const DEFAULT_TARGET_C: {
|
|
31
|
+
readonly cool: 25;
|
|
32
|
+
readonly heat: 27;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* What a write left: the state once the unit reported the change (`confirmed`), or as it stood when `CONFIRM_MS` ran out
|
|
36
|
+
* without it. With `{ dryRun: true }` nothing is sent: `wouldSend` is the request and what it sets, `state` the unit as it
|
|
37
|
+
* reads now, and `confirmed` is false. Check `wouldSend` first.
|
|
38
|
+
*/
|
|
39
|
+
export type DaikinApplied = {
|
|
40
|
+
state: DaikinState;
|
|
41
|
+
confirmed: boolean;
|
|
42
|
+
wouldSend?: DaikinRequest;
|
|
43
|
+
};
|
|
44
|
+
/** One write: `POST /dsiot/multireq` with `body`; `sets` is what it changes, in this library's words. */
|
|
45
|
+
export type DaikinRequest = {
|
|
46
|
+
method: "POST";
|
|
47
|
+
path: "/dsiot/multireq";
|
|
48
|
+
body: WriteRequest;
|
|
49
|
+
sets: {
|
|
50
|
+
on?: boolean;
|
|
51
|
+
mode?: Mode;
|
|
52
|
+
targetC?: number;
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
/** `dryRun`: every check a write makes (the mode, the setpoint's range), one read for the state, and nothing sent. */
|
|
56
|
+
export type DaikinWriteOptions = {
|
|
57
|
+
dryRun?: boolean;
|
|
58
|
+
};
|
|
59
|
+
/** How long `power` and `mode` wait for the unit to report what was asked, reading once a second. */
|
|
60
|
+
export declare const CONFIRM_MS = 5000;
|
|
26
61
|
/** Little-endian hex to an integer, two's complement when `signed`: "2700" → 39, "EEFF" → -18. */
|
|
27
62
|
export declare function hexLE(hex: string, signed?: boolean): number | undefined;
|
|
28
63
|
/** Map the indoor and outdoor `dgc_status` trees to a state. Unknown codes go to `anomaly`, never to a guess. */
|
|
@@ -48,7 +83,7 @@ export declare const setMode: (mode: Mode) => WriteRequest;
|
|
|
48
83
|
export declare function setTarget(c: number, mode: Mode): WriteRequest;
|
|
49
84
|
/** Set the fan speed of `mode` (usually the current one). */
|
|
50
85
|
export declare function setFan(fan: Fan, mode: Mode): WriteRequest;
|
|
51
|
-
/** A client for one unit. Reads are op 2; `write` sends
|
|
86
|
+
/** A client for one unit. Reads are op 2. `power` and `mode` are the writes most callers want; `write` sends any body built by setPower, setMode, setTarget or setFan. */
|
|
52
87
|
export declare function daikinClient(options: {
|
|
53
88
|
host: string;
|
|
54
89
|
fetch: typeof fetch;
|
|
@@ -61,5 +96,13 @@ export declare function daikinClient(options: {
|
|
|
61
96
|
* unconfirmed; re-read to be sure). Writes waiting their turn merge (`mergeWrites`) and resolve with the send that carried them.
|
|
62
97
|
*/
|
|
63
98
|
write(request: WriteRequest): Promise<void>;
|
|
99
|
+
/** Turn the unit on or off, as it was last left. Only when a person asks. Resolves with what the unit then reported. */
|
|
100
|
+
power: (on: boolean, options?: DaikinWriteOptions) => Promise<DaikinApplied>;
|
|
101
|
+
/**
|
|
102
|
+
* Select a mode, turn the unit on and set its setpoint, in one request: `targetC`, else `DEFAULT_TARGET_C` (cool 25,
|
|
103
|
+
* heat 27), else (auto) the unit's own. Dry and fan take none, and a `targetC` for them throws before anything is
|
|
104
|
+
* sent, as one off the unit's range does. Only when a person asks. Resolves with what the unit then reported.
|
|
105
|
+
*/
|
|
106
|
+
mode(mode: Mode, targetC?: number, options?: DaikinWriteOptions): Promise<DaikinApplied>;
|
|
64
107
|
};
|
|
65
108
|
export {};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { realClock } from "../../clock.js";
|
|
2
3
|
import { ConnectorError, getJson } from "../../connector.js";
|
|
3
4
|
import { writeLane } from "../../lane.js";
|
|
4
5
|
// Daikin's JSON dsiot API (the BRP084 family): POST /dsiot/multireq, plain HTTP, no key. Field paths and value
|
|
@@ -56,6 +57,16 @@ const TARGET_RANGE = {
|
|
|
56
57
|
heat: [10, 30],
|
|
57
58
|
auto: [18, 30],
|
|
58
59
|
};
|
|
60
|
+
/**
|
|
61
|
+
* The setpoint a mode gets when the caller names none: this library's opinion (KO's: not too cold, properly warm).
|
|
62
|
+
* Without one, "cool" would mean whatever the remote last left. Auto keeps the unit's own.
|
|
63
|
+
*/
|
|
64
|
+
export const DEFAULT_TARGET_C = {
|
|
65
|
+
cool: 25,
|
|
66
|
+
heat: 27,
|
|
67
|
+
};
|
|
68
|
+
/** How long `power` and `mode` wait for the unit to report what was asked, reading once a second. */
|
|
69
|
+
export const CONFIRM_MS = 5000;
|
|
59
70
|
/** Little-endian hex to an integer, two's complement when `signed`: "2700" → 39, "EEFF" → -18. */
|
|
60
71
|
export function hexLE(hex, signed = false) {
|
|
61
72
|
if (!/^(?:[0-9a-f]{2})+$/i.test(hex))
|
|
@@ -173,7 +184,7 @@ export function setFan(fan, mode) {
|
|
|
173
184
|
throw new DaikinError(`${mode} mode has no fan speed`);
|
|
174
185
|
return write([["e_3001", FAN[mode], key(FANS, fan)]]);
|
|
175
186
|
}
|
|
176
|
-
/** A client for one unit. Reads are op 2; `write` sends
|
|
187
|
+
/** A client for one unit. Reads are op 2. `power` and `mode` are the writes most callers want; `write` sends any body built by setPower, setMode, setTarget or setFan. */
|
|
177
188
|
export function daikinClient(options) {
|
|
178
189
|
const lane = writeLane({
|
|
179
190
|
gapMs: WRITE_GAP_MS,
|
|
@@ -190,7 +201,8 @@ export function daikinClient(options) {
|
|
|
190
201
|
schema: Multi,
|
|
191
202
|
error: DaikinError,
|
|
192
203
|
});
|
|
193
|
-
|
|
204
|
+
const clock = options.clock ?? realClock;
|
|
205
|
+
const client = {
|
|
194
206
|
/** The indoor and outdoor status in one request. A failed outdoor read leaves `outsideC` empty. */
|
|
195
207
|
async read(signal) {
|
|
196
208
|
const m = await multireq({
|
|
@@ -218,4 +230,57 @@ export function daikinClient(options) {
|
|
|
218
230
|
}, mergeWrites);
|
|
219
231
|
},
|
|
220
232
|
};
|
|
233
|
+
/** Send `request`, then read once a second until the unit reports `asked` or `CONFIRM_MS` passes. Rejects if the write is refused, or if no read after it succeeds. */
|
|
234
|
+
async function apply(request, sets, asked, options) {
|
|
235
|
+
if (options?.dryRun)
|
|
236
|
+
return {
|
|
237
|
+
state: await client.read(),
|
|
238
|
+
confirmed: false,
|
|
239
|
+
wouldSend: {
|
|
240
|
+
method: "POST",
|
|
241
|
+
path: "/dsiot/multireq",
|
|
242
|
+
body: request,
|
|
243
|
+
sets,
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
await client.write(request);
|
|
247
|
+
const until = clock.now() + CONFIRM_MS;
|
|
248
|
+
let state;
|
|
249
|
+
let failed;
|
|
250
|
+
// The write was accepted, so one dropped read must not turn it into a failure: keep reading until the time is up.
|
|
251
|
+
for (;;) {
|
|
252
|
+
try {
|
|
253
|
+
state = await client.read();
|
|
254
|
+
if (asked(state))
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
failed = error;
|
|
259
|
+
}
|
|
260
|
+
if (clock.now() >= until)
|
|
261
|
+
break;
|
|
262
|
+
await clock.sleep(1000);
|
|
263
|
+
}
|
|
264
|
+
if (!state)
|
|
265
|
+
throw failed; // sent, but the unit answered no read since: nothing to report
|
|
266
|
+
return { state, confirmed: asked(state) };
|
|
267
|
+
}
|
|
268
|
+
return {
|
|
269
|
+
...client,
|
|
270
|
+
/** Turn the unit on or off, as it was last left. Only when a person asks. Resolves with what the unit then reported. */
|
|
271
|
+
power: (on, options) => apply(setPower(on), { on }, (s) => s.on === on, options),
|
|
272
|
+
/**
|
|
273
|
+
* Select a mode, turn the unit on and set its setpoint, in one request: `targetC`, else `DEFAULT_TARGET_C` (cool 25,
|
|
274
|
+
* heat 27), else (auto) the unit's own. Dry and fan take none, and a `targetC` for them throws before anything is
|
|
275
|
+
* sent, as one off the unit's range does. Only when a person asks. Resolves with what the unit then reported.
|
|
276
|
+
*/
|
|
277
|
+
async mode(mode, targetC, options) {
|
|
278
|
+
const c = targetC ?? DEFAULT_TARGET_C[mode];
|
|
279
|
+
return apply(c === undefined
|
|
280
|
+
? setMode(mode)
|
|
281
|
+
: mergeWrites(setMode(mode), setTarget(c, mode)), { on: true, mode, ...(c === undefined ? {} : { targetC: c }) }, (s) => s.on === true &&
|
|
282
|
+
s.mode === mode &&
|
|
283
|
+
(c === undefined || s.targetC === c), options);
|
|
284
|
+
},
|
|
285
|
+
};
|
|
221
286
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { type Clock } from "../../clock.ts";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import type
|
|
3
|
+
import { type DeviceRecord, type Integration, type Part } from "../../home.ts";
|
|
4
4
|
import { type Finder } from "../../discover.ts";
|
|
5
5
|
import type { Manifest } from "../../manifest.ts";
|
|
6
|
-
import { daikinClient, type DaikinState } from "./client.ts";
|
|
7
|
-
export { DaikinError, type DaikinState, daikinClient, type Fan, type Mode, setFan, setMode, setPower, setTarget, type WriteRequest, } from "./client.ts";
|
|
6
|
+
import { daikinClient, type DaikinState, type Mode } from "./client.ts";
|
|
7
|
+
export { CONFIRM_MS, type DaikinApplied, type DaikinRequest, type DaikinWriteOptions, DaikinError, type DaikinState, DEFAULT_TARGET_C, daikinClient, type Fan, type Mode, setFan, setMode, setPower, setTarget, type WriteRequest, } from "./client.ts";
|
|
8
8
|
/** Poll every 120 s, as Home Assistant's daikin integration does (its coordinator's update_interval). */
|
|
9
9
|
export declare const POLL_MS = 120000;
|
|
10
10
|
/** A Daikin AC in a home config: its last known address, and the MAC its UDP discovery reports, which is how it is recognised. */
|
|
@@ -30,7 +30,7 @@ export type DaikinSnapshot = DaikinState & {
|
|
|
30
30
|
/** What `daikin().client` is: the reads and writes of the unit, at wherever it is now. */
|
|
31
31
|
export type DaikinClient = ReturnType<typeof daikinClient>;
|
|
32
32
|
/**
|
|
33
|
-
* One Daikin AC as a home integration: one read now, then every 120 s. Its writes are `client.
|
|
33
|
+
* One Daikin AC as a home integration: one read now, then every 120 s. Its writes are `client.power(on)` and `client.mode(mode, targetC?)`: one
|
|
34
34
|
* client for the integration's life, so the writes share its lane and follow the unit when it moves.
|
|
35
35
|
*/
|
|
36
36
|
export declare function daikin(config: DaikinConfig): Integration<DaikinSnapshot, "daikin"> & {
|
|
@@ -39,3 +39,7 @@ export declare function daikin(config: DaikinConfig): Integration<DaikinSnapshot
|
|
|
39
39
|
/** The AC in a few words: "off · cool 25 °C · room 24 °C · outdoor unit 19.5 °C". */
|
|
40
40
|
export declare function acText(s: DaikinState): string;
|
|
41
41
|
export declare const manifest: Manifest<DaikinSnapshot>;
|
|
42
|
+
/** The AC as a home device: `state` is `off`, its mode while on, or `on` when it hasn't said which; `detail` its snapshot. */
|
|
43
|
+
export type DaikinHomeDevice = DeviceRecord<"daikin", "ac", "off" | "on" | Mode, DaikinSnapshot>;
|
|
44
|
+
/** The one unit, once it has been read. It reports no name or id of its own: a home config's entry names and places it. */
|
|
45
|
+
export declare function daikinHomeDevices(part: Part<DaikinSnapshot> | undefined): DaikinHomeDevice[];
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { asOf, realClock } from "../../clock.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import { deviceRecords, } from "../../home.js";
|
|
3
4
|
import { lostDetail, relocator } from "../../discover.js";
|
|
4
5
|
import { poller } from "../../poll.js";
|
|
5
|
-
import { daikinClient, DaikinError } from "./client.js";
|
|
6
|
-
export { DaikinError, daikinClient, setFan, setMode, setPower, setTarget, } from "./client.js";
|
|
6
|
+
import { daikinClient, DaikinError, } from "./client.js";
|
|
7
|
+
export { CONFIRM_MS, DaikinError, DEFAULT_TARGET_C, daikinClient, setFan, setMode, setPower, setTarget, } from "./client.js";
|
|
7
8
|
/** Poll every 120 s, as Home Assistant's daikin integration does (its coordinator's update_interval). */
|
|
8
9
|
export const POLL_MS = 120_000;
|
|
9
10
|
/** A Daikin AC in a home config: its last known address, and the MAC its UDP discovery reports, which is how it is recognised. */
|
|
@@ -16,7 +17,7 @@ export const configSchema = z.looseObject({
|
|
|
16
17
|
.describe("the unit's MAC as its UDP discovery reports it: its identity"),
|
|
17
18
|
});
|
|
18
19
|
/**
|
|
19
|
-
* One Daikin AC as a home integration: one read now, then every 120 s. Its writes are `client.
|
|
20
|
+
* One Daikin AC as a home integration: one read now, then every 120 s. Its writes are `client.power(on)` and `client.mode(mode, targetC?)`: one
|
|
20
21
|
* client for the integration's life, so the writes share its lane and follow the unit when it moves.
|
|
21
22
|
*/
|
|
22
23
|
export function daikin(config) {
|
|
@@ -101,8 +102,28 @@ export const manifest = {
|
|
|
101
102
|
{
|
|
102
103
|
name: "DAIKIN_HOST",
|
|
103
104
|
example: "192.0.2.20",
|
|
104
|
-
|
|
105
|
+
optional: true,
|
|
106
|
+
what: "the unit's address; found by UDP broadcast when unset (1.5 s a run)",
|
|
105
107
|
},
|
|
106
108
|
],
|
|
107
109
|
lines: ({ state }) => (state ? [acText(state)] : []),
|
|
108
110
|
};
|
|
111
|
+
/** The one unit, once it has been read. It reports no name or id of its own: a home config's entry names and places it. */
|
|
112
|
+
export function daikinHomeDevices(part) {
|
|
113
|
+
const s = part?.state;
|
|
114
|
+
if (!s)
|
|
115
|
+
return [];
|
|
116
|
+
return deviceRecords(part, [
|
|
117
|
+
{
|
|
118
|
+
connector: "daikin",
|
|
119
|
+
id: "daikin",
|
|
120
|
+
name: manifest.title,
|
|
121
|
+
kind: "ac",
|
|
122
|
+
available: true,
|
|
123
|
+
...(s.on === undefined ? {} : { state: s.on ? (s.mode ?? "on") : "off" }),
|
|
124
|
+
asOf: s.asOf,
|
|
125
|
+
text: `${manifest.title}: ${acText(s)}`,
|
|
126
|
+
detail: s,
|
|
127
|
+
},
|
|
128
|
+
]);
|
|
129
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type Clock } from "../../clock.ts";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { type Integration } from "../../home.ts";
|
|
3
|
+
import { type DeviceRecord, type Integration, type Part } from "../../home.ts";
|
|
4
4
|
import { type Finder } from "../../discover.ts";
|
|
5
5
|
import { type Manifest } from "../../manifest.ts";
|
|
6
6
|
import { type EnphaseState } from "./client.ts";
|
|
@@ -41,3 +41,7 @@ export declare function enphase(config: EnphaseConfig): Integration<EnphaseSnaps
|
|
|
41
41
|
/** "solar 2.1 kW, using 0.6 kW · token 300 days left". The login ask near the end is the integration's, in `detail`. */
|
|
42
42
|
export declare function enphaseText(s: EnphaseSnapshot): string;
|
|
43
43
|
export declare const manifest: Manifest<EnphaseSnapshot>;
|
|
44
|
+
/** The gateway as a home device: `state` is `producing` while the solar makes power, else `idle`; `detail` its snapshot. */
|
|
45
|
+
export type EnphaseHomeDevice = DeviceRecord<"enphase", "solar", "producing" | "idle", EnphaseSnapshot>;
|
|
46
|
+
/** The one gateway, once it has been read. */
|
|
47
|
+
export declare function enphaseHomeDevices(part: Part<EnphaseSnapshot> | undefined): EnphaseHomeDevice[];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { localDay, realClock } from "../../clock.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { filePath } from "../../home.js";
|
|
3
|
+
import { deviceRecords, filePath, } from "../../home.js";
|
|
4
4
|
import { lostDetail, relocator } from "../../discover.js";
|
|
5
5
|
import { kw } from "../../manifest.js";
|
|
6
6
|
import { poller } from "../../poll.js";
|
|
@@ -51,7 +51,17 @@ export function enphase(config) {
|
|
|
51
51
|
name: "enphase",
|
|
52
52
|
refresh: () => (where.reset(), poll.nudge()),
|
|
53
53
|
async run(signal, update) {
|
|
54
|
-
|
|
54
|
+
let expiresAt;
|
|
55
|
+
try {
|
|
56
|
+
expiresAt = tokenExpiry(config.token); // a bad token fails this integration only
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return update({
|
|
60
|
+
status: "unavailable",
|
|
61
|
+
reason: "auth",
|
|
62
|
+
detail: "the token is unreadable; run `home enphase login`",
|
|
63
|
+
});
|
|
64
|
+
}
|
|
55
65
|
let last;
|
|
56
66
|
let day;
|
|
57
67
|
let moved; // kept in `detail` so a consumer can persist the new address
|
|
@@ -106,6 +116,7 @@ export function enphase(config) {
|
|
|
106
116
|
if (error instanceof EnphaseError && error.status === 401)
|
|
107
117
|
return update({
|
|
108
118
|
status: "unavailable",
|
|
119
|
+
reason: "auth",
|
|
109
120
|
detail: "the gateway rejected the token; run `home enphase login`",
|
|
110
121
|
state: last,
|
|
111
122
|
});
|
|
@@ -172,3 +183,24 @@ export const manifest = {
|
|
|
172
183
|
files: ["token"],
|
|
173
184
|
lines: ({ state }) => (state ? [enphaseText(state)] : []),
|
|
174
185
|
};
|
|
186
|
+
/** The one gateway, once it has been read. */
|
|
187
|
+
export function enphaseHomeDevices(part) {
|
|
188
|
+
const s = part?.state;
|
|
189
|
+
if (!s)
|
|
190
|
+
return [];
|
|
191
|
+
return deviceRecords(part, [
|
|
192
|
+
{
|
|
193
|
+
connector: "enphase",
|
|
194
|
+
id: "enphase",
|
|
195
|
+
name: manifest.title,
|
|
196
|
+
kind: "solar",
|
|
197
|
+
available: true,
|
|
198
|
+
...(s.productionW === undefined
|
|
199
|
+
? {}
|
|
200
|
+
: { state: s.productionW > 0 ? "producing" : "idle" }),
|
|
201
|
+
asOf: s.asOf,
|
|
202
|
+
text: `${manifest.title}: ${enphaseText(s)}`,
|
|
203
|
+
detail: s,
|
|
204
|
+
},
|
|
205
|
+
]);
|
|
206
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
/** Signify's root certificates for Hue bridges (PEM, two certificates): the default `ca` everywhere one is taken. */
|
|
2
|
+
export declare const HUE_BRIDGE_ROOT_CA = "-----BEGIN CERTIFICATE-----\nMIICMjCCAdigAwIBAgIUO7FSLbaxikuXAljzVaurLXWmFw4wCgYIKoZIzj0EAwIw\nOTELMAkGA1UEBhMCTkwxFDASBgNVBAoMC1BoaWxpcHMgSHVlMRQwEgYDVQQDDAty\nb290LWJyaWRnZTAiGA8yMDE3MDEwMTAwMDAwMFoYDzIwMzgwMTE5MDMxNDA3WjA5\nMQswCQYDVQQGEwJOTDEUMBIGA1UECgwLUGhpbGlwcyBIdWUxFDASBgNVBAMMC3Jv\nb3QtYnJpZGdlMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEjNw2tx2AplOf9x86\naTdvEcL1FU65QDxziKvBpW9XXSIcibAeQiKxegpq8Exbr9v6LBnYbna2VcaK0G22\njOKkTqOBuTCBtjAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAdBgNV\nHQ4EFgQUZ2ONTFrDT6o8ItRnKfqWKnHFGmQwdAYDVR0jBG0wa4AUZ2ONTFrDT6o8\nItRnKfqWKnHFGmShPaQ7MDkxCzAJBgNVBAYTAk5MMRQwEgYDVQQKDAtQaGlsaXBz\nIEh1ZTEUMBIGA1UEAwwLcm9vdC1icmlkZ2WCFDuxUi22sYpLlwJY81Wrqy11phcO\nMAoGCCqGSM49BAMCA0gAMEUCIEBYYEOsa07TH7E5MJnGw557lVkORgit2Rm1h3B2\nsFgDAiEA1Fj/C3AN5psFMjo0//mrQebo0eKd3aWRx+pQY08mk48=\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIIBzDCCAXOgAwIBAgICEAAwCgYIKoZIzj0EAwIwPDELMAkGA1UEBhMCTkwxFDAS\nBgNVBAoMC1NpZ25pZnkgSHVlMRcwFQYDVQQDDA5IdWUgUm9vdCBDQSAwMTAgFw0y\nNTAyMjUwMDAwMDBaGA8yMDUwMTIzMTIzNTk1OVowPDELMAkGA1UEBhMCTkwxFDAS\nBgNVBAoMC1NpZ25pZnkgSHVlMRcwFQYDVQQDDA5IdWUgUm9vdCBDQSAwMTBZMBMG\nByqGSM49AgEGCCqGSM49AwEHA0IABFfOO0jfSAUXGQ9kjEDzyBrcMQ3ItyA5krE+\ncyvb1Y3xFti7KlAad8UOnAx0FBLn7HZrlmIwm1QnX0fK3LPM13mjYzBhMB0GA1Ud\nDgQWBBTF1pSpsCASX/z0VHLigxU2CAaqoTAfBgNVHSMEGDAWgBTF1pSpsCASX/z0\nVHLigxU2CAaqoTAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAKBggq\nhkjOPQQDAgNHADBEAiAk7duT+IHbOGO4UUuGLAEpyYejGZK9Z7V9oSfnvuQ5BQIg\nIYSgwwxHXm73/JgcU9lAM6c8Bmu3UE3kBIUwBs1qXFw=\n-----END CERTIFICATE-----\n";
|
|
@@ -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 {};
|