@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.
Files changed (217) hide show
  1. package/.env.example +25 -0
  2. package/CHANGELOG.md +25 -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 +426 -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 +11 -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 +745 -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 +267 -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 +195 -0
  92. package/dist/discover.js +446 -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 +45 -0
  98. package/dist/finders.js +173 -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 +39 -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 +26 -0
  137. package/skills/draw-home/SKILL.md +82 -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 +175 -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 +43 -0
  160. package/src/connectors/cast/codec.ts +126 -0
  161. package/src/connectors/cast/index.ts +551 -0
  162. package/src/connectors/cast/schemas.ts +101 -0
  163. package/src/connectors/cast/serve.ts +72 -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 +80 -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 +929 -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 +380 -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 +634 -0
  206. package/src/energy.ts +221 -0
  207. package/src/finders-entry.ts +2 -0
  208. package/src/finders.ts +247 -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,34 @@
1
+ import { z } from "zod";
2
+ // Shapes from Enphase's TEB-00060 and pyenphase's recorded gateway replies (MIT, software 7 and 8).
3
+ // Loose objects, optional fields: a firmware change degrades a reading, it does not throw.
4
+ /** One measurement in `/production.json`: an `inverters` total or an `eim` meter reading. */
5
+ export const Measurement = z.looseObject({
6
+ type: z.string(), // "inverters" | "eim" | "acb" (battery)
7
+ measurementType: z.string().optional(), // eim: "production" | "total-consumption" | "net-consumption"
8
+ activeCount: z.number().optional(), // 0 means the meter is not really measuring
9
+ wNow: z.number().optional(),
10
+ whToday: z.number().optional(),
11
+ whLifetime: z.number().optional(),
12
+ readingTime: z.number().optional(), // unix seconds
13
+ });
14
+ /** `GET /production.json?details=1`. */
15
+ export const Production = z.looseObject({
16
+ production: z.array(Measurement).default([]),
17
+ consumption: z.array(Measurement).default([]),
18
+ });
19
+ /** `GET /ivp/meters`: which current transformers exist and whether they are switched on. */
20
+ export const Meters = z.array(z.looseObject({
21
+ eid: z.number().optional(),
22
+ measurementType: z.string(),
23
+ state: z.string(),
24
+ }));
25
+ /** `GET /ivp/meters/readings`: one reading per meter, joined to `/ivp/meters` by `eid`. */
26
+ export const Readings = z.array(z.looseObject({
27
+ eid: z.number(),
28
+ timestamp: z.number().optional(), // unix seconds
29
+ activePower: z.number().optional(), // W: production, or grid import (negative = export) on a net meter
30
+ actEnergyDlvd: z.number().optional(), // Wh lifetime: produced, or imported on a net meter
31
+ actEnergyRcvd: z.number().optional(), // Wh lifetime: exported on a net meter
32
+ }));
33
+ /** The JSON from Enphase's login: only the session id is used. */
34
+ export const Login = z.looseObject({ session_id: z.string() });
@@ -0,0 +1,48 @@
1
+ import { type Kind, Resource } from "./schemas.ts";
2
+ import type { Clock } from "../../clock.ts";
3
+ import { ConnectorError } from "../../connector.ts";
4
+ /** What a Hue client needs: the bridge's LAN address, its application key, and a pinned `fetch`. */
5
+ export type HueOptions = {
6
+ host: string;
7
+ key: string;
8
+ fetch: typeof fetch;
9
+ clock?: Clock;
10
+ };
11
+ /** A failed bridge call. `code` is "link_button" when pairing waits on the bridge's button. */
12
+ export declare class HueError extends ConnectorError {
13
+ readonly code?: "link_button";
14
+ }
15
+ /** Minimum gap between writes, per lane. Signify's guidance (login-gated, unconfirmed): ~10/s to lights, 1/s to groups. */
16
+ export declare const WRITE_GAP_MS: {
17
+ readonly group: 1000;
18
+ readonly light: 100;
19
+ };
20
+ type Lane = keyof typeof WRITE_GAP_MS;
21
+ /** Which lane a write goes in: `light` writes are fast, everything else (grouped_light, scene) is a group write. */
22
+ export declare const laneFor: (kind: string) => Lane;
23
+ /** Hue bodies merge field by field, the newer value winning: `{ on }` then `{ dimming }` lands as both. */
24
+ export declare function mergeBody(older: object, newer: object): object;
25
+ /** A typed CLIP v2 client. Reads go straight through; writes go through the bridge's write queue. `host` is read at each call, so a getter follows a move. */
26
+ export declare function hueClient(options: HueOptions): {
27
+ put: (kind: Kind, id: string, body: object) => Promise<unknown[]>;
28
+ /** Every resource of one kind, or every resource on the bridge when `kind` is left out (one call). */
29
+ get(kind?: Kind, signal?: AbortSignal): Promise<Resource[]>;
30
+ /** Recall a scene in its room or zone. */
31
+ recallScene(id: string): Promise<unknown[]>;
32
+ };
33
+ export type HueClient = ReturnType<typeof hueClient>;
34
+ /** Ask the bridge for an application key. One call: throws `HueError` with code "link_button" until KO presses it. */
35
+ export declare function pair(options: {
36
+ host: string;
37
+ fetch: typeof fetch;
38
+ deviceType: string;
39
+ }): Promise<{
40
+ key: string;
41
+ clientKey?: string;
42
+ }>;
43
+ /**
44
+ * A `fetch` that trusts only `ca` and accepts only a certificate whose CN is `bridgeId`.
45
+ * Uses Bun's `tls` fetch option; Node ignores it and then rejects the bridge, so it fails closed there; Node uses `nodePinnedFetch` from `@khalido/home/node`.
46
+ */
47
+ export declare function bunPinnedFetch(bridgeId: string, ca: string): typeof fetch;
48
+ export {};
@@ -0,0 +1,95 @@
1
+ import { z } from "zod";
2
+ import { Envelope, PairResponse, Resource } from "./schemas.js";
3
+ import { ConnectorError, fetchTimeout } from "../../connector.js";
4
+ import { writeLane } from "../../lane.js";
5
+ /** A failed bridge call. `code` is "link_button" when pairing waits on the bridge's button. */
6
+ export class HueError extends ConnectorError {
7
+ }
8
+ /** Minimum gap between writes, per lane. Signify's guidance (login-gated, unconfirmed): ~10/s to lights, 1/s to groups. */
9
+ export const WRITE_GAP_MS = { group: 1000, light: 100 };
10
+ /** Which lane a write goes in: `light` writes are fast, everything else (grouped_light, scene) is a group write. */
11
+ export const laneFor = (kind) => kind === "light" ? "light" : "group";
12
+ /** Hue bodies merge field by field, the newer value winning: `{ on }` then `{ dimming }` lands as both. */
13
+ export function mergeBody(older, newer) {
14
+ const plain = (x) => typeof x === "object" && x !== null && !Array.isArray(x);
15
+ const out = { ...older };
16
+ for (const [k, v] of Object.entries(newer))
17
+ out[k] = plain(out[k]) && plain(v) ? mergeBody(out[k], v) : v;
18
+ return out;
19
+ }
20
+ /** A typed CLIP v2 client. Reads go straight through; writes go through the bridge's write queue. `host` is read at each call, so a getter follows a move. */
21
+ export function hueClient(options) {
22
+ const lanes = {
23
+ group: writeLane({ gapMs: WRITE_GAP_MS.group, clock: options.clock }),
24
+ light: writeLane({ gapMs: WRITE_GAP_MS.light, clock: options.clock }),
25
+ };
26
+ async function call(method, path, body, signal) {
27
+ const headers = {
28
+ "hue-application-key": options.key,
29
+ };
30
+ if (body !== undefined)
31
+ headers["content-type"] = "application/json";
32
+ const res = await fetchTimeout(options.fetch, `https://${options.host}${path}`, {
33
+ method,
34
+ headers,
35
+ body: body === undefined ? undefined : JSON.stringify(body),
36
+ }, { signal });
37
+ const parsed = Envelope.safeParse(await res.json().catch(() => undefined));
38
+ const errors = parsed.success
39
+ ? parsed.data.errors.map((e) => e.description)
40
+ : [];
41
+ if (!res.ok || errors.length)
42
+ throw new HueError(errors.join("; ") || `HTTP ${res.status} on ${method} ${path}`, { status: res.status });
43
+ return parsed.success ? parsed.data.data : [];
44
+ }
45
+ /** Change one resource, e.g. `put("grouped_light", id, { on: { on: false } })`. Spaced per lane; writes to one resource waiting their turn merge. */
46
+ const put = (kind, id, body) => lanes[laneFor(kind)].send(`${kind}/${id}`, body, (b) => call("PUT", `/clip/v2/resource/${kind}/${id}`, b), mergeBody);
47
+ return {
48
+ put,
49
+ /** Every resource of one kind, or every resource on the bridge when `kind` is left out (one call). */
50
+ async get(kind, signal) {
51
+ return z
52
+ .array(Resource)
53
+ .parse(await call("GET", `/clip/v2/resource${kind ? `/${kind}` : ""}`, undefined, signal));
54
+ },
55
+ /** Recall a scene in its room or zone. */
56
+ recallScene(id) {
57
+ return put("scene", id, { recall: { action: "active" } });
58
+ },
59
+ };
60
+ }
61
+ /** Ask the bridge for an application key. One call: throws `HueError` with code "link_button" until KO presses it. */
62
+ export async function pair(options) {
63
+ const res = await fetchTimeout(options.fetch, `https://${options.host}/api`, {
64
+ method: "POST",
65
+ headers: { "content-type": "application/json" },
66
+ body: JSON.stringify({
67
+ devicetype: options.deviceType,
68
+ generateclientkey: true,
69
+ }),
70
+ });
71
+ const parsed = PairResponse.safeParse(await res.json().catch(() => undefined));
72
+ if (!parsed.success)
73
+ throw new HueError(`unexpected answer to POST /api (HTTP ${res.status})`, {
74
+ status: res.status,
75
+ });
76
+ const [first] = parsed.data;
77
+ if (first?.success)
78
+ return { key: first.success.username, clientKey: first.success.clientkey };
79
+ const error = first?.error;
80
+ throw new HueError(error?.description ?? `HTTP ${res.status} on POST /api`, {
81
+ status: res.status,
82
+ code: error?.type === 101 ? "link_button" : undefined,
83
+ });
84
+ }
85
+ /**
86
+ * A `fetch` that trusts only `ca` and accepts only a certificate whose CN is `bridgeId`.
87
+ * Uses Bun's `tls` fetch option; Node ignores it and then rejects the bridge, so it fails closed there; Node uses `nodePinnedFetch` from `@khalido/home/node`.
88
+ */
89
+ export function bunPinnedFetch(bridgeId, ca) {
90
+ const checkServerIdentity = (_host, cert) => cert.subject?.CN?.toLowerCase() === bridgeId.toLowerCase()
91
+ ? undefined
92
+ : new Error(`Hue bridge certificate is for ${cert.subject?.CN ?? "nobody"}, expected ${bridgeId}`);
93
+ const pinned = (input, init) => fetch(input, { ...init, tls: { ca, checkServerIdentity } });
94
+ return pinned;
95
+ }
@@ -0,0 +1,41 @@
1
+ import { type HueOptions } from "./client.ts";
2
+ import { HueEvent } from "./schemas.ts";
3
+ /** One SSE message: its `id:` line (the bridge sends `<unix seconds>:0`) and its joined `data:` lines. */
4
+ export type SseMessage = {
5
+ id?: string;
6
+ data: string;
7
+ };
8
+ /** Split an SSE byte stream into messages, one per blank-line-separated block. Comment lines (`:`) are skipped. */
9
+ export declare function parseSse(body: ReadableStream<Uint8Array>): AsyncGenerator<SseMessage>;
10
+ /** A 401 or 403: the key is wrong or revoked. */
11
+ export declare const isRejectedKey: (error: unknown) => boolean;
12
+ /** What the stream yields: a (re)connection, a batch of events, or a drop with the wait before the next try. */
13
+ export type StreamItem = {
14
+ kind: "connected";
15
+ } | {
16
+ kind: "events";
17
+ id?: string;
18
+ events: HueEvent[];
19
+ skipped?: number;
20
+ } | {
21
+ kind: "disconnected";
22
+ error: unknown;
23
+ retryInMs: number;
24
+ };
25
+ /**
26
+ * A connection with no bytes for this long is reconnected: a silent half-close otherwise looks connected forever (aiohue
27
+ * uses 90 s). The bridge sends nothing on its own, so a quiet house trips it too; the reconnect is a silent probe.
28
+ */
29
+ export declare const IDLE_MS = 90000;
30
+ /** Waits between reconnects; the last one repeats. Reset after every successful connect. */
31
+ export declare const BACKOFF_MS: number[];
32
+ /**
33
+ * Read `GET /eventstream/clip/v2` until `signal` aborts, reconnecting with backoff.
34
+ * Events missed while disconnected are gone, so refetch everything on each "connected".
35
+ * A connection silent for `idleMs` is reconnected at once without a "disconnected" or a step up the backoff (the bridge
36
+ * sends no keep-alive); only a reconnect that then fails is reported, as any failure is.
37
+ */
38
+ export declare function hueEvents(options: HueOptions & {
39
+ signal?: AbortSignal;
40
+ idleMs?: number;
41
+ }): AsyncGenerator<StreamItem>;
@@ -0,0 +1,156 @@
1
+ import { z } from "zod";
2
+ import { realClock } from "../../clock.js";
3
+ import { HueError } from "./client.js";
4
+ import { HueEvent } from "./schemas.js";
5
+ /** Split an SSE byte stream into messages, one per blank-line-separated block. Comment lines (`:`) are skipped. */
6
+ export async function* parseSse(body) {
7
+ const reader = body.getReader();
8
+ const decoder = new TextDecoder();
9
+ let buffer = "";
10
+ try {
11
+ while (true) {
12
+ const { value, done } = await reader.read();
13
+ if (done)
14
+ return;
15
+ buffer += decoder.decode(value, { stream: true });
16
+ for (let end = buffer.search(/\r?\n\r?\n/); end >= 0; end = buffer.search(/\r?\n\r?\n/)) {
17
+ const block = buffer.slice(0, end);
18
+ buffer = buffer.slice(end).replace(/^\r?\n\r?\n/, "");
19
+ const message = parseBlock(block);
20
+ if (message)
21
+ yield message;
22
+ }
23
+ }
24
+ }
25
+ finally {
26
+ reader.releaseLock();
27
+ }
28
+ }
29
+ function parseBlock(block) {
30
+ let id;
31
+ const data = [];
32
+ for (const line of block.split(/\r?\n/)) {
33
+ if (!line || line.startsWith(":"))
34
+ continue;
35
+ const colon = line.indexOf(":");
36
+ const field = colon < 0 ? line : line.slice(0, colon);
37
+ const value = colon < 0 ? "" : line.slice(colon + 1).replace(/^ /, "");
38
+ if (field === "id")
39
+ id = value;
40
+ else if (field === "data")
41
+ data.push(value);
42
+ }
43
+ return data.length ? { id, data: data.join("\n") } : undefined;
44
+ }
45
+ const safeJson = (text) => {
46
+ try {
47
+ return JSON.parse(text);
48
+ }
49
+ catch {
50
+ return undefined;
51
+ }
52
+ };
53
+ /** A 401 or 403: the key is wrong or revoked. */
54
+ export const isRejectedKey = (error) => error instanceof HueError && (error.status === 401 || error.status === 403);
55
+ /**
56
+ * A connection with no bytes for this long is reconnected: a silent half-close otherwise looks connected forever (aiohue
57
+ * uses 90 s). The bridge sends nothing on its own, so a quiet house trips it too; the reconnect is a silent probe.
58
+ */
59
+ export const IDLE_MS = 90_000;
60
+ /** Waits between reconnects; the last one repeats. Reset after every successful connect. */
61
+ export const BACKOFF_MS = [1000, 5000, 15000, 60000];
62
+ /**
63
+ * Read `GET /eventstream/clip/v2` until `signal` aborts, reconnecting with backoff.
64
+ * Events missed while disconnected are gone, so refetch everything on each "connected".
65
+ * A connection silent for `idleMs` is reconnected at once without a "disconnected" or a step up the backoff (the bridge
66
+ * sends no keep-alive); only a reconnect that then fails is reported, as any failure is.
67
+ */
68
+ export async function* hueEvents(options) {
69
+ const clock = options.clock ?? realClock;
70
+ const stop = new AbortController();
71
+ const onAbort = () => stop.abort();
72
+ if (options.signal?.aborted)
73
+ stop.abort();
74
+ options.signal?.addEventListener("abort", onAbort, { once: true });
75
+ let attempt = 0;
76
+ let watch; // the running watchdog's wait
77
+ try {
78
+ while (!stop.signal.aborted) {
79
+ // Each connection has its own watchdog, on the clock: any bytes (events or the bridge's comment lines) re-arm it.
80
+ const connection = new AbortController();
81
+ const idleMs = options.idleMs ?? IDLE_MS;
82
+ let opened = false; // this connection got as far as "connected"
83
+ let quiet = false; // the watchdog ended it
84
+ const arm = () => {
85
+ watch?.abort();
86
+ const w = (watch = new AbortController());
87
+ void clock.sleep(idleMs, w.signal).then(() => {
88
+ if (w.signal.aborted || connection.signal.aborted)
89
+ return; // re-armed or already over (the clock's sleep resolves on abort)
90
+ quiet = true;
91
+ connection.abort(new HueError(`no data on the event stream for ${idleMs / 1000} s`));
92
+ });
93
+ };
94
+ try {
95
+ arm();
96
+ const res = await options.fetch(`https://${options.host}/eventstream/clip/v2`, {
97
+ headers: {
98
+ "hue-application-key": options.key,
99
+ accept: "text/event-stream",
100
+ },
101
+ signal: AbortSignal.any([stop.signal, connection.signal]),
102
+ });
103
+ if (!res.ok || !res.body)
104
+ throw new HueError(`HTTP ${res.status} on the event stream`, {
105
+ status: res.status,
106
+ });
107
+ yield { kind: "connected" };
108
+ opened = true;
109
+ arm(); // time spent with the consumer is not the bridge's silence
110
+ // The watchdog errors this pass-through, which ends the read even if the body ignores the abort.
111
+ const watched = res.body.pipeThrough(new TransformStream({
112
+ start: (out) => connection.signal.addEventListener("abort", () => out.error(connection.signal.reason), { once: true }),
113
+ transform: (chunk, out) => (arm(), out.enqueue(chunk)),
114
+ }));
115
+ for await (const message of parseSse(watched)) {
116
+ attempt = 0; // reset only once data flows, so a bridge that accepts and drops still backs off
117
+ // One odd batch is skipped and counted, not allowed to drop the stream (a reconnect costs a full read).
118
+ const batches = z
119
+ .array(z.unknown())
120
+ .safeParse(safeJson(message.data));
121
+ const parsed = (batches.success ? batches.data : [message.data]).map((b) => HueEvent.safeParse(b));
122
+ const events = parsed.flatMap((p) => (p.success ? [p.data] : []));
123
+ const skipped = parsed.length - events.length;
124
+ yield {
125
+ kind: "events",
126
+ id: message.id,
127
+ events,
128
+ ...(skipped ? { skipped } : {}),
129
+ };
130
+ }
131
+ throw new HueError("the event stream ended");
132
+ }
133
+ catch (caught) {
134
+ watch?.abort();
135
+ if (stop.signal.aborted)
136
+ return;
137
+ // Quiet, not broken: probe with a fresh connection now. Only a probe that fails is reported (it never opened).
138
+ if (quiet && opened)
139
+ continue;
140
+ const error = connection.signal.aborted
141
+ ? connection.signal.reason
142
+ : caught;
143
+ if (isRejectedKey(error))
144
+ throw error; // retrying will not help: pair again
145
+ const retryInMs = BACKOFF_MS[Math.min(attempt++, BACKOFF_MS.length - 1)];
146
+ yield { kind: "disconnected", error, retryInMs };
147
+ await clock.sleep(retryInMs, stop.signal);
148
+ }
149
+ }
150
+ }
151
+ finally {
152
+ watch?.abort();
153
+ stop.abort(); // a consumer that breaks out also closes the connection
154
+ options.signal?.removeEventListener("abort", onAbort);
155
+ }
156
+ }
@@ -0,0 +1,42 @@
1
+ import { type Clock } from "../../clock.ts";
2
+ import { z } from "zod";
3
+ import { type Finder } from "../../discover.ts";
4
+ import { type Integration } from "../../home.ts";
5
+ import type { Manifest } from "../../manifest.ts";
6
+ import { type HueClient } from "./client.ts";
7
+ import { type HueSnapshot } from "./state.ts";
8
+ export { bunPinnedFetch, type HueClient, HueError, type HueOptions, hueClient, pair, } from "./client.ts";
9
+ export type { HueEvent, Resource } from "./schemas.ts";
10
+ export { type GroupView, groups, type HueSnapshot, homeGroupedLightId, type LightView, lights, scenes, } from "./state.ts";
11
+ /** A Hue bridge in a home config: what it takes to reach it. Its model and software come live into the snapshot. */
12
+ export declare const configSchema: z.ZodObject<{
13
+ connector: z.ZodLiteral<"hue">;
14
+ host: z.ZodString;
15
+ bridgeId: z.ZodString;
16
+ key: z.ZodString;
17
+ ca: z.ZodString;
18
+ }, z.core.$loose>;
19
+ /**
20
+ * Hue settings for a home. Leave out `fetch` in Bun and it pins to `ca` and `bridgeId`; in Node pass a pinned one.
21
+ * `find` (mDNS, matched by `bridgeId`) finds the bridge again after three failed reconnects; `idleMs` is the stream's watchdog.
22
+ */
23
+ export type HueConfig = {
24
+ host: string;
25
+ bridgeId: string;
26
+ key: string;
27
+ ca: string;
28
+ fetch?: typeof fetch;
29
+ clock?: Clock;
30
+ find?: Finder;
31
+ idleMs?: number;
32
+ seen?: (host: string) => void;
33
+ hostCached?: boolean;
34
+ };
35
+ /**
36
+ * Hue as a home integration: a full read on every (re)connect, then patched from the event stream; `refresh` re-reads it all.
37
+ * A bridge that stops answering is looked for by `bridgeId` after three failed reconnects, and followed to its new address.
38
+ */
39
+ export declare function hue(config: HueConfig): Integration<HueSnapshot, "hue"> & {
40
+ readonly client: HueClient;
41
+ };
42
+ export declare const manifest: Manifest<HueSnapshot>;
@@ -0,0 +1,205 @@
1
+ import { asOf, realClock } from "../../clock.js";
2
+ import { errorText } from "../../connector.js";
3
+ import { z } from "zod";
4
+ import { lostDetail, relocator } from "../../discover.js";
5
+ import { envName, filePath, } from "../../home.js";
6
+ import { bunPinnedFetch, hueClient } from "./client.js";
7
+ import { hueEvents, isRejectedKey } from "./events.js";
8
+ import { groups, lights, reduce, snapshotFrom, } from "./state.js";
9
+ export { bunPinnedFetch, HueError, hueClient, pair, } from "./client.js";
10
+ export { groups, homeGroupedLightId, lights, scenes, } from "./state.js";
11
+ /** A Hue bridge in a home config: what it takes to reach it. Its model and software come live into the snapshot. */
12
+ export const configSchema = z.looseObject({
13
+ connector: z.literal("hue"),
14
+ host: z.string().describe("the bridge's last known address"),
15
+ bridgeId: z
16
+ .string()
17
+ .describe("the bridge id, the CN its certificate must carry: its identity"),
18
+ key: envName.describe("env name holding the application key from `home pair`"),
19
+ ca: filePath.describe("Signify's root CA (PEM), relative to the config file"),
20
+ });
21
+ /**
22
+ * Hue as a home integration: a full read on every (re)connect, then patched from the event stream; `refresh` re-reads it all.
23
+ * A bridge that stops answering is looked for by `bridgeId` after three failed reconnects, and followed to its new address.
24
+ */
25
+ export function hue(config) {
26
+ const clock = config.clock ?? realClock;
27
+ const where = relocator({
28
+ host: config.host,
29
+ id: config.bridgeId,
30
+ find: config.find,
31
+ clock,
32
+ cached: config.hostCached,
33
+ });
34
+ // One client for the integration's life, at wherever the bridge is now: a move keeps the write lane and the writes waiting in it.
35
+ const options = {
36
+ ...config,
37
+ fetch: config.fetch ?? bunPinnedFetch(config.bridgeId, config.ca),
38
+ get host() {
39
+ return where.host();
40
+ },
41
+ };
42
+ const client = hueClient(options);
43
+ let snapshot;
44
+ let report;
45
+ let status = "connecting";
46
+ let last = { status }; // the last report, said again to a refresh that cannot read
47
+ let running;
48
+ let buffered; // events seen while a refresh's GET is in flight, re-applied on top of it
49
+ let moved; // kept in `detail` so a consumer can persist the new address
50
+ return {
51
+ name: "hue",
52
+ client,
53
+ /**
54
+ * One full GET of every resource, replacing the stream-patched state; events that arrive meanwhile are re-applied on
55
+ * top. Only while connected; a failure keeps the state and says so.
56
+ */
57
+ refresh() {
58
+ where.reset(); // while it is down, the next failed reconnect looks for it at once
59
+ if (!report || !running || running.aborted)
60
+ return; // not running: nothing will report
61
+ // Not connected: nothing to read until the stream is back, so answer with the last report rather than leave the caller waiting out the backoff.
62
+ if (status !== "connected")
63
+ return report(last);
64
+ if (buffered)
65
+ return; // a refresh already in flight, whose GET covers this one
66
+ const signal = running;
67
+ buffered = [];
68
+ client.get(undefined, signal).then((all) => {
69
+ let s = snapshotFrom(all, asOf(clock));
70
+ for (const event of buffered ?? [])
71
+ s = reduce(s, event);
72
+ buffered = undefined;
73
+ if (signal.aborted || status !== "connected")
74
+ return; // the stream dropped meanwhile, or the key was rejected: the next connect reads it all
75
+ snapshot = s;
76
+ report?.({ status, detail: moved, state: snapshot });
77
+ }, (error) => {
78
+ buffered = undefined;
79
+ // Still connected (the stream is fine) but this read failed: not a good read, so the last one's time stands.
80
+ if (!signal.aborted && status === "connected")
81
+ report?.({
82
+ status,
83
+ detail: `refresh failed: ${errorText(error)}`,
84
+ state: snapshot,
85
+ lastGoodAt: snapshot?.asOf,
86
+ });
87
+ });
88
+ },
89
+ async run(signal, rawUpdate) {
90
+ running = signal;
91
+ report = (part) => ((status = part.status),
92
+ (last = part),
93
+ rawUpdate(part));
94
+ const update = report;
95
+ while (!signal.aborted) {
96
+ try {
97
+ for await (const item of hueEvents({ ...options, signal })) {
98
+ const now = asOf(clock);
99
+ if (item.kind === "disconnected") {
100
+ const r = await where.failed(signal);
101
+ if (r.moved) {
102
+ moved = r.moved;
103
+ break; // connect at the new address now; that connection reports, with the move in `detail`
104
+ }
105
+ update({
106
+ status: "disconnected",
107
+ detail: lostDetail(r, item.error),
108
+ state: snapshot,
109
+ });
110
+ continue;
111
+ }
112
+ if (item.kind === "connected") {
113
+ snapshot = snapshotFrom(await client.get(undefined, signal), now);
114
+ where.ok();
115
+ config.seen?.(options.host);
116
+ }
117
+ else if (item.kind === "events" && snapshot) {
118
+ for (const event of item.events)
119
+ snapshot = reduce(snapshot, event);
120
+ buffered?.push(...item.events);
121
+ snapshot = { ...snapshot, lastEventId: item.id, asOf: now };
122
+ }
123
+ const anomaly = item.kind === "events" && item.skipped
124
+ ? `skipped ${item.skipped} event batch(es) the schema did not recognise`
125
+ : undefined;
126
+ update({
127
+ status: "connected",
128
+ detail: moved,
129
+ anomaly,
130
+ state: snapshot,
131
+ });
132
+ }
133
+ }
134
+ catch (error) {
135
+ if (signal.aborted)
136
+ return; // stopped mid-read: not a failure
137
+ if (isRejectedKey(error))
138
+ return update({
139
+ status: "unavailable",
140
+ detail: "the bridge rejected the key; pair again",
141
+ state: snapshot,
142
+ });
143
+ // The full read failed after connecting: say so, wait, and start over with a fresh stream.
144
+ update({
145
+ status: "disconnected",
146
+ detail: errorText(error),
147
+ state: snapshot,
148
+ });
149
+ await clock.sleep(5000, signal);
150
+ }
151
+ }
152
+ },
153
+ };
154
+ }
155
+ /** Lights on that the bridge can reach, and each room and zone as on or off with its brightness. */
156
+ const view = (s) => ({
157
+ lightsOn: lights(s).filter((l) => l.on && l.reachable !== false).length,
158
+ rooms: groups(s).map(({ name, on, brightness }) => ({
159
+ name,
160
+ on,
161
+ brightness,
162
+ })),
163
+ });
164
+ export const manifest = {
165
+ name: "hue",
166
+ title: "Philips Hue",
167
+ iotClass: "local_push",
168
+ access: "local HTTPS, key from `home pair`",
169
+ env: [
170
+ {
171
+ name: "HUE_BRIDGE_HOST",
172
+ example: "hue-bridge.local",
173
+ optional: true,
174
+ what: "found by mDNS (dns-sd) when unset",
175
+ },
176
+ {
177
+ name: "HUE_BRIDGE_ID",
178
+ example: "0017880000000000",
179
+ optional: true,
180
+ what: "the certificate's CN must match it; mDNS finds it when unset",
181
+ },
182
+ {
183
+ name: "HUE_APPLICATION_KEY",
184
+ example: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
185
+ what: "from `home pair`",
186
+ },
187
+ {
188
+ name: "HUE_CA_FILE",
189
+ example: "/path/to/hue-root-ca.pem",
190
+ what: "until HUE_BRIDGE_ROOT_CA ships in src/connectors/hue/",
191
+ },
192
+ ],
193
+ files: ["ca"],
194
+ /** "3 lights on", then "Kitchen: on · 40%" per room and zone. */
195
+ lines({ state }) {
196
+ if (!state)
197
+ return [];
198
+ const { lightsOn, rooms } = view(state);
199
+ return [
200
+ `${lightsOn} lights on`,
201
+ ...rooms.map((r) => `${r.name}: ${r.on ? `on${r.brightness === undefined ? "" : ` · ${Math.round(r.brightness)}%`}` : "off"}`),
202
+ ];
203
+ },
204
+ record: ({ state }) => (state ? view(state) : {}),
205
+ };