@khalido/home 0.0.0-stage → 0.1.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 +25 -0
- package/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +77 -2
- package/dist/clock.d.ts +17 -0
- package/dist/clock.js +43 -0
- package/dist/config.d.ts +273 -0
- package/dist/config.js +392 -0
- package/dist/connector.d.ts +34 -0
- package/dist/connector.js +47 -0
- package/dist/connectors/amber/client.d.ts +61 -0
- package/dist/connectors/amber/client.js +106 -0
- package/dist/connectors/amber/index.d.ts +30 -0
- package/dist/connectors/amber/index.js +94 -0
- package/dist/connectors/cast/codec.d.ts +38 -0
- package/dist/connectors/cast/codec.js +102 -0
- package/dist/connectors/cast/index.d.ts +60 -0
- package/dist/connectors/cast/index.js +426 -0
- package/dist/connectors/cast/schemas.d.ts +167 -0
- package/dist/connectors/cast/schemas.js +87 -0
- package/dist/connectors/cast/serve.d.ts +11 -0
- package/dist/connectors/cast/serve.js +57 -0
- package/dist/connectors/cast/state.d.ts +62 -0
- package/dist/connectors/cast/state.js +133 -0
- package/dist/connectors/cast/tls.d.ts +3 -0
- package/dist/connectors/cast/tls.js +67 -0
- package/dist/connectors/cast/writes.d.ts +80 -0
- package/dist/connectors/cast/writes.js +390 -0
- package/dist/connectors/daikin/client.d.ts +65 -0
- package/dist/connectors/daikin/client.js +221 -0
- package/dist/connectors/daikin/index.d.ts +41 -0
- package/dist/connectors/daikin/index.js +108 -0
- package/dist/connectors/enphase/client.d.ts +65 -0
- package/dist/connectors/enphase/client.js +173 -0
- package/dist/connectors/enphase/index.d.ts +43 -0
- package/dist/connectors/enphase/index.js +174 -0
- package/dist/connectors/enphase/schemas.d.ts +51 -0
- package/dist/connectors/enphase/schemas.js +34 -0
- package/dist/connectors/hue/client.d.ts +48 -0
- package/dist/connectors/hue/client.js +95 -0
- package/dist/connectors/hue/events.d.ts +41 -0
- package/dist/connectors/hue/events.js +156 -0
- package/dist/connectors/hue/index.d.ts +42 -0
- package/dist/connectors/hue/index.js +205 -0
- package/dist/connectors/hue/schemas.d.ts +65 -0
- package/dist/connectors/hue/schemas.js +48 -0
- package/dist/connectors/hue/state.d.ts +43 -0
- package/dist/connectors/hue/state.js +84 -0
- package/dist/connectors/printers/client.d.ts +28 -0
- package/dist/connectors/printers/client.js +74 -0
- package/dist/connectors/printers/codec.d.ts +76 -0
- package/dist/connectors/printers/codec.js +246 -0
- package/dist/connectors/printers/found.d.ts +15 -0
- package/dist/connectors/printers/found.js +35 -0
- package/dist/connectors/printers/index.d.ts +52 -0
- package/dist/connectors/printers/index.js +282 -0
- package/dist/connectors/printers/state.d.ts +60 -0
- package/dist/connectors/printers/state.js +99 -0
- package/dist/connectors/spotify/changes.d.ts +32 -0
- package/dist/connectors/spotify/changes.js +84 -0
- package/dist/connectors/spotify/client.d.ts +199 -0
- package/dist/connectors/spotify/client.js +270 -0
- package/dist/connectors/spotify/index.d.ts +92 -0
- package/dist/connectors/spotify/index.js +745 -0
- package/dist/connectors/up/client.d.ts +183 -0
- package/dist/connectors/up/client.js +308 -0
- package/dist/connectors/up/index.d.ts +91 -0
- package/dist/connectors/up/index.js +213 -0
- package/dist/connectors/up/spend.d.ts +78 -0
- package/dist/connectors/up/spend.js +213 -0
- package/dist/connectors/up/webhook.d.ts +13 -0
- package/dist/connectors/up/webhook.js +32 -0
- package/dist/connectors/weather/blocks.d.ts +51 -0
- package/dist/connectors/weather/blocks.js +106 -0
- package/dist/connectors/weather/bom.d.ts +37 -0
- package/dist/connectors/weather/bom.js +237 -0
- package/dist/connectors/weather/ftp.d.ts +5 -0
- package/dist/connectors/weather/ftp.js +46 -0
- package/dist/connectors/weather/google.d.ts +31 -0
- package/dist/connectors/weather/google.js +364 -0
- package/dist/connectors/weather/index.d.ts +53 -0
- package/dist/connectors/weather/index.js +267 -0
- package/dist/connectors/weather/open-meteo.d.ts +15 -0
- package/dist/connectors/weather/open-meteo.js +285 -0
- package/dist/connectors/weather/rain.d.ts +19 -0
- package/dist/connectors/weather/rain.js +51 -0
- package/dist/connectors/weather/source.d.ts +149 -0
- package/dist/connectors/weather/source.js +77 -0
- package/dist/connectors/weather/sun.d.ts +48 -0
- package/dist/connectors/weather/sun.js +65 -0
- package/dist/discover.d.ts +195 -0
- package/dist/discover.js +446 -0
- package/dist/energy.d.ts +61 -0
- package/dist/energy.js +162 -0
- package/dist/finders-entry.d.ts +1 -0
- package/dist/finders-entry.js +2 -0
- package/dist/finders.d.ts +45 -0
- package/dist/finders.js +173 -0
- package/dist/health.d.ts +28 -0
- package/dist/health.js +35 -0
- package/dist/home.d.ts +81 -0
- package/dist/home.js +114 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +11 -0
- package/dist/lane.d.ts +17 -0
- package/dist/lane.js +74 -0
- package/dist/locate.d.ts +27 -0
- package/dist/locate.js +59 -0
- package/dist/manifest.d.ts +31 -0
- package/dist/manifest.js +4 -0
- package/dist/manifests.d.ts +5 -0
- package/dist/manifests.js +23 -0
- package/dist/node-fetch.d.ts +9 -0
- package/dist/node-fetch.js +25 -0
- package/dist/poll.d.ts +10 -0
- package/dist/poll.js +33 -0
- package/docs/cast.md +133 -0
- package/docs/cli.md +39 -0
- package/docs/config.md +36 -0
- package/docs/decisions.md +150 -0
- package/docs/devices.md +128 -0
- package/docs/enphase-notes.md +24 -0
- package/docs/google.md +92 -0
- package/docs/ideas.md +69 -0
- package/docs/matter.md +55 -0
- package/docs/performance.md +12 -0
- package/docs/philosophy.md +23 -0
- package/docs/up.md +84 -0
- package/docs/using.md +65 -0
- package/docs/weather-probe.md +63 -0
- package/examples/home.jsonc +74 -0
- package/examples/home.svg +76 -0
- package/home.schema.json +474 -0
- package/llms.txt +32 -0
- package/package.json +143 -4
- package/skills/draw-home/README.md +26 -0
- package/skills/draw-home/SKILL.md +82 -0
- package/skills/use-home/SKILL.md +86 -0
- package/src/cli/cache.ts +29 -0
- package/src/cli/cast.ts +172 -0
- package/src/cli/config.ts +52 -0
- package/src/cli/context.ts +118 -0
- package/src/cli/discover.ts +175 -0
- package/src/cli/enphase.ts +62 -0
- package/src/cli/hue.ts +124 -0
- package/src/cli/locate.ts +47 -0
- package/src/cli/main.ts +182 -0
- package/src/cli/printers.ts +21 -0
- package/src/cli/probe.ts +274 -0
- package/src/cli/spotify.ts +76 -0
- package/src/cli/status.ts +228 -0
- package/src/cli/up.ts +350 -0
- package/src/clock.ts +62 -0
- package/src/config.ts +578 -0
- package/src/connector.ts +77 -0
- package/src/connectors/amber/README.md +40 -0
- package/src/connectors/amber/client.ts +157 -0
- package/src/connectors/amber/index.ts +118 -0
- package/src/connectors/cast/README.md +43 -0
- package/src/connectors/cast/codec.ts +126 -0
- package/src/connectors/cast/index.ts +551 -0
- package/src/connectors/cast/schemas.ts +101 -0
- package/src/connectors/cast/serve.ts +72 -0
- package/src/connectors/cast/state.ts +203 -0
- package/src/connectors/cast/tls.ts +100 -0
- package/src/connectors/cast/writes.ts +572 -0
- package/src/connectors/daikin/README.md +78 -0
- package/src/connectors/daikin/client.ts +295 -0
- package/src/connectors/daikin/index.ts +145 -0
- package/src/connectors/enphase/README.md +64 -0
- package/src/connectors/enphase/client.ts +244 -0
- package/src/connectors/enphase/index.ts +241 -0
- package/src/connectors/enphase/schemas.ts +45 -0
- package/src/connectors/hue/README.md +50 -0
- package/src/connectors/hue/client.ts +157 -0
- package/src/connectors/hue/events.ts +182 -0
- package/src/connectors/hue/index.ts +261 -0
- package/src/connectors/hue/schemas.ts +60 -0
- package/src/connectors/hue/state.ts +124 -0
- package/src/connectors/printers/README.md +46 -0
- package/src/connectors/printers/client.ts +114 -0
- package/src/connectors/printers/codec.ts +292 -0
- package/src/connectors/printers/found.ts +46 -0
- package/src/connectors/printers/index.ts +385 -0
- package/src/connectors/printers/state.ts +182 -0
- package/src/connectors/spotify/README.md +80 -0
- package/src/connectors/spotify/changes.ts +127 -0
- package/src/connectors/spotify/client.ts +387 -0
- package/src/connectors/spotify/index.ts +929 -0
- package/src/connectors/up/README.md +35 -0
- package/src/connectors/up/client.ts +518 -0
- package/src/connectors/up/index.ts +370 -0
- package/src/connectors/up/spend.ts +280 -0
- package/src/connectors/up/webhook.ts +62 -0
- package/src/connectors/weather/README.md +84 -0
- package/src/connectors/weather/blocks.ts +173 -0
- package/src/connectors/weather/bom.ts +344 -0
- package/src/connectors/weather/ftp.ts +48 -0
- package/src/connectors/weather/google.ts +425 -0
- package/src/connectors/weather/index.ts +380 -0
- package/src/connectors/weather/open-meteo.ts +309 -0
- package/src/connectors/weather/rain.ts +73 -0
- package/src/connectors/weather/source.ts +236 -0
- package/src/connectors/weather/sun.ts +114 -0
- package/src/discover.ts +634 -0
- package/src/energy.ts +221 -0
- package/src/finders-entry.ts +2 -0
- package/src/finders.ts +247 -0
- package/src/health.ts +74 -0
- package/src/home.ts +205 -0
- package/src/index.ts +62 -0
- package/src/lane.ts +110 -0
- package/src/locate.ts +85 -0
- package/src/manifest.ts +43 -0
- package/src/manifests.ts +27 -0
- package/src/node-fetch.ts +41 -0
- package/src/poll.ts +33 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { Clock } from "../../clock.ts";
|
|
3
|
+
import { ConnectorError, getJson } from "../../connector.ts";
|
|
4
|
+
import { writeLane } from "../../lane.ts";
|
|
5
|
+
|
|
6
|
+
// Daikin's JSON dsiot API (the BRP084 family): POST /dsiot/multireq, plain HTTP, no key. Field paths and value
|
|
7
|
+
// maps are protocol facts from pydaikin (GPL-3.0, read only; see README.md), checked against KO's unit 2026-10-13.
|
|
8
|
+
|
|
9
|
+
/** A failed Daikin call. `status` is the HTTP status, or the dsiot `rsc` when HTTP succeeded. */
|
|
10
|
+
export class DaikinError extends ConnectorError {}
|
|
11
|
+
|
|
12
|
+
export type Mode = "auto" | "cool" | "heat" | "fan" | "dry";
|
|
13
|
+
export type Fan = "1" | "2" | "3" | "4" | "5" | "auto" | "quiet";
|
|
14
|
+
|
|
15
|
+
/** The AC now. Temperatures in °C as the unit reports them. */
|
|
16
|
+
export type DaikinState = {
|
|
17
|
+
on?: boolean;
|
|
18
|
+
mode?: Mode; // the selected mode, kept while the unit is off
|
|
19
|
+
targetC?: number; // the setpoint of the selected mode; none in fan or dry
|
|
20
|
+
roomC?: number;
|
|
21
|
+
humidity?: number; // percent
|
|
22
|
+
fan?: Fan; // the fan speed of the selected mode; none in dry
|
|
23
|
+
outsideC?: number;
|
|
24
|
+
anomaly?: string; // a code or value the maps here do not know, kept visible rather than guessed
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type Node = { pn?: string; pv?: unknown; pch?: Node[] };
|
|
28
|
+
const Node: z.ZodType<Node> = z.looseObject({
|
|
29
|
+
pn: z.string().optional(),
|
|
30
|
+
pv: z.unknown().optional(),
|
|
31
|
+
get pch() {
|
|
32
|
+
return z.array(Node).optional();
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
const Multi = z.looseObject({
|
|
36
|
+
responses: z.array(
|
|
37
|
+
z.looseObject({
|
|
38
|
+
fr: z.string().optional(),
|
|
39
|
+
rsc: z.number().optional(),
|
|
40
|
+
pc: Node.optional(),
|
|
41
|
+
}),
|
|
42
|
+
),
|
|
43
|
+
});
|
|
44
|
+
type Multi = z.infer<typeof Multi>;
|
|
45
|
+
|
|
46
|
+
const INDOOR = "/dsiot/edge/adr_0100.dgc_status";
|
|
47
|
+
const OUTDOOR = "/dsiot/edge/adr_0200.dgc_status";
|
|
48
|
+
|
|
49
|
+
const MODES: Record<string, Mode> = {
|
|
50
|
+
"0000": "fan",
|
|
51
|
+
"0100": "heat",
|
|
52
|
+
"0200": "cool",
|
|
53
|
+
"0300": "auto",
|
|
54
|
+
"0500": "dry",
|
|
55
|
+
};
|
|
56
|
+
const FANS: Record<string, Fan> = {
|
|
57
|
+
"0300": "1",
|
|
58
|
+
"0400": "2",
|
|
59
|
+
"0500": "3",
|
|
60
|
+
"0600": "4",
|
|
61
|
+
"0700": "5",
|
|
62
|
+
"0A00": "auto",
|
|
63
|
+
"0B00": "quiet",
|
|
64
|
+
};
|
|
65
|
+
/** Each mode keeps its own setpoint and fan speed under `e_3001`. */
|
|
66
|
+
const TARGET: Partial<Record<Mode, string>> = {
|
|
67
|
+
cool: "p_02",
|
|
68
|
+
heat: "p_03",
|
|
69
|
+
auto: "p_1D",
|
|
70
|
+
};
|
|
71
|
+
const FAN: Partial<Record<Mode, string>> = {
|
|
72
|
+
cool: "p_09",
|
|
73
|
+
heat: "p_0A",
|
|
74
|
+
auto: "p_26",
|
|
75
|
+
fan: "p_28",
|
|
76
|
+
};
|
|
77
|
+
/** The setpoint range per mode, from the unit's own `md.mi`/`md.mx` (KO's unit, 2026-10-13). */
|
|
78
|
+
const TARGET_RANGE: Partial<Record<Mode, [number, number]>> = {
|
|
79
|
+
cool: [18, 32],
|
|
80
|
+
heat: [10, 30],
|
|
81
|
+
auto: [18, 30],
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** Little-endian hex to an integer, two's complement when `signed`: "2700" → 39, "EEFF" → -18. */
|
|
85
|
+
export function hexLE(hex: string, signed = false): number | undefined {
|
|
86
|
+
if (!/^(?:[0-9a-f]{2})+$/i.test(hex)) return undefined;
|
|
87
|
+
const bytes = hex.match(/../g)!.reverse().join("");
|
|
88
|
+
const n = Number.parseInt(bytes, 16);
|
|
89
|
+
return signed && n >= 2 ** (hex.length * 4 - 1)
|
|
90
|
+
? n - 2 ** (hex.length * 4)
|
|
91
|
+
: n;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The `pv` at a path of `pn` names under one response's tree, by name (never by position). */
|
|
95
|
+
function value(m: Multi, fr: string, path: string[]): string | undefined {
|
|
96
|
+
let node = m.responses.find((r) => r.fr === fr)?.pc;
|
|
97
|
+
for (const pn of path) node = node?.pch?.find((c) => c.pn === pn);
|
|
98
|
+
return typeof node?.pv === "string" ? node.pv : undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Map the indoor and outdoor `dgc_status` trees to a state. Unknown codes go to `anomaly`, never to a guess. */
|
|
102
|
+
export function toState(response: unknown): DaikinState {
|
|
103
|
+
const m = Multi.parse(response);
|
|
104
|
+
const odd: string[] = [];
|
|
105
|
+
const indoor = (...path: string[]) => value(m, INDOOR, ["e_1002", ...path]);
|
|
106
|
+
const code = <T>(
|
|
107
|
+
map: Record<string, T>,
|
|
108
|
+
hex: string | undefined,
|
|
109
|
+
what: string,
|
|
110
|
+
) => {
|
|
111
|
+
if (hex !== undefined && !(hex in map))
|
|
112
|
+
odd.push(`unknown ${what} code ${hex}`);
|
|
113
|
+
return hex === undefined ? undefined : map[hex];
|
|
114
|
+
};
|
|
115
|
+
const temp = (hex: string | undefined, divisor: number, what: string) => {
|
|
116
|
+
if (hex === undefined) return undefined;
|
|
117
|
+
const n = hexLE(hex, true);
|
|
118
|
+
if (n === undefined) odd.push(`${what} is not hex: ${hex}`);
|
|
119
|
+
return n === undefined ? undefined : n / divisor;
|
|
120
|
+
};
|
|
121
|
+
const power = indoor("e_A002", "p_01");
|
|
122
|
+
if (power !== undefined && power !== "00" && power !== "01")
|
|
123
|
+
odd.push(`unknown power code ${power}`);
|
|
124
|
+
const mode = code(MODES, indoor("e_3001", "p_01"), "mode");
|
|
125
|
+
const target = mode && TARGET[mode];
|
|
126
|
+
const fan = mode && FAN[mode];
|
|
127
|
+
const humidityHex = indoor("e_A00B", "p_02");
|
|
128
|
+
const humidity = humidityHex === undefined ? undefined : hexLE(humidityHex);
|
|
129
|
+
if (humidityHex !== undefined && humidity === undefined)
|
|
130
|
+
odd.push(`humidity is not hex: ${humidityHex}`);
|
|
131
|
+
return {
|
|
132
|
+
on: power === "01" ? true : power === "00" ? false : undefined,
|
|
133
|
+
mode,
|
|
134
|
+
targetC: target ? temp(indoor("e_3001", target), 2, "target") : undefined,
|
|
135
|
+
roomC: temp(indoor("e_A00B", "p_01"), 1, "room temperature"),
|
|
136
|
+
humidity,
|
|
137
|
+
fan: fan ? code(FANS, indoor("e_3001", fan), "fan") : undefined,
|
|
138
|
+
outsideC: temp(
|
|
139
|
+
value(m, OUTDOOR, ["e_1003", "e_A00D", "p_01"]),
|
|
140
|
+
2,
|
|
141
|
+
"outside temperature",
|
|
142
|
+
),
|
|
143
|
+
anomaly: odd.length ? odd.join("; ") : undefined,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** A multireq body that writes one or more `e_1002` fields of the indoor unit (op 3), merged into one tree. */
|
|
148
|
+
export type WriteRequest = { requests: { op: 3; to: string; pc: Node }[] };
|
|
149
|
+
const write = (
|
|
150
|
+
fields: [entity: string, pn: string, pv: string][],
|
|
151
|
+
): WriteRequest => ({
|
|
152
|
+
requests: [
|
|
153
|
+
{
|
|
154
|
+
op: 3,
|
|
155
|
+
to: INDOOR,
|
|
156
|
+
pc: {
|
|
157
|
+
pn: "dgc_status",
|
|
158
|
+
pch: [
|
|
159
|
+
{
|
|
160
|
+
pn: "e_1002",
|
|
161
|
+
pch: fields.map(([e, pn, pv]) => ({ pn: e, pch: [{ pn, pv }] })),
|
|
162
|
+
},
|
|
163
|
+
],
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
});
|
|
168
|
+
const fields = (r: WriteRequest): [string, string, string][] =>
|
|
169
|
+
r.requests.flatMap((q) =>
|
|
170
|
+
(q.pc.pch ?? []).flatMap((e1002) =>
|
|
171
|
+
(e1002.pch ?? []).flatMap((e) =>
|
|
172
|
+
(e.pch ?? []).map((f): [string, string, string] => [
|
|
173
|
+
e.pn!,
|
|
174
|
+
f.pn!,
|
|
175
|
+
String(f.pv),
|
|
176
|
+
]),
|
|
177
|
+
),
|
|
178
|
+
),
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
/** Two writes as one, field by field, the newer value winning: `setMode("cool")` then `setPower(false)` sends mode cool, power off. */
|
|
182
|
+
export function mergeWrites(
|
|
183
|
+
older: WriteRequest,
|
|
184
|
+
newer: WriteRequest,
|
|
185
|
+
): WriteRequest {
|
|
186
|
+
const merged = new Map(fields(older).map((f) => [`${f[0]}/${f[1]}`, f]));
|
|
187
|
+
for (const f of fields(newer)) merged.set(`${f[0]}/${f[1]}`, f);
|
|
188
|
+
return write([...merged.values()]);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Writes to the unit at most this often, and at most `WRITE_PER_MINUTE` a minute; a guess, the BRP084's limits are unpublished. */
|
|
192
|
+
export const WRITE_GAP_MS = 1000;
|
|
193
|
+
export const WRITE_PER_MINUTE = 20;
|
|
194
|
+
|
|
195
|
+
const key = <T>(map: Record<string, T>, v: T) =>
|
|
196
|
+
Object.keys(map).find((k) => map[k] === v)!;
|
|
197
|
+
|
|
198
|
+
/** Turn the unit on or off. */
|
|
199
|
+
export const setPower = (on: boolean) =>
|
|
200
|
+
write([["e_A002", "p_01", on ? "01" : "00"]]);
|
|
201
|
+
|
|
202
|
+
/** Select a mode and turn the unit on, as pydaikin and the Alira integration both do. */
|
|
203
|
+
export const setMode = (mode: Mode) =>
|
|
204
|
+
write([
|
|
205
|
+
["e_A002", "p_01", "01"],
|
|
206
|
+
["e_3001", "p_01", key(MODES, mode)],
|
|
207
|
+
]);
|
|
208
|
+
|
|
209
|
+
/** Set the setpoint of `mode` (usually the current one), one byte of °C × 2. Throws off the unit's range or 0.5 steps; never rounds. */
|
|
210
|
+
export function setTarget(c: number, mode: Mode): WriteRequest {
|
|
211
|
+
const range = TARGET_RANGE[mode];
|
|
212
|
+
if (!range) throw new DaikinError(`${mode} mode has no setpoint`);
|
|
213
|
+
if (!Number.isInteger(c * 2) || c < range[0] || c > range[1])
|
|
214
|
+
throw new DaikinError(
|
|
215
|
+
`${c} °C is not a ${mode} setpoint (${range[0]}–${range[1]} in 0.5 steps)`,
|
|
216
|
+
);
|
|
217
|
+
return write([
|
|
218
|
+
[
|
|
219
|
+
"e_3001",
|
|
220
|
+
TARGET[mode]!,
|
|
221
|
+
(c * 2).toString(16).toUpperCase().padStart(2, "0"),
|
|
222
|
+
],
|
|
223
|
+
]);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Set the fan speed of `mode` (usually the current one). */
|
|
227
|
+
export function setFan(fan: Fan, mode: Mode): WriteRequest {
|
|
228
|
+
if (!FAN[mode]) throw new DaikinError(`${mode} mode has no fan speed`);
|
|
229
|
+
return write([["e_3001", FAN[mode]!, key(FANS, fan)]]);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** A client for one unit. Reads are op 2; `write` sends a body built by setPower, setMode, setTarget or setFan. */
|
|
233
|
+
export function daikinClient(options: {
|
|
234
|
+
host: string;
|
|
235
|
+
fetch: typeof fetch;
|
|
236
|
+
clock?: Clock;
|
|
237
|
+
}) {
|
|
238
|
+
const lane = writeLane({
|
|
239
|
+
gapMs: WRITE_GAP_MS,
|
|
240
|
+
perMinute: WRITE_PER_MINUTE,
|
|
241
|
+
clock: options.clock,
|
|
242
|
+
});
|
|
243
|
+
const multireq = (body: unknown, signal?: AbortSignal): Promise<Multi> =>
|
|
244
|
+
getJson(options.fetch, `http://${options.host}/dsiot/multireq`, {
|
|
245
|
+
init: {
|
|
246
|
+
method: "POST",
|
|
247
|
+
headers: { "content-type": "application/json" },
|
|
248
|
+
body: JSON.stringify(body),
|
|
249
|
+
},
|
|
250
|
+
signal,
|
|
251
|
+
schema: Multi,
|
|
252
|
+
error: DaikinError,
|
|
253
|
+
});
|
|
254
|
+
return {
|
|
255
|
+
/** The indoor and outdoor status in one request. A failed outdoor read leaves `outsideC` empty. */
|
|
256
|
+
async read(signal?: AbortSignal): Promise<DaikinState> {
|
|
257
|
+
const m = await multireq(
|
|
258
|
+
{
|
|
259
|
+
requests: [INDOOR, OUTDOOR].map((to) => ({
|
|
260
|
+
op: 2,
|
|
261
|
+
to: `${to}?filter=pv,pt,md`,
|
|
262
|
+
})),
|
|
263
|
+
},
|
|
264
|
+
signal,
|
|
265
|
+
);
|
|
266
|
+
const indoor = m.responses.find((r) => r.fr === INDOOR);
|
|
267
|
+
if (indoor?.rsc !== 2000)
|
|
268
|
+
throw new DaikinError(`no indoor status (rsc ${indoor?.rsc})`, {
|
|
269
|
+
status: indoor?.rsc,
|
|
270
|
+
});
|
|
271
|
+
return toState(m);
|
|
272
|
+
},
|
|
273
|
+
/**
|
|
274
|
+
* Send a write. Only when KO asks: this changes the AC. Needs an answer for the indoor unit below 4000 (success codes
|
|
275
|
+
* unconfirmed; re-read to be sure). Writes waiting their turn merge (`mergeWrites`) and resolve with the send that carried them.
|
|
276
|
+
*/
|
|
277
|
+
write(request: WriteRequest): Promise<void> {
|
|
278
|
+
return lane.send(
|
|
279
|
+
"unit",
|
|
280
|
+
request,
|
|
281
|
+
async (r) => {
|
|
282
|
+
const answer = (await multireq(r)).responses.find(
|
|
283
|
+
(x) => x.fr === INDOOR,
|
|
284
|
+
);
|
|
285
|
+
if (!answer || answer.rsc === undefined || answer.rsc >= 4000)
|
|
286
|
+
throw new DaikinError(
|
|
287
|
+
`no accepted answer from the indoor unit (rsc ${answer?.rsc})`,
|
|
288
|
+
{ status: answer?.rsc },
|
|
289
|
+
);
|
|
290
|
+
},
|
|
291
|
+
mergeWrites,
|
|
292
|
+
);
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { asOf, type Clock, realClock } from "../../clock.ts";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import type { Integration } from "../../home.ts";
|
|
4
|
+
import { type Finder, lostDetail, relocator } from "../../discover.ts";
|
|
5
|
+
import type { Manifest } from "../../manifest.ts";
|
|
6
|
+
import { poller } from "../../poll.ts";
|
|
7
|
+
import { daikinClient, DaikinError, type DaikinState } from "./client.ts";
|
|
8
|
+
|
|
9
|
+
export {
|
|
10
|
+
DaikinError,
|
|
11
|
+
type DaikinState,
|
|
12
|
+
daikinClient,
|
|
13
|
+
type Fan,
|
|
14
|
+
type Mode,
|
|
15
|
+
setFan,
|
|
16
|
+
setMode,
|
|
17
|
+
setPower,
|
|
18
|
+
setTarget,
|
|
19
|
+
type WriteRequest,
|
|
20
|
+
} from "./client.ts";
|
|
21
|
+
|
|
22
|
+
/** Poll every 120 s, as Home Assistant's daikin integration does (its coordinator's update_interval). */
|
|
23
|
+
export const POLL_MS = 120_000;
|
|
24
|
+
|
|
25
|
+
/** A Daikin AC in a home config: its last known address, and the MAC its UDP discovery reports, which is how it is recognised. */
|
|
26
|
+
export const configSchema = z.looseObject({
|
|
27
|
+
connector: z.literal("daikin"),
|
|
28
|
+
host: z.string().describe("the unit's last known address"),
|
|
29
|
+
mac: z
|
|
30
|
+
.string()
|
|
31
|
+
.optional()
|
|
32
|
+
.describe("the unit's MAC as its UDP discovery reports it: its identity"),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/** Daikin settings: its last known address, its MAC and a UDP discovery to find it again if it moves (both optional), and a `fetch`. */
|
|
36
|
+
export type DaikinConfig = {
|
|
37
|
+
host: string;
|
|
38
|
+
mac?: string;
|
|
39
|
+
find?: Finder;
|
|
40
|
+
fetch: typeof fetch;
|
|
41
|
+
clock?: Clock;
|
|
42
|
+
seen?: (host: string) => void;
|
|
43
|
+
hostCached?: boolean;
|
|
44
|
+
}; // seen: after each good read, for an address cache; hostCached: `host` came from it
|
|
45
|
+
|
|
46
|
+
/** What the snapshot holds: the latest reading and when it was taken. */
|
|
47
|
+
export type DaikinSnapshot = DaikinState & { asOf: string };
|
|
48
|
+
|
|
49
|
+
/** What `daikin().client` is: the reads and writes of the unit, at wherever it is now. */
|
|
50
|
+
export type DaikinClient = ReturnType<typeof daikinClient>;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* One Daikin AC as a home integration: one read now, then every 120 s. Its writes are `client.write(setPower(true))`: one
|
|
54
|
+
* client for the integration's life, so the writes share its lane and follow the unit when it moves.
|
|
55
|
+
*/
|
|
56
|
+
export function daikin(
|
|
57
|
+
config: DaikinConfig,
|
|
58
|
+
): Integration<DaikinSnapshot, "daikin"> & { readonly client: DaikinClient } {
|
|
59
|
+
const clock = config.clock ?? realClock;
|
|
60
|
+
const where = relocator({
|
|
61
|
+
host: config.host,
|
|
62
|
+
id: config.mac,
|
|
63
|
+
find: config.find,
|
|
64
|
+
clock,
|
|
65
|
+
cached: config.hostCached,
|
|
66
|
+
});
|
|
67
|
+
const client = daikinClient({
|
|
68
|
+
...config,
|
|
69
|
+
get host() {
|
|
70
|
+
return where.host();
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
const poll = poller(clock);
|
|
74
|
+
return {
|
|
75
|
+
name: "daikin",
|
|
76
|
+
client,
|
|
77
|
+
refresh: () => (where.reset(), poll.nudge()),
|
|
78
|
+
async run(signal, update) {
|
|
79
|
+
let last: DaikinSnapshot | undefined;
|
|
80
|
+
let moved: string | undefined; // kept in `detail` so a consumer can persist the new address
|
|
81
|
+
while (!signal.aborted) {
|
|
82
|
+
try {
|
|
83
|
+
last = { ...(await client.read(signal)), asOf: asOf(clock) };
|
|
84
|
+
where.ok();
|
|
85
|
+
config.seen?.(where.host());
|
|
86
|
+
update({
|
|
87
|
+
status: "connected",
|
|
88
|
+
detail: moved,
|
|
89
|
+
anomaly: last.anomaly,
|
|
90
|
+
state: last,
|
|
91
|
+
});
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (signal.aborted) return; // stopped mid-read: not a failure
|
|
94
|
+
// A 404 is a failed read like any other: the address may now be another device's, so discovery looks for the unit.
|
|
95
|
+
const why =
|
|
96
|
+
error instanceof DaikinError && error.status === 404
|
|
97
|
+
? "no /dsiot/multireq at this address: not a dsiot Daikin"
|
|
98
|
+
: error;
|
|
99
|
+
const r = await where.failed(signal);
|
|
100
|
+
if (r.moved) {
|
|
101
|
+
moved = r.moved;
|
|
102
|
+
continue; // read at the new address now; that read reports, with the move in `detail`
|
|
103
|
+
}
|
|
104
|
+
update({
|
|
105
|
+
status: "disconnected",
|
|
106
|
+
detail: lostDetail(r, why),
|
|
107
|
+
state: last,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
await poll.wait(POLL_MS, signal);
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The AC in a few words: "off · cool 25 °C · room 24 °C · outdoor unit 19.5 °C". */
|
|
117
|
+
export function acText(s: DaikinState): string {
|
|
118
|
+
const setting = [s.mode, s.targetC === undefined ? "" : `${s.targetC} °C`]
|
|
119
|
+
.filter(Boolean)
|
|
120
|
+
.join(" ");
|
|
121
|
+
return [
|
|
122
|
+
s.on === undefined ? "" : s.on ? "on" : "off",
|
|
123
|
+
setting,
|
|
124
|
+
s.roomC === undefined ? "" : `room ${s.roomC} °C`,
|
|
125
|
+
s.outsideC === undefined ? "" : `outdoor unit ${s.outsideC} °C`,
|
|
126
|
+
]
|
|
127
|
+
.filter(Boolean)
|
|
128
|
+
.join(" · ");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export const manifest: Manifest<DaikinSnapshot> = {
|
|
132
|
+
name: "daikin",
|
|
133
|
+
title: "Daikin",
|
|
134
|
+
iotClass: "local_polling",
|
|
135
|
+
pollSeconds: POLL_MS / 1000,
|
|
136
|
+
access: "local HTTP JSON (dsiot), no key",
|
|
137
|
+
env: [
|
|
138
|
+
{
|
|
139
|
+
name: "DAIKIN_HOST",
|
|
140
|
+
example: "192.0.2.20",
|
|
141
|
+
what: "the unit's address; `home probe` finds it by UDP broadcast",
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
lines: ({ state }) => (state ? [acText(state)] : []),
|
|
145
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Enphase
|
|
2
|
+
|
|
3
|
+
Reads an IQ Gateway (Envoy) on the LAN: production, consumption and grid flow. `iot_class`: **local_polling**, every 60 s. Tried on one metered gateway with microinverters, **software D8.3.5528**, `web-tokens: true` (read 2026-10-13).
|
|
4
|
+
|
|
5
|
+
## Built
|
|
6
|
+
|
|
7
|
+
- `enphase({ host, token, fetch: bunGatewayFetch() })` polls every 60 s (the caller chooses the TLS trade-off; see below). With a production meter on, it reads `GET /ivp/meters` and `GET /ivp/meters/readings`, joined by `eid` (`fromReadings`): about 100 ms on KO's gateway, where `/production.json?details=1` takes 1–10 s (measured, Bun 1.4.2) against a 10 s timeout. Production is the production meter's `activePower`; on a net-consumption meter, consumption is production plus net import, and lifetime consumption is production + imported − exported. Both matched `/production.json` live to within a few watts and to the Wh. Without a production meter it falls back to `/production.json` (`toState`).
|
|
8
|
+
- Today is counted by the integration from the lifetime counters, from its first reading of the day (`todaySince`). The day starts at midnight in `timeZone` (the home config's `location.timezone`, an IANA name), the runtime's zone when unset, so a consumer on a UTC server does not reset at 10 am Sydney.
|
|
9
|
+
- Today's peak (`productionWPeakToday`, since `peakSince`) is the highest production the integration has read today, one read a minute, so a short burst between reads is missed. It starts again at the home's midnight, or when the integration starts. A drawing scales the roof's panels by it when the config gives no `capacityW` (`solarLevel` in `src/energy.ts`).
|
|
10
|
+
- `host` may be pasted with a scheme or a trailing slash (`https://…/`); `gatewayHost` reduces it to the bare host for requests, discovery and the address cache.
|
|
11
|
+
- In the home config the device may carry `marker` (the plan's marker where the array is drawn, on the roof outside any room) and `capacityW` (the array's rated watts from the installer's paperwork, never guessed from the gateway). Neither reaches the gateway; they are for drawing.
|
|
12
|
+
- A meter counts only when `/ivp/meters` says `enabled` **and** its reading has `activeCount > 0`; otherwise production comes from the inverters and Wh today is left out, since pyenphase found the inverter daily total unreliable.
|
|
13
|
+
- The token's days left ride in the snapshot; under 30 the status says to run `home enphase login`. A 401 marks Enphase unavailable and stops polling.
|
|
14
|
+
- `home enphase login` mints the token (below) and writes `~/.config/home/enphase-token.json` (0600, directory 0700), printing only the days left. `home status` shows solar now, consumption and the token's days left.
|
|
15
|
+
|
|
16
|
+
**Live, KO's gateway, 2026-10-13 (D8.3.5528, owner token).** `/info` answers without a token; everything else answers 401 without one. With the token, one GET each:
|
|
17
|
+
|
|
18
|
+
- `/ivp/meters`: `production` and `net-consumption` **enabled**, `meteringStatus normal`; `storage`, `backfeed`, `load`, `evse`, `pv3p` disabled. So both CTs are fitted.
|
|
19
|
+
- `/production.json?details=1`: `production` holds `inverters` (`activeCount`, `wNow`, `whLifetime`, no daily fields) and `eim production` (20 fields); `consumption` holds `eim total-consumption` and `eim net-consumption`; `storage` holds an inactive `acb`. At dusk production was near zero and net consumption matched consumption less production.
|
|
20
|
+
- **Anomaly**: on every `eim` reading, `whToday`, `whLastSevenDays` and `whLifetime` are the same number. The daily counters are broken on this firmware. `toState` drops them and sets `anomaly`; `enphase()` counts today from the lifetime counter from its first reading of the day and marks where that count starts (`todaySince`).
|
|
21
|
+
- `/api/v1/production`: all zeros except `wattHoursLifetime`. Not used.
|
|
22
|
+
- `/ivp/meters/readings`: 8 entries with `eid`, `timestamp`, `actEnergyDlvd`, `actEnergyRcvd`, `instantaneousDemand` and more. Not used yet.
|
|
23
|
+
- The lifetime totals disagree across the production meter, the inverters (far lower) and `/api/v1`. The meter is used; why the inverter total is lower is unconfirmed (a reset or replacement is a guess).
|
|
24
|
+
|
|
25
|
+
## TLS: why the Bun path skips verification
|
|
26
|
+
|
|
27
|
+
The gateway's certificate is **not self-signed** on software 8: it serves `CN=<serial>.<part>._mDevice.enphaseenergy.com`, issued by `Cloud.HSM.TA`, then `Factories.MFR.CA` (valid to 2070), which chains to `root-a.PKI.CA`. The gateway does not send that root and Enphase does not publish it (searched 2026-10-13).
|
|
28
|
+
|
|
29
|
+
- **Bun 1.4.2** (measured): trusting `Factories.MFR.CA` fails with "unable to get issuer certificate" (no partial-chain trust), and with verification off Bun does not run `checkServerIdentity`, so the CN cannot be checked either. `bunGatewayFetch()` therefore skips verification for the gateway's requests only. The token goes to whatever answers at that address, so reserve the address in the router.
|
|
30
|
+
- **Node**: `nodeGatewayFetch()` from `@khalido/home/node` skips verification the same way (checked against a local self-signed server, 2026-10-28; the gateway did not answer that day). Untested: undici's `Agent({ connect: { ca: factoriesMfrCa, allowPartialTrustChain: true, checkServerIdentity } })`, checking the CN starts with the serial, would pin it properly (Node 22.9 and later).
|
|
31
|
+
|
|
32
|
+
## The plan: local IQ Gateway with an owner token
|
|
33
|
+
|
|
34
|
+
Source for every step: Enphase's technical brief [Accessing IQ Gateway local APIs and local UI using token](https://enphase.com/download/iq-gateway-local-apis-or-ui-access-using-token) (TEB-00060-2.0, June 2025). It lists ENV-S-WM-230 among the gateways it covers.
|
|
35
|
+
|
|
36
|
+
1. **Mint a token once** (`home enphase login`, KO runs it). Gateway software 7.0 and later needs a token for local APIs. The brief's script does two calls:
|
|
37
|
+
- `POST https://enlighten.enphaseenergy.com/login/login.json` with form fields `user[email]` and `user[password]`, returning a `session_id`. (The brief writes `http://`; use HTTPS.)
|
|
38
|
+
- `POST https://entrez.enphaseenergy.com/tokens` with JSON `{ session_id, serial_num, username }`, returning the token.
|
|
39
|
+
|
|
40
|
+
A system owner's token is valid for **one year**; an installer's, or a self-installer's, for 12 hours. Unconfirmed which KO's account is; the token's expiry will say.
|
|
41
|
+
|
|
42
|
+
2. **Cache it** in `~/.config/home/enphase-token.json`, owner-only (0600), written by the CLI only. The library never stores it; callers pass it in. The password is read from `ENPHASE_PASSWORD` for those two calls and never written.
|
|
43
|
+
3. **Read locally**: HTTPS to the gateway with `Authorization: Bearer <token>` and `Accept: application/json`. The brief uses `curl -k`; see TLS above.
|
|
44
|
+
|
|
45
|
+
Endpoints from the brief: `/info` (serial, part number, software; **no token needed**), `/api/v1/production` (watts now, watt-hours today, seven days, lifetime), `/api/v1/production/inverters`, `/ivp/meters`, `/ivp/meters/readings`, `/ivp/pdm/energy`, `/ivp/livedata/status`, `/ivp/meters/reports/consumption`, `/ivp/meters/gridReading`.
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
home enphase login # mints the token from IQ_GATEWAY_SERIAL_NUMBER, ENPHASE_USERNAME, ENPHASE_PASSWORD; prints its expiry
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## From Home Assistant's `enphase_envoy`
|
|
52
|
+
|
|
53
|
+
Its coordinator polls the gateway every 60 s, rechecks the firmware every 4 h, checks the token once a day, and refreshes it when fewer than 30 days remain, raising a repair notice if that fails. The plan copies the policy: `home status` shows the token's days left, and a client warns under 30.
|
|
54
|
+
|
|
55
|
+
## Sources
|
|
56
|
+
|
|
57
|
+
| Source | Licence | Used for |
|
|
58
|
+
| ------------------------------------------------------------------------------------------------------------------------------------ | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
|
|
59
|
+
| Enphase [Envoy-S manual](https://portal.segen.co.uk/reseller/docs/Envoy-S-MAN-EN-INTL.pdf) (141-00035 Rev 02, a reseller's copy) | Enphase document | LEDs, AP mode, Wi-Fi change |
|
|
60
|
+
| Enphase [TEB-00060-2.0](https://enphase.com/download/iq-gateway-local-apis-or-ui-access-using-token) | Enphase document, © Enphase | token flow, local endpoints |
|
|
61
|
+
| [Enphase developer v4](https://developer-v4.enphase.com/) | Enphase API terms | the cloud alternative |
|
|
62
|
+
| [Home Assistant `enphase_envoy` integration](https://github.com/home-assistant/core/tree/dev/homeassistant/components/enphase_envoy) | Apache-2.0 | how HA reads a local gateway |
|
|
63
|
+
| [pyenphase/pyenphase](https://github.com/pyenphase/pyenphase) | MIT | endpoints for software 8, the meter rules, and the test fixtures (copied with attribution) |
|
|
64
|
+
| The author's earlier Python dashboard (not public) | the author's | ran against this gateway: the env names, `/production.json` with `/ivp/meters`, trusting a meter only when enabled and active |
|