@khalido/home 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/.env.example +25 -0
  2. package/CHANGELOG.md +47 -0
  3. package/LICENSE +21 -0
  4. package/README.md +77 -2
  5. package/dist/clock.d.ts +17 -0
  6. package/dist/clock.js +43 -0
  7. package/dist/config.d.ts +273 -0
  8. package/dist/config.js +392 -0
  9. package/dist/connector.d.ts +34 -0
  10. package/dist/connector.js +47 -0
  11. package/dist/connectors/amber/client.d.ts +61 -0
  12. package/dist/connectors/amber/client.js +106 -0
  13. package/dist/connectors/amber/index.d.ts +30 -0
  14. package/dist/connectors/amber/index.js +94 -0
  15. package/dist/connectors/cast/codec.d.ts +38 -0
  16. package/dist/connectors/cast/codec.js +102 -0
  17. package/dist/connectors/cast/index.d.ts +60 -0
  18. package/dist/connectors/cast/index.js +428 -0
  19. package/dist/connectors/cast/schemas.d.ts +167 -0
  20. package/dist/connectors/cast/schemas.js +87 -0
  21. package/dist/connectors/cast/serve.d.ts +18 -0
  22. package/dist/connectors/cast/serve.js +57 -0
  23. package/dist/connectors/cast/state.d.ts +62 -0
  24. package/dist/connectors/cast/state.js +133 -0
  25. package/dist/connectors/cast/tls.d.ts +3 -0
  26. package/dist/connectors/cast/tls.js +67 -0
  27. package/dist/connectors/cast/writes.d.ts +80 -0
  28. package/dist/connectors/cast/writes.js +390 -0
  29. package/dist/connectors/daikin/client.d.ts +65 -0
  30. package/dist/connectors/daikin/client.js +221 -0
  31. package/dist/connectors/daikin/index.d.ts +41 -0
  32. package/dist/connectors/daikin/index.js +108 -0
  33. package/dist/connectors/enphase/client.d.ts +65 -0
  34. package/dist/connectors/enphase/client.js +173 -0
  35. package/dist/connectors/enphase/index.d.ts +43 -0
  36. package/dist/connectors/enphase/index.js +174 -0
  37. package/dist/connectors/enphase/schemas.d.ts +51 -0
  38. package/dist/connectors/enphase/schemas.js +34 -0
  39. package/dist/connectors/hue/client.d.ts +48 -0
  40. package/dist/connectors/hue/client.js +95 -0
  41. package/dist/connectors/hue/events.d.ts +41 -0
  42. package/dist/connectors/hue/events.js +156 -0
  43. package/dist/connectors/hue/index.d.ts +42 -0
  44. package/dist/connectors/hue/index.js +205 -0
  45. package/dist/connectors/hue/schemas.d.ts +65 -0
  46. package/dist/connectors/hue/schemas.js +48 -0
  47. package/dist/connectors/hue/state.d.ts +43 -0
  48. package/dist/connectors/hue/state.js +84 -0
  49. package/dist/connectors/printers/client.d.ts +28 -0
  50. package/dist/connectors/printers/client.js +74 -0
  51. package/dist/connectors/printers/codec.d.ts +76 -0
  52. package/dist/connectors/printers/codec.js +246 -0
  53. package/dist/connectors/printers/found.d.ts +15 -0
  54. package/dist/connectors/printers/found.js +35 -0
  55. package/dist/connectors/printers/index.d.ts +52 -0
  56. package/dist/connectors/printers/index.js +282 -0
  57. package/dist/connectors/printers/state.d.ts +60 -0
  58. package/dist/connectors/printers/state.js +99 -0
  59. package/dist/connectors/spotify/changes.d.ts +32 -0
  60. package/dist/connectors/spotify/changes.js +84 -0
  61. package/dist/connectors/spotify/client.d.ts +199 -0
  62. package/dist/connectors/spotify/client.js +270 -0
  63. package/dist/connectors/spotify/index.d.ts +92 -0
  64. package/dist/connectors/spotify/index.js +754 -0
  65. package/dist/connectors/up/client.d.ts +183 -0
  66. package/dist/connectors/up/client.js +308 -0
  67. package/dist/connectors/up/index.d.ts +91 -0
  68. package/dist/connectors/up/index.js +213 -0
  69. package/dist/connectors/up/spend.d.ts +78 -0
  70. package/dist/connectors/up/spend.js +213 -0
  71. package/dist/connectors/up/webhook.d.ts +13 -0
  72. package/dist/connectors/up/webhook.js +32 -0
  73. package/dist/connectors/weather/blocks.d.ts +51 -0
  74. package/dist/connectors/weather/blocks.js +106 -0
  75. package/dist/connectors/weather/bom.d.ts +37 -0
  76. package/dist/connectors/weather/bom.js +237 -0
  77. package/dist/connectors/weather/ftp.d.ts +5 -0
  78. package/dist/connectors/weather/ftp.js +46 -0
  79. package/dist/connectors/weather/google.d.ts +31 -0
  80. package/dist/connectors/weather/google.js +364 -0
  81. package/dist/connectors/weather/index.d.ts +53 -0
  82. package/dist/connectors/weather/index.js +280 -0
  83. package/dist/connectors/weather/open-meteo.d.ts +15 -0
  84. package/dist/connectors/weather/open-meteo.js +285 -0
  85. package/dist/connectors/weather/rain.d.ts +19 -0
  86. package/dist/connectors/weather/rain.js +51 -0
  87. package/dist/connectors/weather/source.d.ts +149 -0
  88. package/dist/connectors/weather/source.js +77 -0
  89. package/dist/connectors/weather/sun.d.ts +48 -0
  90. package/dist/connectors/weather/sun.js +65 -0
  91. package/dist/discover.d.ts +197 -0
  92. package/dist/discover.js +452 -0
  93. package/dist/energy.d.ts +61 -0
  94. package/dist/energy.js +162 -0
  95. package/dist/finders-entry.d.ts +1 -0
  96. package/dist/finders-entry.js +2 -0
  97. package/dist/finders.d.ts +48 -0
  98. package/dist/finders.js +176 -0
  99. package/dist/health.d.ts +28 -0
  100. package/dist/health.js +35 -0
  101. package/dist/home.d.ts +81 -0
  102. package/dist/home.js +114 -0
  103. package/dist/index.d.ts +11 -0
  104. package/dist/index.js +11 -0
  105. package/dist/lane.d.ts +17 -0
  106. package/dist/lane.js +74 -0
  107. package/dist/locate.d.ts +27 -0
  108. package/dist/locate.js +59 -0
  109. package/dist/manifest.d.ts +31 -0
  110. package/dist/manifest.js +4 -0
  111. package/dist/manifests.d.ts +5 -0
  112. package/dist/manifests.js +23 -0
  113. package/dist/node-fetch.d.ts +9 -0
  114. package/dist/node-fetch.js +25 -0
  115. package/dist/poll.d.ts +10 -0
  116. package/dist/poll.js +33 -0
  117. package/docs/cast.md +133 -0
  118. package/docs/cli.md +41 -0
  119. package/docs/config.md +36 -0
  120. package/docs/decisions.md +150 -0
  121. package/docs/devices.md +128 -0
  122. package/docs/enphase-notes.md +24 -0
  123. package/docs/google.md +92 -0
  124. package/docs/ideas.md +69 -0
  125. package/docs/matter.md +55 -0
  126. package/docs/performance.md +12 -0
  127. package/docs/philosophy.md +23 -0
  128. package/docs/up.md +84 -0
  129. package/docs/using.md +65 -0
  130. package/docs/weather-probe.md +63 -0
  131. package/examples/home.jsonc +74 -0
  132. package/examples/home.svg +76 -0
  133. package/home.schema.json +474 -0
  134. package/llms.txt +32 -0
  135. package/package.json +143 -4
  136. package/skills/draw-home/README.md +25 -0
  137. package/skills/draw-home/SKILL.md +80 -0
  138. package/skills/use-home/SKILL.md +86 -0
  139. package/src/cli/cache.ts +29 -0
  140. package/src/cli/cast.ts +172 -0
  141. package/src/cli/config.ts +52 -0
  142. package/src/cli/context.ts +118 -0
  143. package/src/cli/discover.ts +182 -0
  144. package/src/cli/enphase.ts +62 -0
  145. package/src/cli/hue.ts +124 -0
  146. package/src/cli/locate.ts +47 -0
  147. package/src/cli/main.ts +182 -0
  148. package/src/cli/printers.ts +21 -0
  149. package/src/cli/probe.ts +274 -0
  150. package/src/cli/spotify.ts +76 -0
  151. package/src/cli/status.ts +228 -0
  152. package/src/cli/up.ts +350 -0
  153. package/src/clock.ts +62 -0
  154. package/src/config.ts +578 -0
  155. package/src/connector.ts +77 -0
  156. package/src/connectors/amber/README.md +40 -0
  157. package/src/connectors/amber/client.ts +157 -0
  158. package/src/connectors/amber/index.ts +118 -0
  159. package/src/connectors/cast/README.md +44 -0
  160. package/src/connectors/cast/codec.ts +126 -0
  161. package/src/connectors/cast/index.ts +552 -0
  162. package/src/connectors/cast/schemas.ts +101 -0
  163. package/src/connectors/cast/serve.ts +82 -0
  164. package/src/connectors/cast/state.ts +203 -0
  165. package/src/connectors/cast/tls.ts +100 -0
  166. package/src/connectors/cast/writes.ts +572 -0
  167. package/src/connectors/daikin/README.md +78 -0
  168. package/src/connectors/daikin/client.ts +295 -0
  169. package/src/connectors/daikin/index.ts +145 -0
  170. package/src/connectors/enphase/README.md +64 -0
  171. package/src/connectors/enphase/client.ts +244 -0
  172. package/src/connectors/enphase/index.ts +241 -0
  173. package/src/connectors/enphase/schemas.ts +45 -0
  174. package/src/connectors/hue/README.md +50 -0
  175. package/src/connectors/hue/client.ts +157 -0
  176. package/src/connectors/hue/events.ts +182 -0
  177. package/src/connectors/hue/index.ts +261 -0
  178. package/src/connectors/hue/schemas.ts +60 -0
  179. package/src/connectors/hue/state.ts +124 -0
  180. package/src/connectors/printers/README.md +46 -0
  181. package/src/connectors/printers/client.ts +114 -0
  182. package/src/connectors/printers/codec.ts +292 -0
  183. package/src/connectors/printers/found.ts +46 -0
  184. package/src/connectors/printers/index.ts +385 -0
  185. package/src/connectors/printers/state.ts +182 -0
  186. package/src/connectors/spotify/README.md +83 -0
  187. package/src/connectors/spotify/changes.ts +127 -0
  188. package/src/connectors/spotify/client.ts +387 -0
  189. package/src/connectors/spotify/index.ts +937 -0
  190. package/src/connectors/up/README.md +35 -0
  191. package/src/connectors/up/client.ts +518 -0
  192. package/src/connectors/up/index.ts +370 -0
  193. package/src/connectors/up/spend.ts +280 -0
  194. package/src/connectors/up/webhook.ts +62 -0
  195. package/src/connectors/weather/README.md +84 -0
  196. package/src/connectors/weather/blocks.ts +173 -0
  197. package/src/connectors/weather/bom.ts +344 -0
  198. package/src/connectors/weather/ftp.ts +48 -0
  199. package/src/connectors/weather/google.ts +425 -0
  200. package/src/connectors/weather/index.ts +396 -0
  201. package/src/connectors/weather/open-meteo.ts +309 -0
  202. package/src/connectors/weather/rain.ts +73 -0
  203. package/src/connectors/weather/source.ts +236 -0
  204. package/src/connectors/weather/sun.ts +114 -0
  205. package/src/discover.ts +638 -0
  206. package/src/energy.ts +221 -0
  207. package/src/finders-entry.ts +2 -0
  208. package/src/finders.ts +250 -0
  209. package/src/health.ts +74 -0
  210. package/src/home.ts +205 -0
  211. package/src/index.ts +62 -0
  212. package/src/lane.ts +110 -0
  213. package/src/locate.ts +85 -0
  214. package/src/manifest.ts +43 -0
  215. package/src/manifests.ts +27 -0
  216. package/src/node-fetch.ts +41 -0
  217. package/src/poll.ts +33 -0
