@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,244 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ConnectorError, fetchTimeout, getJson } from "../../connector.ts";
|
|
3
|
+
import {
|
|
4
|
+
Login,
|
|
5
|
+
Meters,
|
|
6
|
+
Production,
|
|
7
|
+
Readings,
|
|
8
|
+
type Measurement,
|
|
9
|
+
} from "./schemas.ts";
|
|
10
|
+
|
|
11
|
+
/** A failed Enphase call. `status` 401 means the token is wrong or expired. */
|
|
12
|
+
export class EnphaseError extends ConnectorError {}
|
|
13
|
+
|
|
14
|
+
/** Solar right now and today. Watts and watt-hours as the gateway reports them, negatives included. */
|
|
15
|
+
export type EnphaseState = {
|
|
16
|
+
productionW?: number;
|
|
17
|
+
productionWhToday?: number; // undefined when the gateway's daily counter is missing or broken (see anomaly)
|
|
18
|
+
productionWhLifetime?: number;
|
|
19
|
+
consumptionW?: number; // needs a consumption meter
|
|
20
|
+
consumptionWhToday?: number;
|
|
21
|
+
consumptionWhLifetime?: number;
|
|
22
|
+
netW?: number; // positive imports from the grid, negative exports
|
|
23
|
+
source: "meter" | "inverters"; // where production came from
|
|
24
|
+
readingTime?: string; // the gateway's own reading time
|
|
25
|
+
anomaly?: string; // something the gateway reported that cannot be right, kept visible rather than corrected
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** A daily counter equal to the lifetime one is the software 8 bug seen on KO's gateway (D8.3.5528): not today's energy. */
|
|
29
|
+
const today = (m: Measurement | undefined) =>
|
|
30
|
+
m?.whToday !== undefined && m.whToday !== m.whLifetime
|
|
31
|
+
? m.whToday
|
|
32
|
+
: undefined;
|
|
33
|
+
|
|
34
|
+
const find = (list: Measurement[], type: string, measurementType?: string) =>
|
|
35
|
+
list.find(
|
|
36
|
+
(m) =>
|
|
37
|
+
m.type === type &&
|
|
38
|
+
(measurementType === undefined || m.measurementType === measurementType),
|
|
39
|
+
);
|
|
40
|
+
const live = (m: Measurement | undefined) =>
|
|
41
|
+
m && (m.activeCount ?? 0) > 0 ? m : undefined;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Map `/production.json` and `/ivp/meters` to a state. A meter counts only when `/ivp/meters` says
|
|
45
|
+
* "enabled" and its reading has `activeCount > 0` (KO's v1 code, pyenphase); else production falls back to the inverters.
|
|
46
|
+
*/
|
|
47
|
+
export function toState(production: unknown, meters: unknown): EnphaseState {
|
|
48
|
+
const p = Production.parse(production);
|
|
49
|
+
const enabled = new Set(
|
|
50
|
+
Meters.parse(meters)
|
|
51
|
+
.filter((m) => m.state === "enabled")
|
|
52
|
+
.map((m) => m.measurementType),
|
|
53
|
+
);
|
|
54
|
+
const prodMeter = enabled.has("production")
|
|
55
|
+
? live(find(p.production, "eim", "production"))
|
|
56
|
+
: undefined;
|
|
57
|
+
const inverters = find(p.production, "inverters");
|
|
58
|
+
const hasConsumption =
|
|
59
|
+
enabled.has("total-consumption") || enabled.has("net-consumption");
|
|
60
|
+
const total = hasConsumption
|
|
61
|
+
? live(find(p.consumption, "eim", "total-consumption"))
|
|
62
|
+
: undefined;
|
|
63
|
+
const net = hasConsumption
|
|
64
|
+
? live(find(p.consumption, "eim", "net-consumption"))
|
|
65
|
+
: undefined;
|
|
66
|
+
const at = (prodMeter ?? inverters)?.readingTime;
|
|
67
|
+
const broken = [prodMeter, total].some(
|
|
68
|
+
(m) => m?.whToday !== undefined && m.whToday === m.whLifetime,
|
|
69
|
+
);
|
|
70
|
+
return {
|
|
71
|
+
productionW: (prodMeter ?? inverters)?.wNow,
|
|
72
|
+
productionWhToday: today(prodMeter),
|
|
73
|
+
productionWhLifetime: (prodMeter ?? inverters)?.whLifetime,
|
|
74
|
+
consumptionW: total?.wNow,
|
|
75
|
+
consumptionWhToday: today(total),
|
|
76
|
+
consumptionWhLifetime: total?.whLifetime,
|
|
77
|
+
...(broken
|
|
78
|
+
? {
|
|
79
|
+
anomaly:
|
|
80
|
+
"the gateway's whToday equals its lifetime total; today is counted from the lifetime counter instead",
|
|
81
|
+
}
|
|
82
|
+
: {}),
|
|
83
|
+
netW: net?.wNow,
|
|
84
|
+
source: prodMeter ? "meter" : "inverters",
|
|
85
|
+
readingTime: at ? new Date(at * 1000).toISOString() : undefined,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Map `/ivp/meters` and `/ivp/meters/readings` to a state: ~100 ms on KO's gateway against 1–10 s for
|
|
91
|
+
* `/production.json` (measured, Bun 1.4.2). Consumption is production plus net import on a net-consumption meter,
|
|
92
|
+
* or read directly from a total-consumption meter (pyenphase's meter types). No daily totals here: callers count today.
|
|
93
|
+
*/
|
|
94
|
+
export function fromReadings(meters: unknown, readings: unknown): EnphaseState {
|
|
95
|
+
const enabled = Meters.parse(meters).filter((m) => m.state === "enabled");
|
|
96
|
+
const byEid = new Map(Readings.parse(readings).map((r) => [r.eid, r]));
|
|
97
|
+
const reading = (type: string) =>
|
|
98
|
+
byEid.get(enabled.find((m) => m.measurementType === type)?.eid ?? -1);
|
|
99
|
+
const prod = reading("production");
|
|
100
|
+
const net = reading("net-consumption");
|
|
101
|
+
const total = reading("total-consumption");
|
|
102
|
+
const pW = prod?.activePower;
|
|
103
|
+
const consumptionW =
|
|
104
|
+
total?.activePower ??
|
|
105
|
+
(net?.activePower !== undefined && pW !== undefined
|
|
106
|
+
? pW + net.activePower
|
|
107
|
+
: undefined);
|
|
108
|
+
const pLife = prod?.actEnergyDlvd;
|
|
109
|
+
const consumptionWhLifetime =
|
|
110
|
+
total?.actEnergyDlvd ??
|
|
111
|
+
(pLife !== undefined &&
|
|
112
|
+
net?.actEnergyDlvd !== undefined &&
|
|
113
|
+
net.actEnergyRcvd !== undefined
|
|
114
|
+
? pLife + net.actEnergyDlvd - net.actEnergyRcvd
|
|
115
|
+
: undefined);
|
|
116
|
+
const at = prod?.timestamp;
|
|
117
|
+
return {
|
|
118
|
+
productionW: pW,
|
|
119
|
+
productionWhLifetime: pLife,
|
|
120
|
+
consumptionW,
|
|
121
|
+
consumptionWhLifetime,
|
|
122
|
+
netW:
|
|
123
|
+
net?.activePower ??
|
|
124
|
+
(consumptionW !== undefined && pW !== undefined
|
|
125
|
+
? consumptionW - pW
|
|
126
|
+
: undefined),
|
|
127
|
+
source: "meter",
|
|
128
|
+
readingTime: at ? new Date(at * 1000).toISOString() : undefined,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** What a gateway client needs: its LAN address, an owner token, and a `fetch` that accepts its certificate. */
|
|
133
|
+
export type EnphaseOptions = {
|
|
134
|
+
host: string;
|
|
135
|
+
token: string;
|
|
136
|
+
fetch: typeof fetch;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/** The gateway's address as a bare host: a scheme prefix and a trailing slash, as people paste it, are dropped. */
|
|
140
|
+
export const gatewayHost = (host: string) =>
|
|
141
|
+
host
|
|
142
|
+
.trim()
|
|
143
|
+
.replace(/^https?:\/\//i, "")
|
|
144
|
+
.replace(/\/+$/, "");
|
|
145
|
+
|
|
146
|
+
/** A read-only client for the IQ Gateway's local API (software 7 and later). */
|
|
147
|
+
export function enphaseClient(options: EnphaseOptions) {
|
|
148
|
+
const headers = {
|
|
149
|
+
authorization: `Bearer ${options.token}`,
|
|
150
|
+
accept: "application/json",
|
|
151
|
+
};
|
|
152
|
+
// `host` is read at each call, so a getter follows a move.
|
|
153
|
+
const get = (path: string, signal?: AbortSignal) =>
|
|
154
|
+
getJson(options.fetch, `https://${gatewayHost(options.host)}${path}`, {
|
|
155
|
+
init: { headers },
|
|
156
|
+
signal,
|
|
157
|
+
error: EnphaseError,
|
|
158
|
+
});
|
|
159
|
+
return {
|
|
160
|
+
get,
|
|
161
|
+
/** Production, consumption and net now: the fast meter readings when a production meter is on, else `/production.json`. */
|
|
162
|
+
async read(signal?: AbortSignal): Promise<EnphaseState> {
|
|
163
|
+
const meters = await get("/ivp/meters", signal);
|
|
164
|
+
const metered = Meters.parse(meters).some(
|
|
165
|
+
(m) => m.measurementType === "production" && m.state === "enabled",
|
|
166
|
+
);
|
|
167
|
+
return metered
|
|
168
|
+
? fromReadings(meters, await get("/ivp/meters/readings", signal))
|
|
169
|
+
: toState(await get("/production.json?details=1", signal), meters);
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** When a JWT expires, from its `exp` claim. Throws on anything that is not a JWT. */
|
|
175
|
+
export function tokenExpiry(token: string): Date {
|
|
176
|
+
const payload = token.split(".")[1];
|
|
177
|
+
if (!payload) throw new EnphaseError("not a JWT");
|
|
178
|
+
const json = JSON.parse(atob(payload.replace(/-/g, "+").replace(/_/g, "/")));
|
|
179
|
+
const exp = z.object({ exp: z.number() }).parse(json).exp;
|
|
180
|
+
return new Date(exp * 1000);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Whole days until `expiresAt`, negative once expired. */
|
|
184
|
+
export const daysLeft = (expiresAt: Date, now: Date) =>
|
|
185
|
+
Math.floor((expiresAt.getTime() - now.getTime()) / 86_400_000);
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Mint an owner token as Enphase's TEB-00060 shows: log in for a session id, then ask Entrez for the gateway's token.
|
|
189
|
+
* Two calls to Enphase's cloud; nothing is stored here.
|
|
190
|
+
*/
|
|
191
|
+
export async function mintToken(options: {
|
|
192
|
+
username: string;
|
|
193
|
+
password: string;
|
|
194
|
+
serial: string;
|
|
195
|
+
fetch: typeof fetch;
|
|
196
|
+
}) {
|
|
197
|
+
const form = new URLSearchParams({
|
|
198
|
+
"user[email]": options.username,
|
|
199
|
+
"user[password]": options.password,
|
|
200
|
+
});
|
|
201
|
+
const login = await fetchTimeout(
|
|
202
|
+
options.fetch,
|
|
203
|
+
"https://enlighten.enphaseenergy.com/login/login.json",
|
|
204
|
+
{ method: "POST", body: form },
|
|
205
|
+
);
|
|
206
|
+
const session = Login.safeParse(await login.json().catch(() => undefined));
|
|
207
|
+
if (!login.ok || !session.success)
|
|
208
|
+
throw new EnphaseError(`Enphase login failed (HTTP ${login.status})`, {
|
|
209
|
+
status: login.status,
|
|
210
|
+
});
|
|
211
|
+
const res = await fetchTimeout(
|
|
212
|
+
options.fetch,
|
|
213
|
+
"https://entrez.enphaseenergy.com/tokens",
|
|
214
|
+
{
|
|
215
|
+
method: "POST",
|
|
216
|
+
headers: { "content-type": "application/json" },
|
|
217
|
+
body: JSON.stringify({
|
|
218
|
+
session_id: session.data.session_id,
|
|
219
|
+
serial_num: options.serial,
|
|
220
|
+
username: options.username,
|
|
221
|
+
}),
|
|
222
|
+
},
|
|
223
|
+
);
|
|
224
|
+
const token = (await res.text()).trim();
|
|
225
|
+
if (!res.ok)
|
|
226
|
+
throw new EnphaseError(`Entrez refused the token (HTTP ${res.status})`, {
|
|
227
|
+
status: res.status,
|
|
228
|
+
});
|
|
229
|
+
return { token, expiresAt: tokenExpiry(token) };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* A `fetch` for the gateway that skips certificate verification, for this gateway's requests only (Bun's `tls` option).
|
|
234
|
+
* The gateway's chain ends at Enphase's unpublished `root-a.PKI.CA`, and Bun 1.4.2 neither trusts a partial chain nor
|
|
235
|
+
* runs `checkServerIdentity` with verification off, so it cannot pin (measured). Reserve the gateway's IP. Node: see README.
|
|
236
|
+
*/
|
|
237
|
+
export function bunGatewayFetch(): typeof fetch {
|
|
238
|
+
const f = (input: RequestInfo | URL, init?: RequestInit) =>
|
|
239
|
+
fetch(input, {
|
|
240
|
+
...init,
|
|
241
|
+
tls: { rejectUnauthorized: false },
|
|
242
|
+
} as RequestInit);
|
|
243
|
+
return f as typeof fetch;
|
|
244
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { type Clock, localDay, realClock } from "../../clock.ts";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { filePath, type Integration } from "../../home.ts";
|
|
4
|
+
import { type Finder, lostDetail, relocator } from "../../discover.ts";
|
|
5
|
+
import { kw, type Manifest } from "../../manifest.ts";
|
|
6
|
+
import { poller } from "../../poll.ts";
|
|
7
|
+
import {
|
|
8
|
+
daysLeft,
|
|
9
|
+
enphaseClient,
|
|
10
|
+
EnphaseError,
|
|
11
|
+
type EnphaseState,
|
|
12
|
+
gatewayHost,
|
|
13
|
+
tokenExpiry,
|
|
14
|
+
} from "./client.ts";
|
|
15
|
+
|
|
16
|
+
export {
|
|
17
|
+
bunGatewayFetch,
|
|
18
|
+
EnphaseError,
|
|
19
|
+
type EnphaseOptions,
|
|
20
|
+
type EnphaseState,
|
|
21
|
+
enphaseClient,
|
|
22
|
+
mintToken,
|
|
23
|
+
tokenExpiry,
|
|
24
|
+
} from "./client.ts";
|
|
25
|
+
|
|
26
|
+
/** Poll every 60 s, as Home Assistant's enphase_envoy does. */
|
|
27
|
+
export const POLL_MS = 60_000;
|
|
28
|
+
/** Warn when the token has fewer days left than this (Home Assistant's threshold). */
|
|
29
|
+
const TOKEN_WARN_DAYS = 30;
|
|
30
|
+
|
|
31
|
+
/** An IQ Gateway in a home config: its last known address, its serial (its identity), and the owner token's file. */
|
|
32
|
+
export const configSchema = z.looseObject({
|
|
33
|
+
connector: z.literal("enphase"),
|
|
34
|
+
host: z.string().describe("the gateway's last known address"),
|
|
35
|
+
serial: z
|
|
36
|
+
.string()
|
|
37
|
+
.optional()
|
|
38
|
+
.describe(
|
|
39
|
+
"the gateway's serial, as mDNS `serialnum` (and /info) report it: its identity",
|
|
40
|
+
),
|
|
41
|
+
token: filePath.describe(
|
|
42
|
+
"the owner token file: JSON with a `token` field (what `home enphase login` writes), or the bare token",
|
|
43
|
+
),
|
|
44
|
+
marker: z
|
|
45
|
+
.string()
|
|
46
|
+
.startsWith("marker-")
|
|
47
|
+
.optional()
|
|
48
|
+
.describe(
|
|
49
|
+
"the plan's marker where the solar array is drawn, on the roof outside any room",
|
|
50
|
+
),
|
|
51
|
+
capacityW: z
|
|
52
|
+
.number()
|
|
53
|
+
.positive()
|
|
54
|
+
.optional()
|
|
55
|
+
.describe(
|
|
56
|
+
"the array's rated size in watts, from the installer's paperwork; never guessed from the gateway",
|
|
57
|
+
),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/** Enphase settings: last known address, owner token, and optionally its serial and an mDNS discovery to find it again if it moves. `fetch` must accept the gateway's certificate: `bunGatewayFetch()` in Bun (see README). */
|
|
61
|
+
export type EnphaseConfig = {
|
|
62
|
+
host: string;
|
|
63
|
+
token: string;
|
|
64
|
+
serial?: string;
|
|
65
|
+
find?: Finder;
|
|
66
|
+
fetch: typeof fetch;
|
|
67
|
+
clock?: Clock;
|
|
68
|
+
timeZone?: string;
|
|
69
|
+
seen?: (host: string) => void;
|
|
70
|
+
hostCached?: boolean;
|
|
71
|
+
}; // seen: after each good read, for an address cache; hostCached: `host` came from it // timeZone: where "today" starts (IANA), the runtime's by default
|
|
72
|
+
|
|
73
|
+
/** What the snapshot holds: the latest reading, when it was taken, and the token's days left. */
|
|
74
|
+
export type EnphaseSnapshot = EnphaseState & {
|
|
75
|
+
asOf: string;
|
|
76
|
+
tokenDaysLeft: number;
|
|
77
|
+
todaySince?: string; // set when "today" is counted locally from the lifetime counter: it starts here, not at midnight
|
|
78
|
+
productionWPeakToday?: number; // the highest productionW this integration has read today, one read a minute, since `peakSince`
|
|
79
|
+
peakSince?: string; // midnight's first read, or when the integration started
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** The IQ Gateway as a home integration: one read now, then every 60 s. */
|
|
83
|
+
export function enphase(
|
|
84
|
+
config: EnphaseConfig,
|
|
85
|
+
): Integration<EnphaseSnapshot, "enphase"> {
|
|
86
|
+
const clock = config.clock ?? realClock;
|
|
87
|
+
const where = relocator({
|
|
88
|
+
host: gatewayHost(config.host),
|
|
89
|
+
id: config.serial,
|
|
90
|
+
find: config.find,
|
|
91
|
+
clock,
|
|
92
|
+
cached: config.hostCached,
|
|
93
|
+
});
|
|
94
|
+
const client = enphaseClient({
|
|
95
|
+
...config,
|
|
96
|
+
get host() {
|
|
97
|
+
return where.host();
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
const poll = poller(clock);
|
|
101
|
+
return {
|
|
102
|
+
name: "enphase",
|
|
103
|
+
refresh: () => (where.reset(), poll.nudge()),
|
|
104
|
+
async run(signal, update) {
|
|
105
|
+
const expiresAt = tokenExpiry(config.token); // a bad token fails this integration only
|
|
106
|
+
let last: EnphaseSnapshot | undefined;
|
|
107
|
+
let day:
|
|
108
|
+
| {
|
|
109
|
+
date: string;
|
|
110
|
+
since: string;
|
|
111
|
+
production?: number;
|
|
112
|
+
consumption?: number;
|
|
113
|
+
peakW?: number;
|
|
114
|
+
}
|
|
115
|
+
| undefined;
|
|
116
|
+
let moved: string | undefined; // kept in `detail` so a consumer can persist the new address
|
|
117
|
+
while (!signal.aborted) {
|
|
118
|
+
const now = new Date(clock.now());
|
|
119
|
+
const date = localDay(now.getTime(), config.timeZone); // the home's day, not the runtime's: a bot on a UTC box still resets at local midnight
|
|
120
|
+
const tokenDaysLeft = daysLeft(expiresAt, now);
|
|
121
|
+
// The one owner of the login ask: in `detail` whether the read works or not, so it shows while the gateway is unreachable too.
|
|
122
|
+
const warn =
|
|
123
|
+
tokenDaysLeft < 0
|
|
124
|
+
? "token expired; run `home enphase login`"
|
|
125
|
+
: tokenDaysLeft < TOKEN_WARN_DAYS
|
|
126
|
+
? `token expires in ${tokenDaysLeft} days; run \`home enphase login\``
|
|
127
|
+
: undefined;
|
|
128
|
+
try {
|
|
129
|
+
const s = await client.read(signal);
|
|
130
|
+
if (day?.date !== date)
|
|
131
|
+
day = {
|
|
132
|
+
date,
|
|
133
|
+
since: now.toISOString(),
|
|
134
|
+
production: s.productionWhLifetime,
|
|
135
|
+
consumption: s.consumptionWhLifetime,
|
|
136
|
+
};
|
|
137
|
+
if (s.productionW !== undefined)
|
|
138
|
+
day.peakW = Math.max(day.peakW ?? s.productionW, s.productionW);
|
|
139
|
+
const since = (lifetime?: number, start?: number) =>
|
|
140
|
+
lifetime !== undefined && start !== undefined
|
|
141
|
+
? lifetime - start
|
|
142
|
+
: undefined;
|
|
143
|
+
last = {
|
|
144
|
+
...s,
|
|
145
|
+
productionWhToday:
|
|
146
|
+
s.productionWhToday ??
|
|
147
|
+
since(s.productionWhLifetime, day.production),
|
|
148
|
+
consumptionWhToday:
|
|
149
|
+
s.consumptionWhToday ??
|
|
150
|
+
since(s.consumptionWhLifetime, day.consumption),
|
|
151
|
+
todaySince:
|
|
152
|
+
s.productionWhToday === undefined ? day.since : undefined,
|
|
153
|
+
productionWPeakToday: day.peakW,
|
|
154
|
+
peakSince: day.since,
|
|
155
|
+
asOf: now.toISOString(),
|
|
156
|
+
tokenDaysLeft,
|
|
157
|
+
};
|
|
158
|
+
where.ok();
|
|
159
|
+
config.seen?.(where.host());
|
|
160
|
+
update({
|
|
161
|
+
status: "connected",
|
|
162
|
+
detail: [moved, warn].filter(Boolean).join("; ") || undefined,
|
|
163
|
+
anomaly: s.anomaly,
|
|
164
|
+
state: last,
|
|
165
|
+
});
|
|
166
|
+
} catch (error) {
|
|
167
|
+
if (signal.aborted) return; // stopped mid-read: not a failure
|
|
168
|
+
if (error instanceof EnphaseError && error.status === 401)
|
|
169
|
+
return update({
|
|
170
|
+
status: "unavailable",
|
|
171
|
+
detail:
|
|
172
|
+
"the gateway rejected the token; run `home enphase login`",
|
|
173
|
+
state: last,
|
|
174
|
+
});
|
|
175
|
+
const r = await where.failed(signal);
|
|
176
|
+
if (r.moved) {
|
|
177
|
+
moved = r.moved;
|
|
178
|
+
continue; // read at the new address now; that read reports, with the move in `detail`
|
|
179
|
+
}
|
|
180
|
+
update({
|
|
181
|
+
status: "disconnected",
|
|
182
|
+
detail: [lostDetail(r, error), warn].filter(Boolean).join("; "),
|
|
183
|
+
state: last,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
await poll.wait(POLL_MS, signal);
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** "solar 2.1 kW, using 0.6 kW · token 300 days left". The login ask near the end is the integration's, in `detail`. */
|
|
193
|
+
export function enphaseText(s: EnphaseSnapshot): string {
|
|
194
|
+
const solar =
|
|
195
|
+
s.productionW === undefined
|
|
196
|
+
? ""
|
|
197
|
+
: [
|
|
198
|
+
`solar ${kw(s.productionW)}`,
|
|
199
|
+
s.consumptionW === undefined ? "" : `using ${kw(s.consumptionW)}`,
|
|
200
|
+
]
|
|
201
|
+
.filter(Boolean)
|
|
202
|
+
.join(", ");
|
|
203
|
+
const token =
|
|
204
|
+
s.tokenDaysLeft === undefined ? "" : `token ${s.tokenDaysLeft} days left`;
|
|
205
|
+
return [solar, token].filter(Boolean).join(" · ");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export const manifest: Manifest<EnphaseSnapshot> = {
|
|
209
|
+
name: "enphase",
|
|
210
|
+
title: "Enphase IQ Gateway",
|
|
211
|
+
iotClass: "local_polling",
|
|
212
|
+
pollSeconds: POLL_MS / 1000,
|
|
213
|
+
access: "local HTTPS, owner token from `home enphase login`",
|
|
214
|
+
env: [
|
|
215
|
+
{
|
|
216
|
+
name: "IQ_GATEWAY_HOST",
|
|
217
|
+
example: "envoy.local",
|
|
218
|
+
what: "the IQ Gateway on the LAN (a scheme is tolerated)",
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
name: "IQ_GATEWAY_SERIAL_NUMBER",
|
|
222
|
+
example: "000000000000",
|
|
223
|
+
optional: true,
|
|
224
|
+
what: "for `home enphase login`; from the Enphase app: System > Devices > Gateway",
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
name: "ENPHASE_USERNAME",
|
|
228
|
+
example: "you@example.com",
|
|
229
|
+
optional: true,
|
|
230
|
+
what: "for `home enphase login`, once a year: the Enphase account",
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
name: "ENPHASE_PASSWORD",
|
|
234
|
+
example: "xxxxxxxx",
|
|
235
|
+
optional: true,
|
|
236
|
+
what: "for `home enphase login`; never stored, only sent to Enphase's login service",
|
|
237
|
+
},
|
|
238
|
+
],
|
|
239
|
+
files: ["token"],
|
|
240
|
+
lines: ({ state }) => (state ? [enphaseText(state)] : []),
|
|
241
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
// Shapes from Enphase's TEB-00060 and pyenphase's recorded gateway replies (MIT, software 7 and 8).
|
|
4
|
+
// Loose objects, optional fields: a firmware change degrades a reading, it does not throw.
|
|
5
|
+
|
|
6
|
+
/** One measurement in `/production.json`: an `inverters` total or an `eim` meter reading. */
|
|
7
|
+
export const Measurement = z.looseObject({
|
|
8
|
+
type: z.string(), // "inverters" | "eim" | "acb" (battery)
|
|
9
|
+
measurementType: z.string().optional(), // eim: "production" | "total-consumption" | "net-consumption"
|
|
10
|
+
activeCount: z.number().optional(), // 0 means the meter is not really measuring
|
|
11
|
+
wNow: z.number().optional(),
|
|
12
|
+
whToday: z.number().optional(),
|
|
13
|
+
whLifetime: z.number().optional(),
|
|
14
|
+
readingTime: z.number().optional(), // unix seconds
|
|
15
|
+
});
|
|
16
|
+
export type Measurement = z.infer<typeof Measurement>;
|
|
17
|
+
|
|
18
|
+
/** `GET /production.json?details=1`. */
|
|
19
|
+
export const Production = z.looseObject({
|
|
20
|
+
production: z.array(Measurement).default([]),
|
|
21
|
+
consumption: z.array(Measurement).default([]),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/** `GET /ivp/meters`: which current transformers exist and whether they are switched on. */
|
|
25
|
+
export const Meters = z.array(
|
|
26
|
+
z.looseObject({
|
|
27
|
+
eid: z.number().optional(),
|
|
28
|
+
measurementType: z.string(),
|
|
29
|
+
state: z.string(),
|
|
30
|
+
}),
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
/** `GET /ivp/meters/readings`: one reading per meter, joined to `/ivp/meters` by `eid`. */
|
|
34
|
+
export const Readings = z.array(
|
|
35
|
+
z.looseObject({
|
|
36
|
+
eid: z.number(),
|
|
37
|
+
timestamp: z.number().optional(), // unix seconds
|
|
38
|
+
activePower: z.number().optional(), // W: production, or grid import (negative = export) on a net meter
|
|
39
|
+
actEnergyDlvd: z.number().optional(), // Wh lifetime: produced, or imported on a net meter
|
|
40
|
+
actEnergyRcvd: z.number().optional(), // Wh lifetime: exported on a net meter
|
|
41
|
+
}),
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
/** The JSON from Enphase's login: only the session id is used. */
|
|
45
|
+
export const Login = z.looseObject({ session_id: z.string() });
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Hue
|
|
2
|
+
|
|
3
|
+
Philips Hue over the bridge's local CLIP v2 API: HTTPS on the LAN, one key, one event stream. Square (v2) bridges only. `iot_class`: **local_push**.
|
|
4
|
+
|
|
5
|
+
## Official docs
|
|
6
|
+
|
|
7
|
+
Both pages need a free Hue developer login.
|
|
8
|
+
|
|
9
|
+
- [Getting started](https://developers.meethue.com/develop/hue-api-v2/getting-started/): pairing (`POST /api` with `devicetype` and `generateclientkey`), the `hue-application-key` header, the resource URLs.
|
|
10
|
+
- [Core concepts](https://developers.meethue.com/develop/hue-api-v2/core-concepts/): the resource model (devices own services; rooms and zones group them; `grouped_light`, `bridge_home`), the event stream, and the rate guidance.
|
|
11
|
+
- [Using HTTPS](https://developers.meethue.com/develop/application-design-guidance/using-https/): the bridge certificate and Signify's root CA.
|
|
12
|
+
|
|
13
|
+
The schemas here were checked against [openhue-api](https://github.com/openhue/openhue-api) (Apache-2.0), an OpenAPI spec of CLIP v2, not against Signify's gated pages. Treat every field as unconfirmed from the primary source; all are optional, so a change degrades rather than throws.
|
|
14
|
+
|
|
15
|
+
## Pinning
|
|
16
|
+
|
|
17
|
+
The bridge serves only its leaf certificate, `CN=<bridge id>`, signed by Signify's `root-bridge` CA (valid to 2038-01-19). Trust that CA and nothing else, and check the CN equals your bridge id. Never turn verification off: the key goes out on every request.
|
|
18
|
+
|
|
19
|
+
- **Bun**: `bunPinnedFetch(bridgeId, ca)`, which `hue()` uses by default. Measured on Bun 1.4.2: 124 ms cold, 16 ms kept alive; a wrong bridge id is rejected.
|
|
20
|
+
- **Node**: `nodePinnedFetch(bridgeId, ca)` from `@khalido/home/node`, undici's `fetch` with an `Agent` holding `ca` and the same CN check. Checked on Node 24.20 and 26.11 against a local TLS server (2026-10-28), not yet a real bridge.
|
|
21
|
+
|
|
22
|
+
The CA's SHA-256 is `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`. It comes from a public mirror and passes `openssl verify` against a live BSB002; confirming it against the copy on the developer portal is still to do. It is not in the package yet: pass `ca` yourself (the CLI reads `HUE_CA_FILE`). The plan is a `HUE_BRIDGE_ROOT_CA` export here so pinning is one import.
|
|
23
|
+
|
|
24
|
+
## Pairing
|
|
25
|
+
|
|
26
|
+
`pair({ host, fetch, deviceType })` makes one `POST /api`. Until someone presses the bridge's link button it throws `HueError` with `code: "link_button"` (bridge error 101). It never retries in a loop; the caller decides how to prompt. Pairing happens once: the key it returns keeps working, and the library never stores it.
|
|
27
|
+
|
|
28
|
+
## Events
|
|
29
|
+
|
|
30
|
+
`GET /eventstream/clip/v2` with `Accept: text/event-stream`, read through `fetch` (the `EventSource` constructor takes no headers). Each message has `id: <unix seconds>:0` and `data:` holding an array of batches, each `add`, `update`, `delete` or `error`. Updates carry only changed fields, so `reduce` merges them. The bridge keeps a short buffer and does not say when you fell out of it, so after every reconnect `hue()` reads everything again. Reconnects back off 1 s, 5 s, 15 s, then every 60 s. A watchdog reconnects a connection that has sent no bytes, events or comment lines, for 90 s (`IDLE_MS`, aiohue's figure), so a silent half-close does not look connected forever. The bridge sends nothing on its own (aiohue source), so a quiet 90 s costs one silent reconnect: at once, with no `disconnected` and no step up the backoff, then the usual full read (one request; 16 ms kept alive, Bun 1.4.2, as measured under Pinning). Only a reconnect that fails is reported. So a bridge that accepts the stream and then never sends anything reconnects every 90 s, silently, for as long as that lasts. The alternative is aiohue's: it provokes traffic by writing a geofence client to the bridge every 60 s. That is a write to the bridge KO has not asked for, so it is not done; it is KO's call to switch. After three failed reconnects in a row, `hue({ find })` looks for the bridge by `bridgeId` (mDNS, two passes) and follows it to a new address, saying "moved from a to b" in `detail`, as Daikin and Enphase do; `loadHome` passes `finders.hue`.
|
|
31
|
+
|
|
32
|
+
Unconfirmed: whether the bridge resumes from `Last-Event-ID`; how many streams one bridge allows.
|
|
33
|
+
|
|
34
|
+
## Rates
|
|
35
|
+
|
|
36
|
+
Two write lanes per client (`src/lane.ts`). `grouped_light` and `scene` writes go out 1 s apart, `light` writes 100 ms apart, each lane in order. A write to a resource whose last write is still waiting merges into it (`mergeBody`, the newer field winning), so a burst cannot build a backlog; the merged write keeps the first one's place in the lane. Unverified: which the bridge applies when a merge carries both `color_temperature` and `color`. That is Signify's guidance as secondary sources quote it (about 10 commands a second to lights, 1 a second to groups), unconfirmed from the gated page. Prefer one `grouped_light` or scene write over many light writes: the bridge sends it to every light at once.
|
|
37
|
+
|
|
38
|
+
## Sources
|
|
39
|
+
|
|
40
|
+
What this folder learned from, and on what terms. No code was copied.
|
|
41
|
+
|
|
42
|
+
| Source | Licence | Used for |
|
|
43
|
+
| ---------------------------------------------------------------------------------------------------------------- | ---------------------------- | ------------------------------------ |
|
|
44
|
+
| [Signify's Hue API docs](https://developers.meethue.com/develop/hue-api-v2/) | Developer terms, login-gated | the API itself |
|
|
45
|
+
| [openhue/openhue-api](https://github.com/openhue/openhue-api) | Apache-2.0 | resource and event-stream shapes |
|
|
46
|
+
| [Home Assistant `hue` integration](https://github.com/home-assistant/core/tree/dev/homeassistant/components/hue) | Apache-2.0 | how HA wires aiohue into entities |
|
|
47
|
+
| [home-assistant-libs/aiohue](https://github.com/home-assistant-libs/aiohue) | Apache-2.0 | reconnect behaviour |
|
|
48
|
+
| [jash90/lumen-desktop](https://github.com/jash90/lumen-desktop) | MIT | the public copy of Signify's root CA |
|
|
49
|
+
| [jdmg94/Hue-Sync](https://github.com/jdmg94/Hue-Sync) | Apache-2.0 | resource types, cross-checked |
|
|
50
|
+
| [vchlum/smart-home](https://github.com/vchlum/smart-home) | MIT | a compact CLIP v2 event loop |
|