@@ -0,0 +1,41 @@
1
+ import { type Clock } from "../../clock.ts";
2
+ import { z } from "zod";
3
+ import type { Integration } from "../../home.ts";
4
+ import { type Finder } from "../../discover.ts";
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";
8
+ /** Poll every 120 s, as Home Assistant's daikin integration does (its coordinator's update_interval). */
9
+ export declare const POLL_MS = 120000;
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. */
11
+ export declare const configSchema: z.ZodObject<{
12
+ connector: z.ZodLiteral<"daikin">;
13
+ host: z.ZodString;
14
+ mac: z.ZodOptional<z.ZodString>;
15
+ }, z.core.$loose>;
16
+ /** Daikin settings: its last known address, its MAC and a UDP discovery to find it again if it moves (both optional), and a `fetch`. */
17
+ export type DaikinConfig = {
18
+ host: string;
19
+ mac?: string;
20
+ find?: Finder;
21
+ fetch: typeof fetch;
22
+ clock?: Clock;
23
+ seen?: (host: string) => void;
24
+ hostCached?: boolean;
25
+ };
26
+ /** What the snapshot holds: the latest reading and when it was taken. */
27
+ export type DaikinSnapshot = DaikinState & {
28
+ asOf: string;
29
+ };
30
+ /** What `daikin().client` is: the reads and writes of the unit, at wherever it is now. */
31
+ export type DaikinClient = ReturnType<typeof daikinClient>;
32
+ /**
33
+ * One Daikin AC as a home integration: one read now, then every 120 s. Its writes are `client.write(setPower(true))`: one
34
+ * client for the integration's life, so the writes share its lane and follow the unit when it moves.
35
+ */
36
+ export declare function daikin(config: DaikinConfig): Integration<DaikinSnapshot, "daikin"> & {
37
+ readonly client: DaikinClient;
38
+ };
39
+ /** The AC in a few words: "off · cool 25 °C · room 24 °C · outdoor unit 19.5 °C". */
40
+ export declare function acText(s: DaikinState): string;
41
+ export declare const manifest: Manifest<DaikinSnapshot>;
@@ -0,0 +1,108 @@
1
+ import { asOf, realClock } from "../../clock.js";
2
+ import { z } from "zod";
3
+ import { lostDetail, relocator } from "../../discover.js";
4
+ import { poller } from "../../poll.js";
5
+ import { daikinClient, DaikinError } from "./client.js";
6
+ export { DaikinError, daikinClient, setFan, setMode, setPower, setTarget, } from "./client.js";
7
+ /** Poll every 120 s, as Home Assistant's daikin integration does (its coordinator's update_interval). */
8
+ export const POLL_MS = 120_000;
9
+ /** A Daikin AC in a home config: its last known address, and the MAC its UDP discovery reports, which is how it is recognised. */
10
+ export const configSchema = z.looseObject({
11
+ connector: z.literal("daikin"),
12
+ host: z.string().describe("the unit's last known address"),
13
+ mac: z
14
+ .string()
15
+ .optional()
16
+ .describe("the unit's MAC as its UDP discovery reports it: its identity"),
17
+ });
18
+ /**
19
+ * One Daikin AC as a home integration: one read now, then every 120 s. Its writes are `client.write(setPower(true))`: one
20
+ * client for the integration's life, so the writes share its lane and follow the unit when it moves.
21
+ */
22
+ export function daikin(config) {
23
+ const clock = config.clock ?? realClock;
24
+ const where = relocator({
25
+ host: config.host,
26
+ id: config.mac,
27
+ find: config.find,
28
+ clock,
29
+ cached: config.hostCached,
30
+ });
31
+ const client = daikinClient({
32
+ ...config,
33
+ get host() {
34
+ return where.host();
35
+ },
36
+ });
37
+ const poll = poller(clock);
38
+ return {
39
+ name: "daikin",
40
+ client,
41
+ refresh: () => (where.reset(), poll.nudge()),
42
+ async run(signal, update) {
43
+ let last;
44
+ let moved; // kept in `detail` so a consumer can persist the new address
45
+ while (!signal.aborted) {
46
+ try {
47
+ last = { ...(await client.read(signal)), asOf: asOf(clock) };
48
+ where.ok();
49
+ config.seen?.(where.host());
50
+ update({
51
+ status: "connected",
52
+ detail: moved,
53
+ anomaly: last.anomaly,
54
+ state: last,
55
+ });
56
+ }
57
+ catch (error) {
58
+ if (signal.aborted)
59
+ return; // stopped mid-read: not a failure
60
+ // A 404 is a failed read like any other: the address may now be another device's, so discovery looks for the unit.
61
+ const why = error instanceof DaikinError && error.status === 404
62
+ ? "no /dsiot/multireq at this address: not a dsiot Daikin"
63
+ : error;
64
+ const r = await where.failed(signal);
65
+ if (r.moved) {
66
+ moved = r.moved;
67
+ continue; // read at the new address now; that read reports, with the move in `detail`
68
+ }
69
+ update({
70
+ status: "disconnected",
71
+ detail: lostDetail(r, why),
72
+ state: last,
73
+ });
74
+ }
75
+ await poll.wait(POLL_MS, signal);
76
+ }
77
+ },
78
+ };
79
+ }
80
+ /** The AC in a few words: "off · cool 25 °C · room 24 °C · outdoor unit 19.5 °C". */
81
+ export function acText(s) {
82
+ const setting = [s.mode, s.targetC === undefined ? "" : `${s.targetC} °C`]
83
+ .filter(Boolean)
84
+ .join(" ");
85
+ return [
86
+ s.on === undefined ? "" : s.on ? "on" : "off",
87
+ setting,
88
+ s.roomC === undefined ? "" : `room ${s.roomC} °C`,
89
+ s.outsideC === undefined ? "" : `outdoor unit ${s.outsideC} °C`,
90
+ ]
91
+ .filter(Boolean)
92
+ .join(" · ");
93
+ }
94
+ export const manifest = {
95
+ name: "daikin",
96
+ title: "Daikin",
97
+ iotClass: "local_polling",
98
+ pollSeconds: POLL_MS / 1000,
99
+ access: "local HTTP JSON (dsiot), no key",
100
+ env: [
101
+ {
102
+ name: "DAIKIN_HOST",
103
+ example: "192.0.2.20",
104
+ what: "the unit's address; `home probe` finds it by UDP broadcast",
105
+ },
106
+ ],
107
+ lines: ({ state }) => (state ? [acText(state)] : []),
108
+ };
@@ -0,0 +1,65 @@
1
+ import { ConnectorError } from "../../connector.ts";
2
+ /** A failed Enphase call. `status` 401 means the token is wrong or expired. */
3
+ export declare class EnphaseError extends ConnectorError {
4
+ }
5
+ /** Solar right now and today. Watts and watt-hours as the gateway reports them, negatives included. */
6
+ export type EnphaseState = {
7
+ productionW?: number;
8
+ productionWhToday?: number;
9
+ productionWhLifetime?: number;
10
+ consumptionW?: number;
11
+ consumptionWhToday?: number;
12
+ consumptionWhLifetime?: number;
13
+ netW?: number;
14
+ source: "meter" | "inverters";
15
+ readingTime?: string;
16
+ anomaly?: string;
17
+ };
18
+ /**
19
+ * Map `/production.json` and `/ivp/meters` to a state. A meter counts only when `/ivp/meters` says
20
+ * "enabled" and its reading has `activeCount > 0` (KO's v1 code, pyenphase); else production falls back to the inverters.
21
+ */
22
+ export declare function toState(production: unknown, meters: unknown): EnphaseState;
23
+ /**
24
+ * Map `/ivp/meters` and `/ivp/meters/readings` to a state: ~100 ms on KO's gateway against 1–10 s for
25
+ * `/production.json` (measured, Bun 1.4.2). Consumption is production plus net import on a net-consumption meter,
26
+ * or read directly from a total-consumption meter (pyenphase's meter types). No daily totals here: callers count today.
27
+ */
28
+ export declare function fromReadings(meters: unknown, readings: unknown): EnphaseState;
29
+ /** What a gateway client needs: its LAN address, an owner token, and a `fetch` that accepts its certificate. */
30
+ export type EnphaseOptions = {
31
+ host: string;
32
+ token: string;
33
+ fetch: typeof fetch;
34
+ };
35
+ /** The gateway's address as a bare host: a scheme prefix and a trailing slash, as people paste it, are dropped. */
36
+ export declare const gatewayHost: (host: string) => string;
37
+ /** A read-only client for the IQ Gateway's local API (software 7 and later). */
38
+ export declare function enphaseClient(options: EnphaseOptions): {
39
+ get: (path: string, signal?: AbortSignal) => Promise<unknown>;
40
+ /** Production, consumption and net now: the fast meter readings when a production meter is on, else `/production.json`. */
41
+ read(signal?: AbortSignal): Promise<EnphaseState>;
42
+ };
43
+ /** When a JWT expires, from its `exp` claim. Throws on anything that is not a JWT. */
44
+ export declare function tokenExpiry(token: string): Date;
45
+ /** Whole days until `expiresAt`, negative once expired. */
46
+ export declare const daysLeft: (expiresAt: Date, now: Date) => number;
47
+ /**
48
+ * Mint an owner token as Enphase's TEB-00060 shows: log in for a session id, then ask Entrez for the gateway's token.
49
+ * Two calls to Enphase's cloud; nothing is stored here.
50
+ */
51
+ export declare function mintToken(options: {
52
+ username: string;
53
+ password: string;
54
+ serial: string;
55
+ fetch: typeof fetch;
56
+ }): Promise<{
57
+ token: string;
58
+ expiresAt: Date;
59
+ }>;
60
+ /**
61
+ * A `fetch` for the gateway that skips certificate verification, for this gateway's requests only (Bun's `tls` option).
62
+ * The gateway's chain ends at Enphase's unpublished `root-a.PKI.CA`, and Bun 1.4.2 neither trusts a partial chain nor
63
+ * runs `checkServerIdentity` with verification off, so it cannot pin (measured). Reserve the gateway's IP. Node: see README.
64
+ */
65
+ export declare function bunGatewayFetch(): typeof fetch;
@@ -0,0 +1,173 @@
1
+ import { z } from "zod";
2
+ import { ConnectorError, fetchTimeout, getJson } from "../../connector.js";
3
+ import { Login, Meters, Production, Readings, } from "./schemas.js";
4
+ /** A failed Enphase call. `status` 401 means the token is wrong or expired. */
5
+ export class EnphaseError extends ConnectorError {
6
+ }
7
+ /** 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. */
8
+ const today = (m) => m?.whToday !== undefined && m.whToday !== m.whLifetime
9
+ ? m.whToday
10
+ : undefined;
11
+ const find = (list, type, measurementType) => list.find((m) => m.type === type &&
12
+ (measurementType === undefined || m.measurementType === measurementType));
13
+ const live = (m) => m && (m.activeCount ?? 0) > 0 ? m : undefined;
14
+ /**
15
+ * Map `/production.json` and `/ivp/meters` to a state. A meter counts only when `/ivp/meters` says
16
+ * "enabled" and its reading has `activeCount > 0` (KO's v1 code, pyenphase); else production falls back to the inverters.
17
+ */
18
+ export function toState(production, meters) {
19
+ const p = Production.parse(production);
20
+ const enabled = new Set(Meters.parse(meters)
21
+ .filter((m) => m.state === "enabled")
22
+ .map((m) => m.measurementType));
23
+ const prodMeter = enabled.has("production")
24
+ ? live(find(p.production, "eim", "production"))
25
+ : undefined;
26
+ const inverters = find(p.production, "inverters");
27
+ const hasConsumption = enabled.has("total-consumption") || enabled.has("net-consumption");
28
+ const total = hasConsumption
29
+ ? live(find(p.consumption, "eim", "total-consumption"))
30
+ : undefined;
31
+ const net = hasConsumption
32
+ ? live(find(p.consumption, "eim", "net-consumption"))
33
+ : undefined;
34
+ const at = (prodMeter ?? inverters)?.readingTime;
35
+ const broken = [prodMeter, total].some((m) => m?.whToday !== undefined && m.whToday === m.whLifetime);
36
+ return {
37
+ productionW: (prodMeter ?? inverters)?.wNow,
38
+ productionWhToday: today(prodMeter),
39
+ productionWhLifetime: (prodMeter ?? inverters)?.whLifetime,
40
+ consumptionW: total?.wNow,
41
+ consumptionWhToday: today(total),
42
+ consumptionWhLifetime: total?.whLifetime,
43
+ ...(broken
44
+ ? {
45
+ anomaly: "the gateway's whToday equals its lifetime total; today is counted from the lifetime counter instead",
46
+ }
47
+ : {}),
48
+ netW: net?.wNow,
49
+ source: prodMeter ? "meter" : "inverters",
50
+ readingTime: at ? new Date(at * 1000).toISOString() : undefined,
51
+ };
52
+ }
53
+ /**
54
+ * Map `/ivp/meters` and `/ivp/meters/readings` to a state: ~100 ms on KO's gateway against 1–10 s for
55
+ * `/production.json` (measured, Bun 1.4.2). Consumption is production plus net import on a net-consumption meter,
56
+ * or read directly from a total-consumption meter (pyenphase's meter types). No daily totals here: callers count today.
57
+ */
58
+ export function fromReadings(meters, readings) {
59
+ const enabled = Meters.parse(meters).filter((m) => m.state === "enabled");
60
+ const byEid = new Map(Readings.parse(readings).map((r) => [r.eid, r]));
61
+ const reading = (type) => byEid.get(enabled.find((m) => m.measurementType === type)?.eid ?? -1);
62
+ const prod = reading("production");
63
+ const net = reading("net-consumption");
64
+ const total = reading("total-consumption");
65
+ const pW = prod?.activePower;
66
+ const consumptionW = total?.activePower ??
67
+ (net?.activePower !== undefined && pW !== undefined
68
+ ? pW + net.activePower
69
+ : undefined);
70
+ const pLife = prod?.actEnergyDlvd;
71
+ const consumptionWhLifetime = total?.actEnergyDlvd ??
72
+ (pLife !== undefined &&
73
+ net?.actEnergyDlvd !== undefined &&
74
+ net.actEnergyRcvd !== undefined
75
+ ? pLife + net.actEnergyDlvd - net.actEnergyRcvd
76
+ : undefined);
77
+ const at = prod?.timestamp;
78
+ return {
79
+ productionW: pW,
80
+ productionWhLifetime: pLife,
81
+ consumptionW,
82
+ consumptionWhLifetime,
83
+ netW: net?.activePower ??
84
+ (consumptionW !== undefined && pW !== undefined
85
+ ? consumptionW - pW
86
+ : undefined),
87
+ source: "meter",
88
+ readingTime: at ? new Date(at * 1000).toISOString() : undefined,
89
+ };
90
+ }
91
+ /** The gateway's address as a bare host: a scheme prefix and a trailing slash, as people paste it, are dropped. */
92
+ export const gatewayHost = (host) => host
93
+ .trim()
94
+ .replace(/^https?:\/\//i, "")
95
+ .replace(/\/+$/, "");
96
+ /** A read-only client for the IQ Gateway's local API (software 7 and later). */
97
+ export function enphaseClient(options) {
98
+ const headers = {
99
+ authorization: `Bearer ${options.token}`,
100
+ accept: "application/json",
101
+ };
102
+ // `host` is read at each call, so a getter follows a move.
103
+ const get = (path, signal) => getJson(options.fetch, `https://${gatewayHost(options.host)}${path}`, {
104
+ init: { headers },
105
+ signal,
106
+ error: EnphaseError,
107
+ });
108
+ return {
109
+ get,
110
+ /** Production, consumption and net now: the fast meter readings when a production meter is on, else `/production.json`. */
111
+ async read(signal) {
112
+ const meters = await get("/ivp/meters", signal);
113
+ const metered = Meters.parse(meters).some((m) => m.measurementType === "production" && m.state === "enabled");
114
+ return metered
115
+ ? fromReadings(meters, await get("/ivp/meters/readings", signal))
116
+ : toState(await get("/production.json?details=1", signal), meters);
117
+ },
118
+ };
119
+ }
120
+ /** When a JWT expires, from its `exp` claim. Throws on anything that is not a JWT. */
121
+ export function tokenExpiry(token) {
122
+ const payload = token.split(".")[1];
123
+ if (!payload)
124
+ throw new EnphaseError("not a JWT");
125
+ const json = JSON.parse(atob(payload.replace(/-/g, "+").replace(/_/g, "/")));
126
+ const exp = z.object({ exp: z.number() }).parse(json).exp;
127
+ return new Date(exp * 1000);
128
+ }
129
+ /** Whole days until `expiresAt`, negative once expired. */
130
+ export const daysLeft = (expiresAt, now) => Math.floor((expiresAt.getTime() - now.getTime()) / 86_400_000);
131
+ /**
132
+ * Mint an owner token as Enphase's TEB-00060 shows: log in for a session id, then ask Entrez for the gateway's token.
133
+ * Two calls to Enphase's cloud; nothing is stored here.
134
+ */
135
+ export async function mintToken(options) {
136
+ const form = new URLSearchParams({
137
+ "user[email]": options.username,
138
+ "user[password]": options.password,
139
+ });
140
+ const login = await fetchTimeout(options.fetch, "https://enlighten.enphaseenergy.com/login/login.json", { method: "POST", body: form });
141
+ const session = Login.safeParse(await login.json().catch(() => undefined));
142
+ if (!login.ok || !session.success)
143
+ throw new EnphaseError(`Enphase login failed (HTTP ${login.status})`, {
144
+ status: login.status,
145
+ });
146
+ const res = await fetchTimeout(options.fetch, "https://entrez.enphaseenergy.com/tokens", {
147
+ method: "POST",
148
+ headers: { "content-type": "application/json" },
149
+ body: JSON.stringify({
150
+ session_id: session.data.session_id,
151
+ serial_num: options.serial,
152
+ username: options.username,
153
+ }),
154
+ });
155
+ const token = (await res.text()).trim();
156
+ if (!res.ok)
157
+ throw new EnphaseError(`Entrez refused the token (HTTP ${res.status})`, {
158
+ status: res.status,
159
+ });
160
+ return { token, expiresAt: tokenExpiry(token) };
161
+ }
162
+ /**
163
+ * A `fetch` for the gateway that skips certificate verification, for this gateway's requests only (Bun's `tls` option).
164
+ * The gateway's chain ends at Enphase's unpublished `root-a.PKI.CA`, and Bun 1.4.2 neither trusts a partial chain nor
165
+ * runs `checkServerIdentity` with verification off, so it cannot pin (measured). Reserve the gateway's IP. Node: see README.
166
+ */
167
+ export function bunGatewayFetch() {
168
+ const f = (input, init) => fetch(input, {
169
+ ...init,
170
+ tls: { rejectUnauthorized: false },
171
+ });
172
+ return f;
173
+ }
@@ -0,0 +1,43 @@
1
+ import { type Clock } from "../../clock.ts";
2
+ import { z } from "zod";
3
+ import { type Integration } from "../../home.ts";
4
+ import { type Finder } from "../../discover.ts";
5
+ import { type Manifest } from "../../manifest.ts";
6
+ import { type EnphaseState } from "./client.ts";
7
+ export { bunGatewayFetch, EnphaseError, type EnphaseOptions, type EnphaseState, enphaseClient, mintToken, tokenExpiry, } from "./client.ts";
8
+ /** Poll every 60 s, as Home Assistant's enphase_envoy does. */
9
+ export declare const POLL_MS = 60000;
10
+ /** An IQ Gateway in a home config: its last known address, its serial (its identity), and the owner token's file. */
11
+ export declare const configSchema: z.ZodObject<{
12
+ connector: z.ZodLiteral<"enphase">;
13
+ host: z.ZodString;
14
+ serial: z.ZodOptional<z.ZodString>;
15
+ token: z.ZodString;
16
+ marker: z.ZodOptional<z.ZodString>;
17
+ capacityW: z.ZodOptional<z.ZodNumber>;
18
+ }, z.core.$loose>;
19
+ /** 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). */
20
+ export type EnphaseConfig = {
21
+ host: string;
22
+ token: string;
23
+ serial?: string;
24
+ find?: Finder;
25
+ fetch: typeof fetch;
26
+ clock?: Clock;
27
+ timeZone?: string;
28
+ seen?: (host: string) => void;
29
+ hostCached?: boolean;
30
+ };
31
+ /** What the snapshot holds: the latest reading, when it was taken, and the token's days left. */
32
+ export type EnphaseSnapshot = EnphaseState & {
33
+ asOf: string;
34
+ tokenDaysLeft: number;
35
+ todaySince?: string;
36
+ productionWPeakToday?: number;
37
+ peakSince?: string;
38
+ };
39
+ /** The IQ Gateway as a home integration: one read now, then every 60 s. */
40
+ export declare function enphase(config: EnphaseConfig): Integration<EnphaseSnapshot, "enphase">;
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
+ export declare function enphaseText(s: EnphaseSnapshot): string;
43
+ export declare const manifest: Manifest<EnphaseSnapshot>;
@@ -0,0 +1,174 @@
1
+ import { localDay, realClock } from "../../clock.js";
2
+ import { z } from "zod";
3
+ import { filePath } from "../../home.js";
4
+ import { lostDetail, relocator } from "../../discover.js";
5
+ import { kw } from "../../manifest.js";
6
+ import { poller } from "../../poll.js";
7
+ import { daysLeft, enphaseClient, EnphaseError, gatewayHost, tokenExpiry, } from "./client.js";
8
+ export { bunGatewayFetch, EnphaseError, enphaseClient, mintToken, tokenExpiry, } from "./client.js";
9
+ /** Poll every 60 s, as Home Assistant's enphase_envoy does. */
10
+ export const POLL_MS = 60_000;
11
+ /** Warn when the token has fewer days left than this (Home Assistant's threshold). */
12
+ const TOKEN_WARN_DAYS = 30;
13
+ /** An IQ Gateway in a home config: its last known address, its serial (its identity), and the owner token's file. */
14
+ export const configSchema = z.looseObject({
15
+ connector: z.literal("enphase"),
16
+ host: z.string().describe("the gateway's last known address"),
17
+ serial: z
18
+ .string()
19
+ .optional()
20
+ .describe("the gateway's serial, as mDNS `serialnum` (and /info) report it: its identity"),
21
+ token: filePath.describe("the owner token file: JSON with a `token` field (what `home enphase login` writes), or the bare token"),
22
+ marker: z
23
+ .string()
24
+ .startsWith("marker-")
25
+ .optional()
26
+ .describe("the plan's marker where the solar array is drawn, on the roof outside any room"),
27
+ capacityW: z
28
+ .number()
29
+ .positive()
30
+ .optional()
31
+ .describe("the array's rated size in watts, from the installer's paperwork; never guessed from the gateway"),
32
+ });
33
+ /** The IQ Gateway as a home integration: one read now, then every 60 s. */
34
+ export function enphase(config) {
35
+ const clock = config.clock ?? realClock;
36
+ const where = relocator({
37
+ host: gatewayHost(config.host),
38
+ id: config.serial,
39
+ find: config.find,
40
+ clock,
41
+ cached: config.hostCached,
42
+ });
43
+ const client = enphaseClient({
44
+ ...config,
45
+ get host() {
46
+ return where.host();
47
+ },
48
+ });
49
+ const poll = poller(clock);
50
+ return {
51
+ name: "enphase",
52
+ refresh: () => (where.reset(), poll.nudge()),
53
+ async run(signal, update) {
54
+ const expiresAt = tokenExpiry(config.token); // a bad token fails this integration only
55
+ let last;
56
+ let day;
57
+ let moved; // kept in `detail` so a consumer can persist the new address
58
+ while (!signal.aborted) {
59
+ const now = new Date(clock.now());
60
+ 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
61
+ const tokenDaysLeft = daysLeft(expiresAt, now);
62
+ // The one owner of the login ask: in `detail` whether the read works or not, so it shows while the gateway is unreachable too.
63
+ const warn = tokenDaysLeft < 0
64
+ ? "token expired; run `home enphase login`"
65
+ : tokenDaysLeft < TOKEN_WARN_DAYS
66
+ ? `token expires in ${tokenDaysLeft} days; run \`home enphase login\``
67
+ : undefined;
68
+ try {
69
+ const s = await client.read(signal);
70
+ if (day?.date !== date)
71
+ day = {
72
+ date,
73
+ since: now.toISOString(),
74
+ production: s.productionWhLifetime,
75
+ consumption: s.consumptionWhLifetime,
76
+ };
77
+ if (s.productionW !== undefined)
78
+ day.peakW = Math.max(day.peakW ?? s.productionW, s.productionW);
79
+ const since = (lifetime, start) => lifetime !== undefined && start !== undefined
80
+ ? lifetime - start
81
+ : undefined;
82
+ last = {
83
+ ...s,
84
+ productionWhToday: s.productionWhToday ??
85
+ since(s.productionWhLifetime, day.production),
86
+ consumptionWhToday: s.consumptionWhToday ??
87
+ since(s.consumptionWhLifetime, day.consumption),
88
+ todaySince: s.productionWhToday === undefined ? day.since : undefined,
89
+ productionWPeakToday: day.peakW,
90
+ peakSince: day.since,
91
+ asOf: now.toISOString(),
92
+ tokenDaysLeft,
93
+ };
94
+ where.ok();
95
+ config.seen?.(where.host());
96
+ update({
97
+ status: "connected",
98
+ detail: [moved, warn].filter(Boolean).join("; ") || undefined,
99
+ anomaly: s.anomaly,
100
+ state: last,
101
+ });
102
+ }
103
+ catch (error) {
104
+ if (signal.aborted)
105
+ return; // stopped mid-read: not a failure
106
+ if (error instanceof EnphaseError && error.status === 401)
107
+ return update({
108
+ status: "unavailable",
109
+ detail: "the gateway rejected the token; run `home enphase login`",
110
+ state: last,
111
+ });
112
+ const r = await where.failed(signal);
113
+ if (r.moved) {
114
+ moved = r.moved;
115
+ continue; // read at the new address now; that read reports, with the move in `detail`
116
+ }
117
+ update({
118
+ status: "disconnected",
119
+ detail: [lostDetail(r, error), warn].filter(Boolean).join("; "),
120
+ state: last,
121
+ });
122
+ }
123
+ await poll.wait(POLL_MS, signal);
124
+ }
125
+ },
126
+ };
127
+ }
128
+ /** "solar 2.1 kW, using 0.6 kW · token 300 days left". The login ask near the end is the integration's, in `detail`. */
129
+ export function enphaseText(s) {
130
+ const solar = s.productionW === undefined
131
+ ? ""
132
+ : [
133
+ `solar ${kw(s.productionW)}`,
134
+ s.consumptionW === undefined ? "" : `using ${kw(s.consumptionW)}`,
135
+ ]
136
+ .filter(Boolean)
137
+ .join(", ");
138
+ const token = s.tokenDaysLeft === undefined ? "" : `token ${s.tokenDaysLeft} days left`;
139
+ return [solar, token].filter(Boolean).join(" · ");
140
+ }
141
+ export const manifest = {
142
+ name: "enphase",
143
+ title: "Enphase IQ Gateway",
144
+ iotClass: "local_polling",
145
+ pollSeconds: POLL_MS / 1000,
146
+ access: "local HTTPS, owner token from `home enphase login`",
147
+ env: [
148
+ {
149
+ name: "IQ_GATEWAY_HOST",
150
+ example: "envoy.local",
151
+ what: "the IQ Gateway on the LAN (a scheme is tolerated)",
152
+ },
153
+ {
154
+ name: "IQ_GATEWAY_SERIAL_NUMBER",
155
+ example: "000000000000",
156
+ optional: true,
157
+ what: "for `home enphase login`; from the Enphase app: System > Devices > Gateway",
158
+ },
159
+ {
160
+ name: "ENPHASE_USERNAME",
161
+ example: "you@example.com",
162
+ optional: true,
163
+ what: "for `home enphase login`, once a year: the Enphase account",
164
+ },
165
+ {
166
+ name: "ENPHASE_PASSWORD",
167
+ example: "xxxxxxxx",
168
+ optional: true,
169
+ what: "for `home enphase login`; never stored, only sent to Enphase's login service",
170
+ },
171
+ ],
172
+ files: ["token"],
173
+ lines: ({ state }) => (state ? [enphaseText(state)] : []),
174
+ };
@@ -0,0 +1,51 @@
1
+ import { z } from "zod";
2
+ /** One measurement in `/production.json`: an `inverters` total or an `eim` meter reading. */
3
+ export declare const Measurement: z.ZodObject<{
4
+ type: z.ZodString;
5
+ measurementType: z.ZodOptional<z.ZodString>;
6
+ activeCount: z.ZodOptional<z.ZodNumber>;
7
+ wNow: z.ZodOptional<z.ZodNumber>;
8
+ whToday: z.ZodOptional<z.ZodNumber>;
9
+ whLifetime: z.ZodOptional<z.ZodNumber>;
10
+ readingTime: z.ZodOptional<z.ZodNumber>;
11
+ }, z.core.$loose>;
12
+ export type Measurement = z.infer<typeof Measurement>;
13
+ /** `GET /production.json?details=1`. */
14
+ export declare const Production: z.ZodObject<{
15
+ production: z.ZodDefault<z.ZodArray<z.ZodObject<{
16
+ type: z.ZodString;
17
+ measurementType: z.ZodOptional<z.ZodString>;
18
+ activeCount: z.ZodOptional<z.ZodNumber>;
19
+ wNow: z.ZodOptional<z.ZodNumber>;
20
+ whToday: z.ZodOptional<z.ZodNumber>;
21
+ whLifetime: z.ZodOptional<z.ZodNumber>;
22
+ readingTime: z.ZodOptional<z.ZodNumber>;
23
+ }, z.core.$loose>>>;
24
+ consumption: z.ZodDefault<z.ZodArray<z.ZodObject<{
25
+ type: z.ZodString;
26
+ measurementType: z.ZodOptional<z.ZodString>;
27
+ activeCount: z.ZodOptional<z.ZodNumber>;
28
+ wNow: z.ZodOptional<z.ZodNumber>;
29
+ whToday: z.ZodOptional<z.ZodNumber>;
30
+ whLifetime: z.ZodOptional<z.ZodNumber>;
31
+ readingTime: z.ZodOptional<z.ZodNumber>;
32
+ }, z.core.$loose>>>;
33
+ }, z.core.$loose>;
34
+ /** `GET /ivp/meters`: which current transformers exist and whether they are switched on. */
35
+ export declare const Meters: z.ZodArray<z.ZodObject<{
36
+ eid: z.ZodOptional<z.ZodNumber>;
37
+ measurementType: z.ZodString;
38
+ state: z.ZodString;
39
+ }, z.core.$loose>>;
40
+ /** `GET /ivp/meters/readings`: one reading per meter, joined to `/ivp/meters` by `eid`. */
41
+ export declare const Readings: z.ZodArray<z.ZodObject<{
42
+ eid: z.ZodNumber;
43
+ timestamp: z.ZodOptional<z.ZodNumber>;
44
+ activePower: z.ZodOptional<z.ZodNumber>;
45
+ actEnergyDlvd: z.ZodOptional<z.ZodNumber>;
46
+ actEnergyRcvd: z.ZodOptional<z.ZodNumber>;
47
+ }, z.core.$loose>>;
48
+ /** The JSON from Enphase's login: only the session id is used. */
49
+ export declare const Login: z.ZodObject<{
50
+ session_id: z.ZodString;
51
+ }, z.core.$loose>;