@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
package/dist/locate.js ADDED
@@ -0,0 +1,59 @@
1
+ import { applyEdits, modify, parse } from "jsonc-parser";
2
+ import { z } from "zod";
3
+ // A street address to the home's latitude and longitude, once, through OpenStreetMap's Nominatim, and that position
4
+ // written into a home config's text. Pure: the caller passes `fetch` and its User-Agent, and writes the file.
5
+ /** One Nominatim match. It sends the coordinates as strings; they are read as numbers, never rounded. */
6
+ const Place = z.looseObject({
7
+ display_name: z.string(),
8
+ lat: z.coerce.number(),
9
+ lon: z.coerce.number(),
10
+ });
11
+ /**
12
+ * The best match for `address`, or undefined when Nominatim finds none. Its usage policy requires a User-Agent that
13
+ * names the application, and allows one request a second: this is for a one-off lookup, not a loop.
14
+ */
15
+ export async function geocode(address, options) {
16
+ const url = `https://nominatim.openstreetmap.org/search?${new URLSearchParams({ q: address, format: "jsonv2", limit: "1" })}`;
17
+ const res = await (options.fetch ?? fetch)(url, {
18
+ headers: { "User-Agent": options.userAgent },
19
+ signal: options.signal ?? AbortSignal.timeout(10_000),
20
+ });
21
+ if (!res.ok)
22
+ throw new Error(`Nominatim answered ${res.status}`);
23
+ const places = z.array(Place).safeParse(await res.json());
24
+ if (!places.success)
25
+ throw new Error("Nominatim's answer was not a list of places with coordinates");
26
+ const p = places.data[0];
27
+ return p && { name: p.display_name, latitude: p.lat, longitude: p.lon };
28
+ }
29
+ /**
30
+ * A config's text with `location.latitude` and `.longitude` set and, when it has none, `.timezone`, through
31
+ * jsonc-parser's edits: comments and every other key stay. With no `location` (absent or commented out) a fresh one is
32
+ * added, and `fresh` says so; a commented-out one is left as it was.
33
+ */
34
+ export function applyLocation(text, at, timeZone) {
35
+ const location = parse(text)
36
+ ?.location;
37
+ const format = { formattingOptions: { insertSpaces: true, tabSize: 2 } };
38
+ if (typeof location !== "object" || location === null) {
39
+ return {
40
+ text: applyEdits(text, modify(text, ["location"], {
41
+ latitude: at.latitude,
42
+ longitude: at.longitude,
43
+ timezone: timeZone,
44
+ }, format)),
45
+ fresh: true,
46
+ timezoneSet: true,
47
+ };
48
+ }
49
+ let out = text;
50
+ for (const [key, value] of [
51
+ ["latitude", at.latitude],
52
+ ["longitude", at.longitude],
53
+ ])
54
+ out = applyEdits(out, modify(out, ["location", key], value, format));
55
+ const timezoneSet = location.timezone === undefined;
56
+ if (timezoneSet)
57
+ out = applyEdits(out, modify(out, ["location", "timezone"], timeZone, format));
58
+ return { text: out, fresh: false, timezoneSet };
59
+ }
@@ -0,0 +1,31 @@
1
+ import type { Part } from "./home.ts";
2
+ /** Home Assistant's `iot_class`: where the device is and how its state arrives. */
3
+ export type IotClass = "local_push" | "local_polling" | "cloud_polling";
4
+ /** One environment name the CLI reads for a connector; `.env.example` is written from these. `optional`: `home status` runs without it. */
5
+ export type EnvVar = {
6
+ name: string;
7
+ what: string;
8
+ optional?: boolean;
9
+ example?: string;
10
+ };
11
+ export type Manifest<S = unknown> = {
12
+ /** The folder, the config's connector literal, the integration's name and the CLI verb: "hue". */
13
+ name: string;
14
+ title: string;
15
+ iotClass: IotClass;
16
+ pollSeconds?: number;
17
+ /** What it reaches and with what key, for `home status`. */
18
+ access: string;
19
+ /** Every env name the CLI reads for this connector, in `.env.example`'s order. */
20
+ env: EnvVar[];
21
+ /** Config fields that are file paths, read relative to the config file. */
22
+ files?: string[];
23
+ /** The status text: the first line is the connector's own summary, any further lines its children (rooms, devices). Empty without state. */
24
+ lines(part: Part<S>): string[];
25
+ /** The state as `home status --json` shows it, when it is not the state itself. */
26
+ record?(part: Part<S>): Record<string, unknown>;
27
+ };
28
+ /** Watts as "1.2 kW", or "" without a number. Rounded for reading only. */
29
+ export declare const kw: (w: unknown) => string;
30
+ /** A value's text when it exists, else "": a missing field is left out, never printed as "undefined". */
31
+ export declare const field: (value: number | undefined, text: (v: number) => string) => string;
@@ -0,0 +1,4 @@
1
+ /** Watts as "1.2 kW", or "" without a number. Rounded for reading only. */
2
+ export const kw = (w) => typeof w === "number" ? `${(w / 1000).toFixed(1)} kW` : "";
3
+ /** A value's text when it exists, else "": a missing field is left out, never printed as "undefined". */
4
+ export const field = (value, text) => (value === undefined ? "" : text(value));
@@ -0,0 +1,5 @@
1
+ import type { Manifest } from "./manifest.ts";
2
+ /** Every connector's manifest, in the order `home status` and `.env.example` list them. */
3
+ export declare const MANIFESTS: Manifest<any>[];
4
+ /** One connector's manifest by name, or undefined. */
5
+ export declare const manifestOf: (name: string) => Manifest<any> | undefined;
@@ -0,0 +1,23 @@
1
+ import { manifest as amber } from "./connectors/amber/index.js";
2
+ import { manifest as cast } from "./connectors/cast/index.js";
3
+ import { manifest as daikin } from "./connectors/daikin/index.js";
4
+ import { manifest as enphase } from "./connectors/enphase/index.js";
5
+ import { manifest as hue } from "./connectors/hue/index.js";
6
+ import { manifest as printers } from "./connectors/printers/index.js";
7
+ import { manifest as spotify } from "./connectors/spotify/index.js";
8
+ import { manifest as up } from "./connectors/up/index.js";
9
+ import { manifest as weather } from "./connectors/weather/index.js";
10
+ /** Every connector's manifest, in the order `home status` and `.env.example` list them. */
11
+ export const MANIFESTS = [
12
+ hue,
13
+ daikin,
14
+ enphase,
15
+ amber,
16
+ up,
17
+ weather,
18
+ cast,
19
+ printers,
20
+ spotify,
21
+ ];
22
+ /** One connector's manifest by name, or undefined. */
23
+ export const manifestOf = (name) => MANIFESTS.find((m) => m.name === name);
@@ -0,0 +1,9 @@
1
+ /** A `fetch` that trusts only `ca` and accepts only a certificate whose CN is `bridgeId`: Node's `bunPinnedFetch`. */
2
+ export declare function nodePinnedFetch(bridgeId: string, ca: string): typeof fetch;
3
+ /** A `fetch` for the Enphase gateway that skips certificate verification, for its requests only: Node's `bunGatewayFetch`. */
4
+ export declare function nodeGatewayFetch(): typeof fetch;
5
+ /** What `loadHome` takes as `tls` under Node. */
6
+ export declare const NODE_TLS: {
7
+ hue: typeof nodePinnedFetch;
8
+ enphase: typeof nodeGatewayFetch;
9
+ };
@@ -0,0 +1,25 @@
1
+ import { Agent, fetch as undiciFetch } from "undici";
2
+ // `@khalido/home/node`: the fetches Hue and Enphase need under Node, where Bun's `tls` fetch option does not exist. Node's
3
+ // global `fetch` takes no CA, so these use undici's own `fetch` and `Agent` (one undici on both sides). Outside the
4
+ // runtime-neutral build, like cast/tls.ts; a Node consumer passes `NODE_TLS` to `loadHome`, or one fetch to `hue()` or `enphase()`.
5
+ /** undici's `fetch` through `dispatcher`. Takes a URL, not a `Request`, and returns undici's own `Response`. */
6
+ const through = (dispatcher) => ((input, init) => undiciFetch(input, { ...init, dispatcher }));
7
+ /** A `fetch` that trusts only `ca` and accepts only a certificate whose CN is `bridgeId`: Node's `bunPinnedFetch`. */
8
+ export function nodePinnedFetch(bridgeId, ca) {
9
+ const checkServerIdentity = (_host, cert) => {
10
+ const cn = [cert.subject?.CN].flat()[0];
11
+ return cn?.toLowerCase() === bridgeId.toLowerCase()
12
+ ? undefined
13
+ : new Error(`Hue bridge certificate is for ${cn ?? "nobody"}, expected ${bridgeId}`);
14
+ };
15
+ return through(new Agent({ connect: { ca, checkServerIdentity } }));
16
+ }
17
+ /** A `fetch` for the Enphase gateway that skips certificate verification, for its requests only: Node's `bunGatewayFetch`. */
18
+ export function nodeGatewayFetch() {
19
+ return through(new Agent({ connect: { rejectUnauthorized: false } }));
20
+ }
21
+ /** What `loadHome` takes as `tls` under Node. */
22
+ export const NODE_TLS = {
23
+ hue: nodePinnedFetch,
24
+ enphase: nodeGatewayFetch,
25
+ };
package/dist/poll.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ import type { Clock } from "./clock.ts";
2
+ /**
3
+ * The wait between polls, shared by every polling integration, so each can be told to read now.
4
+ * `wait` resolves after `ms`, on abort, or on `nudge()`, and says whether a nudge woke it. A nudge while no one is waiting (mid-read) makes the next wait
5
+ * return at once, so a refresh asked for during a read still gets a read that starts after it.
6
+ */
7
+ export declare function poller(clock: Clock): {
8
+ wait(ms: number, signal?: AbortSignal): Promise<boolean>;
9
+ nudge(): void;
10
+ };
package/dist/poll.js ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The wait between polls, shared by every polling integration, so each can be told to read now.
3
+ * `wait` resolves after `ms`, on abort, or on `nudge()`, and says whether a nudge woke it. A nudge while no one is waiting (mid-read) makes the next wait
4
+ * return at once, so a refresh asked for during a read still gets a read that starts after it.
5
+ */
6
+ export function poller(clock) {
7
+ let wake;
8
+ let pending = false;
9
+ return {
10
+ async wait(ms, signal) {
11
+ if (signal?.aborted)
12
+ return false;
13
+ if (pending)
14
+ return !(pending = false);
15
+ const nudged = new AbortController();
16
+ wake = () => nudged.abort();
17
+ try {
18
+ await clock.sleep(ms, signal ? AbortSignal.any([signal, nudged.signal]) : nudged.signal);
19
+ return nudged.signal.aborted;
20
+ }
21
+ finally {
22
+ wake = undefined;
23
+ pending = false;
24
+ }
25
+ },
26
+ nudge() {
27
+ if (wake)
28
+ wake();
29
+ else
30
+ pending = true;
31
+ },
32
+ };
33
+ }
package/docs/cast.md ADDED
@@ -0,0 +1,133 @@
1
+ # Google Cast
2
+
3
+ **Short answer.** Hand-roll a small CastV2 client in `src/connectors/cast/`: a runtime-neutral codec (one protobuf message, about 25 lines) and the JSON protocol, with the `node:tls` socket in a Node-only file on the `ftp.ts` precedent. The npm options are a 2019 untyped core and wrappers over it, none of which read speaker groups. First milestone: state only (app, playing, volume, standby, groups and members), about 360 lines. Announcements and the Hub dashboard follow, with KO watching.
4
+
5
+ ## What is on the network (probed 2026-10-13, Bun 1.4.2, read-only)
6
+
7
+ `dns-sd -B _googlecast._tcp` found seven entries, all on port 8009 except the group:
8
+
9
+ | Name (`fn`) | Model (`md`) | Receiver status |
10
+ | ----------- | ----------------------------- | --------------------------------------------------------- |
11
+ | Display A | Google Nest Hub (Fuchsia) | Backdrop, `isIdleScreen`, volume 0.47 |
12
+ | Display B | Google Nest Hub Max (Fuchsia) | Backdrop, `isIdleScreen`, 0.46 |
13
+ | Speaker A | Google Nest Mini | no app, 0.375 |
14
+ | Speaker B | Google Home Speaker | no app, 0.30 |
15
+ | TV A | Chromecast Ultra | Backdrop, volume `attenuation` 0.80 |
16
+ | TV B | Google TV Streamer | `isStandBy: true`, `isActiveInput: false`, volume `fixed` |
17
+ | Whole house | Google Cast Group | members below |
18
+
19
+ Each answered a TLS connect plus receiver `GET_STATUS` (nothing launched, no sound, no screen change) in 25–150 ms, TLS 1.2 on the two older speakers, 1.3 elsewhere. Every certificate is self-signed, CN the device id, valid 48 hours, so they rotate.
20
+
21
+ **Groups.** The group is its own mDNS entry resolving to Display A's host on its own high port, with Display A's certificate: a group lives on whichever member leads it. Multizone `GET_STATUS` on the group port lists the members (Display A, Display B, Speaker A) with their volumes; on Display A's own port it lists only itself, and `GET_CASTING_GROUPS` is refused. TXT `rm` does not mark membership (a member has it empty), so membership comes only from `MULTIZONE_STATUS`.
22
+
23
+ Not probed: media status (nothing was playing), socket lifetime, members' status while the group plays, and Node's `tls` (no Node caller yet).
24
+
25
+ ## Library pick
26
+
27
+ | Package | Latest | Code | Verdict |
28
+ | --------------------------- | --------------- | ------------ | -------------------------------------------------------------------------------------------------------------------------- |
29
+ | `castv2` | 0.1.10, 2019 | 438 lines JS | untyped, loads `cast_channel.proto` from `__dirname` at runtime via `protobufjs` 6, hardcodes `rejectUnauthorized = false` |
30
+ | `castv2-client` | 1.2.0, 2016 | 788 | callbacks over `castv2` |
31
+ | `chromecast-api` | 0.4.2, 2023 | | adds SSDP, xml2js, a YouTube remote |
32
+ | `@foxxmd/chromecast-client` | 1.0.4, Jan 2024 | 917 | the best: typed, heartbeat, reconnect, DashCast; but on `castv2`, zod 3 beside our zod 4, no multizone |
33
+
34
+ The probe's codec was about 25 lines and spoke to all six devices; a library would be larger than the code it saves and still miss groups. A deliberate exception to "libraries over hand-rolling"; `protobufjs` for one six-field message is not worth a dependency. pychromecast (MIT) and Home Assistant's `cast` (Apache-2.0) are the reference for state and groups.
35
+
36
+ ## Design
37
+
38
+ ```
39
+ src/connectors/cast/
40
+ codec.ts CastMessage encode/decode, 4-byte length framing (Uint8Array, runtime-neutral)
41
+ schemas.ts zod, every field optional: RECEIVER_STATUS, MEDIA_STATUS, MULTIZONE_STATUS, DEVICE_ADDED/UPDATED/REMOVED
42
+ state.ts reduce(snapshot, deviceName, message), pure
43
+ index.ts cast(config): Integration<CastSnapshot> & CastActions
44
+ writes.ts setVolume, setMuted, stop, announce over the integration's own sockets
45
+ serve.ts Node-only: serveClip, the clip on the LAN for the device to fetch
46
+ tls.ts Node-only: tlsConnect(host, port, signal): CastChannel (excluded from tsconfig.build, in tsconfig.node)
47
+ README.md
48
+ ```
49
+
50
+ **Transport is injected.** `CastChannel = { peer, send(message), messages: AsyncIterable<CastMessage>, close() }`; tests pass a fake replaying fixtures. Every request has a `requestId`; a socket whose receiver status has not come within 10 s is dropped and retried.
51
+
52
+ **Discovery is injected too.** The library cannot browse mDNS, so `cast({ resolve, connect })` takes `resolve(): Promise<{ id, name, model, host, port, group }[]>`; the CLI and the sidecar run `dns-sd`, as `home discover` does. It runs at start and after any drop, which handles a group leader changing.
53
+
54
+ **One connection per entry**, seven here: `CONNECT` to `receiver-0`, `PING` every 5 s, receiver and multizone `GET_STATUS`, then pushed updates. When an app with the media namespace runs, connect to its `transportId` and read media status, as HA does. Reconnect with backoff at the last known address; a drop starts one shared re-discovery, repeated at 1, 5 and 15 minutes, then hourly, while anything is down or missing. `refresh()` re-sends every `GET_STATUS` and nudges that re-discovery.
55
+
56
+ **State:**
57
+
58
+ ```ts
59
+ type CastDevice = {
60
+ name: string;
61
+ model?: string;
62
+ kind: "device" | "group";
63
+ app?: string;
64
+ idle?: boolean; // idle: no app, or Backdrop's isIdleScreen
65
+ standby?: boolean; // TVs
66
+ volume?: number;
67
+ muted?: boolean;
68
+ volumeControl?: "master" | "attenuation" | "fixed";
69
+ playerState?: "PLAYING" | "PAUSED" | "BUFFERING" | "IDLE";
70
+ title?: string;
71
+ artist?: string;
72
+ members?: string[];
73
+ leader?: string; // groups; leader = the device the group's certificate names
74
+ groups?: string[];
75
+ playingVia?: string; // devices
76
+ status: "connected" | "disconnected";
77
+ asOf: string;
78
+ };
79
+ type CastSnapshot = { devices: Record<string, CastDevice>; asOf: string };
80
+ ```
81
+
82
+ As built: the snapshot is keyed by mDNS id, and `members`, `leader`, `groups` and `playingVia` hold ids; the leader comes from the group socket's certificate name (checked live: a group presents its leader's certificate), not from matching hosts. Volume passes through unrounded. **Members while a group plays**: HA keeps each group's media status and shows it on a member whose own is empty; the reducer does the same and sets `playingVia`. HA's model, to confirm live once.
83
+
84
+ **Config fragment**, one entry per Cast device or group so the home window can place it in a room:
85
+
86
+ ```ts
87
+ export const configSchema = z.looseObject({
88
+ connector: z.literal("cast"),
89
+ name: z
90
+ .string()
91
+ .describe(
92
+ "the device's or group's name in the Google Home app (mDNS fn), for display",
93
+ ),
94
+ castId: z
95
+ .string()
96
+ .describe(
97
+ "the mDNS id, the stable identity; `home discover --write` fills it",
98
+ ),
99
+ });
100
+ ```
101
+
102
+ Decided by KO, 2026-10-13: the entry carries both, the Google Home name for display and the mDNS id as identity, so a rename in the app does not lose the device and the config reads like the app.
103
+
104
+ No host: a group's host changes with its leader. All entries fold into one `cast` integration, which `checkHome`'s one-per-connector rule forbids today (question 1). An unlisted device still appears, without a room.
105
+
106
+ **Writes, never called by the integration** (built 2026-10-14 except `castUrl`; tested on a scripted fake socket, not yet live; [src/connectors/cast/README.md](../src/connectors/cast/README.md) has what was built):
107
+
108
+ - `setVolume(name, level)`, `setMuted`, `stop(name)` (receiver `STOP` returns the device to Backdrop).
109
+ - `announce(target, clip, { volume?, timeoutMs? })`, target a device or group, `clip` a URL or bytes with their type (KO's own dictation as recorded, or TTS output; the library never synthesises) served by `serveClip`. The result names each member it could not restore. Read status, set volume, launch Default Media Receiver (`CC1AD845`), `LOAD` the clip, wait for `IDLE`/`FINISHED` (with a timeout), then restore: a prior Default Media Receiver `contentId` is re-loaded at its `currentTime`, an idle device gets `STOP`, then the volume. Spotify, YouTube and other apps cannot be resumed generically (our launch ends their session); the result says so.
110
+ - `castUrl(name, url)` launches DashCast (`84912283`, pychromecast's list) with the URL. HA casts its dashboard with its own registered receiver (`A078F6B0`), a hint at DashCast's limits. KO's own receiver costs a one-off Cast SDK fee and must be published (unpublished ones run only on serial-registered devices). Try DashCast first.
111
+
112
+ **TTS.** The library plays a URL, never synthesises. The speaker fetches it, so it must be HTTP on the LAN, not loopback, and in a format Default Media Receiver plays (MP3, AAC, WAV; not AIFF): `say -o msg.m4a --file-format=m4af --data-format=aac "…"` (m4af alone holds uncompressed PCM). Smallest server: a ~25-line `serveClip(bytes, type)` over `node:http` beside `tls.ts`, bound to the Mac's LAN address, random port, unguessable path, closed when the clip has played or after a minute (not after the first fetch, so a probe before it does no harm). The consumer runs `say`; `serveClip` picks the address on the target's network; the sidecar will raise macOS's local-network prompt once. A cloud voice later is MP3 bytes served the same way.
113
+
114
+ **CLI.** `home status`: `cast 7 · Display A idle 47% · TV B standby · Whole house (3, led by Display A) idle`. `home probe`: this probe.
115
+
116
+ **Tests** on the recorded payloads, ids replaced (idle hub, idle speaker, standby TV, the group's `MULTIZONE_STATUS`), invented `MEDIA_STATUS` and `DEVICE_REMOVED` until live ones are recorded, and codec round-trips.
117
+
118
+ **Size.** Codec 40, schemas 60, state 90, index 90, tls 80: about 360 for milestone 1. Writes and `serveClip` add about 150.
119
+
120
+ ## Risks
121
+
122
+ - **TLS.** Rotating self-signed certs rule out pinning; verification is off, as with Enphase, with the cert CN checked against the mDNS `id`. Nothing secret is sent. Google's device-auth challenge needs its Cast root; not worth it.
123
+ - **Nest Hub firmware and unregistered receivers.** Untested; both Hubs run Fuchsia. Whether DashCast stays up is the first `castUrl` experiment.
124
+ - **Group leader changes.** Re-resolve on drop, and `announce` to a group resolves just before sending.
125
+ - **Bun `node:tls`** works (Bun 1.4.2); Node untested.
126
+ - **Seven always-open sockets.** Cheap (a PING per 5 s each) and what HA does; Hubs mark `senderConnected`, not shown on screen.
127
+
128
+ ## Open questions for KO
129
+
130
+ 1. ~~One `cast` integration over many config entries means relaxing `checkHome`'s one-device-per-connector rule for Cast. Fine?~~ Yes (KO, 2026-10-13); built.
131
+ 2. ~~Entries by Google Home name or mDNS id?~~ Both: the name for display, the id as identity (KO, 2026-10-13).
132
+ 3. ~~`serveClip` in the library (Node-only exception) or in each consumer?~~ In the library, beside `tls.ts`, on the same Node-only footing: the CLI and the sidecar both need it (lead, 2026-10-14).
133
+ 4. ~~Interrupt Spotify it cannot resume, or skip that speaker and say why?~~ Interrupt, as Google's own announcements do, and report what could not be restored (lead, 2026-10-14; KO can reverse).
package/docs/cli.md ADDED
@@ -0,0 +1,41 @@
1
+ # The `home` command
2
+
3
+ ```sh
4
+ home <connector> [--json] # one connector's lines: amber, cast, daikin, enphase, hue, printers, spotify, up, weather
5
+ home [status|doctor] [--json] # each integration: connected or not, which env names it still needs, rooms
6
+ home up transactions | categories | uncategorised | spend | categorise | tag | webhooks # Up Bank's lists and reports; categorise and tag write
7
+ home lights off # one write to the whole-home group
8
+ home room <name> on|off|<1-100>
9
+ home scene <name>
10
+ home pair # press the bridge's button first; prints the env lines to set
11
+ home enphase login # once a year: mints the Enphase owner token into ~/.config/home/ (0600)
12
+ home discover [--json] [--passes n] # which smart-home devices answer here (mDNS + Daikin UDP), which this package supports, then with a config how they match it; two passes 5 s apart
13
+ home cache [clear] # the addresses kept between runs, or delete them so the next run discovers afresh
14
+ home config check [path] # validate a home config: what it would set up, secrets as set or unset by name, every problem
15
+ home discover --write [path] # with a config (the path, else --config or HOME_CONFIG): match what answers by identity, add new devices, update moved hosts and renamed Cast names in place
16
+ home probe # read-only, no keys: Hue's public config, Daikin by UDP broadcast, the IQ Gateway's /info, each weather source, each Cast device's and printer's status
17
+ home locate "<street address>" [--write [path]] # latitude and longitude from OpenStreetMap's Nominatim; --write sets them in the config's location (and timezone, when absent, to this machine's)
18
+ home cast announce --to <name> (--say "<text>" | --file <path>) [--volume 0-100] # play a clip, then put back what was playing and the volume
19
+ home cast volume <name> <0-100>
20
+ home cast mute <name> on|off
21
+ home cast stop <name> # end the running app; the device returns to its idle screen
22
+ home spotify pause | play [link] | next | volume <0-100> # the Soloist speaker at SOLOIST_WS; volume held to SOLOIST_MAX_VOLUME; prints what Soloist then reported
23
+ ```
24
+
25
+ ## Output
26
+
27
+ - **stdout is data, stderr everything else.** Notes such as "nothing to categorise" or "no webhooks" go to stderr, so `home … | jq` never sees them.
28
+ - **Bun on PATH.** The command starts with `#!/usr/bin/env bun`. A non-login SSH shell may not have `~/.bun/bin` on its PATH: run `PATH="$HOME/.bun/bin:$PATH" home`.
29
+ - **`--json` shapes.** `home --json` is one object keyed by connector, with `now` (`energyNow`) first. `home <connector> --json` is that connector's object: `status`, `needs`, `access`, `detail`, `anomaly`, then its state (the weather adds `line`, its text). Lists (`home up transactions`, `uncategorised`, `spend --by merchant`) are arrays, `[]` when empty.
30
+ - **Errors under `--json`** are one object on stderr: `{"error": "…", "code": "usage" | "auth" | "network" | "error", "fix": "home enphase login"}`; `fix` is there only when one command resolves it. In text: `home: <error> (run \`<fix>\`)`.
31
+ - **Exit codes.** 0 success, empty results included. 1 a failure, and `home <connector>` when that connector is not `connected`, so a script can test one. 2 a wrong command, verb or argument. `home` alone lists every connector and exits 0 whatever their state.
32
+
33
+ With `--config <home.jsonc>` or `HOME_CONFIG`, `status` runs the integrations the config describes until each has answered (at most 15 s) and `probe` takes its addresses from it; keys still come from the shell, by the names in the file. Without one, it reads `HUE_*` and the other names in `.env.example` from the shell, and parses arguments with [cac](https://github.com/cacjs/cac), which also generates `home --help`. Every connector has a verb of its own: `home amber`, `home cast`, `home hue` print only that connector's lines (`--json` for its record), and the verbs that need more sit under it (`home hue pair`, `home enphase login`, `home cast announce …`). The commands come from each connector's manifest (`src/manifest.ts`), as does `.env.example` (`bun run gen`). With no `HUE_BRIDGE_HOST` it finds the bridge by mDNS (`dns-sd`, macOS) every time and caches nothing. `home` with no arguments is `status`:
34
+
35
+ ```
36
+ hue not set up · needs HUE_APPLICATION_KEY, HUE_CA_FILE (local HTTPS, key from `home pair`)
37
+ daikin connected · off · cool 25 °C · room 24 °C · outdoor unit 19.5 °C (local HTTP JSON (dsiot), no key)
38
+ weather connected · Sydney - Observatory Hill (1.1 km) 20:20: 16.6 °C, feels 16.7, 93%, 23.6 mm since 9am to 20:23 · today showers rain 80%, tomorrow 14–19 °C cloudy rain 30% (bom) · cloud 100%, UV 0, 0 W/m² (open-meteo) · sun -17°, setting, rises 06:15 (BOM anonymous FTP, Open-Meteo, Google with GOOGLE_WEATHER_KEY; sun computed)
39
+ ```
40
+
41
+ Startup is about 10 ms and 38 MB RSS for `home status` without a key; with Enphase, Amber and the Daikin live it takes 0.4–0.8 s, the devices read in parallel (Bun 1.4.2, Apple M5, measured 2026-10-13). The weather line adds 1.8–2.0 s, nearly all of it BOM's FTP (two files, one connection each). Cast takes about 0.3 s: `dns-sd` answers from the system's mDNS cache in 4–13 ms, the browse waits 200 ms of quiet for stragglers, each resolve ends on its TXT line, and seven sockets connect and answer in 50–80 ms (`home status` from a Cast-only config: 0.33–0.44 s, was 3.2 s with fixed 1.5 s windows; Bun 1.4.2, measured 2026-10-13). A configured entry discovery misses costs the second pass, 5 s more. `status` reads addresses from the environment only, except Cast, which has none to read; `discover` and `probe` do the other network searches. A full-screen TUI was considered (OpenTUI 0.6.2, 52 MB of native binaries) and skipped for now.
package/docs/config.md ADDED
@@ -0,0 +1,36 @@
1
+ # The home config
2
+
3
+ One JSONC file lays out the home, so the `home` command, jaldi and chota-bot read the same thing: where the home is, the weather sources, the rooms (matching the drawing), and each device by connector. [`examples/home.jsonc`](../examples/home.jsonc) is an invented one; [`home.schema.json`](../home.schema.json) gives an editor completion (`"$schema"`).
4
+
5
+ ```ts
6
+ import { readFileSync } from "node:fs";
7
+ import { dirname, resolve } from "node:path";
8
+ import { createHome, loadHome, parseJsonc } from "@khalido/home";
9
+ import { ftpFetch } from "@khalido/home/weather/ftp";
10
+
11
+ const path = "/path/to/home.jsonc";
12
+ const readFile = (p: string) => readFileSync(resolve(dirname(path), p), "utf8"); // paths in the config are relative to it
13
+ const home = createHome(
14
+ loadHome(parseJsonc(readFileSync(path, "utf8")), {
15
+ env: process.env,
16
+ readFile,
17
+ ftpFetch: ftpFetch(),
18
+ }),
19
+ );
20
+ ```
21
+
22
+ `loadHome` runs on Bun and Node. Hue and Enphase need a fetch that handles their certificates: Bun's `tls` fetch option by default, and under Node `tls: NODE_TLS` from `@khalido/home/node`. Under Node, a config with Hue or Enphase and no `tls` is a problem `loadHome` names, not a silent failure at the first read.
23
+
24
+ - **A device entry is only how to reach it and where it is**: its identity (the Hue `bridgeId`, a Daikin's `mac`, the Enphase `serial`, a Cast `castId`), its last known `host`, a key's env name, a file's path, its `room`. What a device reports about itself (model, firmware) comes live; a note about it is a comment.
25
+ - **Secrets are env variable names** (`"key": "AMBER_KEY"`), resolved against the `env` the caller passes; a pasted key fails the schema. Files (`ca`, `token`) are paths the caller reads, relative to the config; the library reads nothing.
26
+ - **Problems name the field**: an unknown connector, a missing env name or a device in a missing room is an error, never a skip. `checkHome` returns them all; `loadHome` throws them all. Unknown keys pass through for a consumer's own settings.
27
+ - **One device per connector** for now (each integration is named by its connector), except printers (one entry each, by `uuid`) and Cast: one entry per device and speaker group (`name`, the Google Home name, and `castId`, the mDNS id), all read by one `cast` integration that finds every address by discovery, since a group's moves with its leader. A `spotify` entry is only its Soloist `url` (and `maxVolume`): a socket on the same machine, with nothing to discover.
28
+ - **Identity, not address.** A device is recognised by the id its own discovery reports; `host` is a cache. `home discover` lists what answers (mDNS, Daikin's UDP broadcast), in two passes 5 s apart since both drop answers now and then: the first pass prints at once, the second only what it adds (`--passes 1` for a quick look; `--json` waits for both); with a config (`--config` or `HOME_CONFIG`) it then matches those against it by identity and prints new devices, moved addresses and conflicts (a different device at an entry's host). `home discover --write <file>` adds the new ones (each with an id no entry has), updates moved hosts and refreshes the `name` of a Cast entry renamed in the Google Home app through `jsonc-parser`'s edits, every comment and other line left as it was, and writes only a result that passes `checkPlan`. Nothing else writes the file; the library never does. Two devices with one id are a config problem.
29
+ - **A device that moves is found again, without periodic scans**, when the caller passes `finders` to `loadHome` (`FINDERS` from `@khalido/home/finders`, Node-side like `weather/ftp`; the CLI does, and jaldi and chota-bot can). After three failed reads in a row (Hue: three failed reconnects of its event stream), Hue, Daikin and Enphase run their discovery (two passes, the second skipped when the first finds the device), switch to the address their identity now answers at and say "moved from a to b" in `detail` (persist it with `home discover --write`); otherwise they look again at 1, 5 and 15 minutes, then hourly for as long as reads fail, so a device back from the wall socket reads again without a restart. The detail says what discovery found ("not found by discovery", or "discovery finds it at its address but reads fail", or "discovery failed: …" when it could not run), and "no answer for a day" once a day has passed; the part stays `disconnected`, since `unavailable` is for a rejected key. `refresh()` resets the wait. Amber and the weather have no discovery and do not take part. Cast always needs `finders.cast`: discovery is how it finds every address.
30
+ - **Discovery is rare: addresses are cached between runs.** `loadHome(config, { cache, configAt })` takes an `AddressCache` (`read()` and `write(entries)`, keyed by connector and identity, each entry `{ host, port?, name?, seenAt }`). A cached address newer than the config (`configAt`, the file's modified time) is tried first, a failure there scans at once rather than after three, and each address that answers is written back when it changed or predates the config, so a config edit is re-trusted at the first good read. Nothing evicts an entry but `home cache clear`: a device gone for good keeps its entry, and `home status` without a config, which reads every Cast entry from the cache, shows it as not found until then. The consumer persists it: the CLI keeps `$XDG_CACHE_HOME/home/addresses.json` (default `~/.cache/home/addresses.json`, 0600) through `fileCache` from `@khalido/home/finders`, written at the end of `status`, `probe` and `discover`; jaldi's sidecar and chota-bot pass `FINDERS` and their own cache the same way. The config's `host` stays the human-set fallback and `home discover --write` the way to change the config. `home status` from a Cast config: 0.33–0.44 s cold, 0.13 s with a warm cache, 1.4 s once when a cached address has gone stale (Bun 1.4.2, measured 2026-10-13).
31
+ - **Parsing** is through `jsonc-parser` (comments and trailing commas), on Bun and Node alike; a syntax error throws. `bun run schema` rewrites `home.schema.json` from the zod schema, and a test fails if they drift.
32
+ - `createHome` stays for code-first use.
33
+
34
+ ## Drawing the home
35
+
36
+ The library doesn't draw. [`skills/draw-home/SKILL.md`](../skills/draw-home/SKILL.md) guides a coding agent from photos or a floor plan to a themeable `home.svg` with one marker per room, and writes the rooms into the home config below. Start from [`examples/`](../examples/); `checkPlan` validates the rooms.
@@ -0,0 +1,150 @@
1
+ # Decisions
2
+
3
+ KO's decisions on this package, newest block last. They override every other doc where they conflict. Add to the end as decisions are made; repoint links when a file moves, never rewrite the text. Until 2026-10-14 the record lived in jaldi's `docs/home-control.md`; this is the package's part of it, with addresses and device names left out.
4
+
5
+ # 2026-10-13
6
+
7
+ ## What this is
8
+
9
+ - **Our own library, not an existing one.** `node-hue-api` is not CLIP v2, `Hue-Sync` has no event stream and pairs over plain HTTP, `openhue-cli` is a process per call that cannot hold a stream, and Home Assistant is a server to run. A typed `fetch` client pins, keeps alive and streams in Bun and Node.
10
+ - **Runtime-neutral, keys passed in.** `fetch`, Web Streams and zod only. Callers pass address, key and a pinned `fetch`. The library never reads the environment or a file and never writes a key; the `home` CLI is the only layer that reads a shell.
11
+ - **The snapshot is the cache.** Every read comes from memory. A slow read happens on the integration's own timer, never on a caller's read. The last good state survives a failed poll with its `asOf`.
12
+ - **Every field optional, nothing corrected.** An odd reading passes through with an `anomaly`; the Enphase daily counters equalling lifetime is the first case, and "today" is counted locally while they are equal.
13
+ - **Home Assistant is the reference.** Its integration source is where the protocol facts live; each connector states its `iot_class` and uses its poll interval. Its quality scale is the checklist. GPL sources (pydaikin, ha-daikin-alira) are read for facts only.
14
+ - **One folder per device**, each with a README of official docs, sources with licences and live findings.
15
+ - **Away from home is out of scope.** Off the LAN the local connectors say so. Amber and the weather are cloud services and are polled as such.
16
+
17
+ ## Hue
18
+
19
+ - **Pin the CA, never bypass.** Trust Signify's root CA and check the certificate's CN equals the bridge id. `rejectUnauthorized: false` would hand the key to any device at the bridge's address.
20
+ - **The event stream through `fetch`, not `EventSource`**: the WHATWG constructor takes no headers, and the key is a header.
21
+ - **Writes spaced to Signify's limits**: grouped lights and scenes 1 s apart, lights 100 ms apart. "Lights off" is one PUT to the home group.
22
+ - **A 90 s idle watchdog** reconnects a silent stream without a `disconnected` or a backoff step, and the bridge is found again by its id (see Discovery). Whether the bridge sends keep-alives on a quiet night is unchecked until it is paired.
23
+
24
+ ## Daikin
25
+
26
+ - **The local `dsiot` API, not BRP069** (its paths 404 on the unit). Reads every 120 s, Home Assistant's interval.
27
+ - **The first write happens with KO at the unit**, then a re-read: the two sources disagree on setpoint width, POST vs PUT and hex case. Until then writes are built and tested on fixtures only.
28
+
29
+ ## Enphase
30
+
31
+ - **Local IQ Gateway with an owner token**, minted once a year by `home enphase login` into `~/.config/home/`, 0600; the password goes only to Enphase's login service. The cloud v4 API was not chosen (OAuth, 10 requests a minute on the free plan, site-level data only).
32
+ - **The fast meter endpoints** (`/ivp/meters/readings`, about 100 ms) over `/production.json` (1 to 10 s).
33
+ - **Verification skipped for the gateway only**: its chain ends in an unpublished Enphase root.
34
+
35
+ ## Amber and energy
36
+
37
+ - **Amber's own descriptor and spike status**, no threshold of ours; `negativePrice` and `estimate` as information.
38
+ - **`energyNow` is the one owner** of solar, use, grid flow, surplus and cost per hour; every consumer calls it. A 20 W display dead band for "balanced"; `gridW` itself is untouched.
39
+ - **SmartShift and any automatic curtailment rejected**: a write nobody asked for.
40
+
41
+ ## Weather
42
+
43
+ - **BOM over anonymous FTP**: its app API forbids use. **Open-Meteo by default** for sky and as the fallback, BOM's précis opt-in by location, **Google Weather last**, keyed, with a 30-minute floor since it bills.
44
+ - **One 15-minute cadence** at :05, :20, :35, :50, with per-source floors; forecasts and the hourly sky read hourly.
45
+ - **`sunNow` is computed locally** and owns the sun's booleans and next events; Google's sun times are only a check, and a difference over 2 minutes is an anomaly on Google's side.
46
+ - **Odd timestamps are flagged, not thrown.**
47
+
48
+ ## Refresh
49
+
50
+ - **Every integration can be told to read now**: `home.refresh(name?)`. One shared poller (`poll.ts`) replaces each connector's own timer. A billed source's floor holds on refresh; Hue's refresh is one full GET and keeps its events.
51
+
52
+ ## The home config
53
+
54
+ - **One JSONC file** (`home.jsonc`): location, weather sources, rooms matching the drawing, devices by connector. `checkHome` returns every problem; `loadHome` throws them all. Unknown keys pass through.
55
+ - **A device entry is only how to reach it and where it is**: identity, last known `host`, a key's env name, a file's path, its room. What a device reports about itself comes live.
56
+ - **Secrets are env variable names**, never values; a pasted key fails the schema. Files are paths relative to the config, read by the caller.
57
+ - **One device per connector, except Cast**: one entry per device and group, one `cast` integration.
58
+ - **`loadHome` is Bun only for now**: Hue and Enphase use Bun's `tls` fetch option. Per-connector fetches come when a Node consumer does.
59
+ - **One meaning for `marker`**: a room's Hue binding is `hueRoom`.
60
+
61
+ ## Discovery and moves
62
+
63
+ - **Identity, not address.** A device is recognised by the id its own discovery reports (`bridgeId`, `mac`, `serial`, `castId`); `host` is a cache.
64
+ - **Two passes, 5 s apart**, since mDNS and the Daikin broadcast drop answers; the second is skipped when the first finds every configured device. `dns-sd` queries end on the answer, not a fixed window.
65
+ - **A rescan only after three failed reads**, then at 1, 5 and 15 minutes and hourly. A device is never `unavailable` for being unreachable; `unavailable` is a rejected key. The detail says what discovery found.
66
+ - **An address cache between runs**, consumer-owned (`AddressCache { read, write }`); the CLI keeps `~/.cache/home/addresses.json`. A cached address newer than the config is tried first; a failure there scans at once; a confirmed address after a config edit is re-trusted at the first good read. Nothing evicts an entry but `home cache clear`.
67
+ - **`home discover --write` is the only thing that edits the config**, through `jsonc-parser`, and only a result that passes `checkPlan`. The library writes no files.
68
+
69
+ ## Cast
70
+
71
+ - **A small CastV2 client of our own** over `node:tls`: the existing packages are untyped, load a `.proto` at runtime and hardcode `rejectUnauthorized = false`. One socket per device and group, the name on the self-signed certificate checked.
72
+ - **Writes are calls on the object, never made by the integration**: `setVolume`, `setMuted`, `stop`, `announce`. `announce` interrupts another app and reports it in `notRestored` rather than refusing.
73
+ - **`serveClip` lives in the library** (Node-only subpath) so a bot can announce a generated clip.
74
+ - **Not the Local Home SDK** (needs a cloud fulfilment path we do not have). **A Matter bridge only later, in its own package.**
75
+
76
+ ## Drawing
77
+
78
+ - **The library doesn't draw.** `skills/draw-home` guides an agent from photos or a plan to an SVG and the config's rooms; `checkPlan` validates them. Element ids equal room or device ids, state by `data-state`, the ha-floorplan way.
79
+
80
+ ## Not built, on purpose
81
+
82
+ - No rules engine: automations belong in the apps, once the numbers have been watched.
83
+ - No TUI (OpenTUI brings 52 MB of native binaries).
84
+ - No plugin host; a device KO does not own is a device this library does not cover.
85
+
86
+ # 2026-10-14
87
+
88
+ - **Connectors live in `src/connectors/<name>/`**, core files at `src/`. Public import paths (`@khalido/home/hue`) are unchanged through `exports`. Done before the count grew.
89
+ - **Two words, two meanings.** A _connector_ is the folder, its factory and the `connector:` field in the config. An _integration_ is the running object a factory returns (`Integration` in `home.ts`).
90
+ - **A shared layer for what was byte-identical, nothing more**: one `ConnectorError`, one `TIMEOUT_MS`, `fetchTimeout` merging the run's signal (so a shutdown cancels a read in flight), `getJson` for the JSON connectors, `asOf(clock)`. The weather error is not a device error: Hue keeps its error-envelope parsing and BOM its text fetch on top of `fetchTimeout`, while the JSON weather sources use `getJson`. The poll loop and the relocator block stay per connector until this layer has settled.
91
+ - **Docs layout.** `AGENTS.md` is the main file; `CLAUDE.md` only imports it. The README is for a visitor: what, a map, the connectors, install, links. A connector's README is the one file for working in that folder (no per-folder AGENTS.md: six stubs is scaffold). `docs/` holds design, decisions, investigations and the handover. `docs/handover.md` is read first and rewritten clean, never appended.
92
+ - **`home discover` says when it is waiting**: a progress line on stderr before each later pass.
93
+ - **The repo is `khalido/home.ts` on GitHub**; the package stays `@khalido/home`.
94
+ - **The CLI is derived from the library.** `cli.ts` splits into `src/cli/` on [cac](https://github.com/cacjs/cac). Each connector exports a `manifest` (name, title, `iotClass`, poll seconds, access text, env names, file fields, status lines) that drives `home status`, `home <connector>`, `.env.example` and the README's connectors table, so one declaration feeds the library, the CLI and the docs. Connector verbs that need Node (pairing, logins, Cast writes) live in `src/cli/<connector>.ts`.
95
+ - **An Up Bank connector is being shaped** (`docs/ideas.md`): read-only by default; whether the category and tag writes ship from the start is open.
96
+ - **Amber is for live prices, not cost logging.** The usage endpoint work (last month's cost per channel, a daily cache, `home amber usage`) was built and reverted the same day: Amber's API is there to adjust use to the price, and a bill is the record of cost. The commit survives as `ceaa2ba` if the question comes back.
97
+ - **`home.refresh(name?)` returns a promise** resolved by each target's next report, so a one-shot consumer can await a read.
98
+ - **Printers are a connector**, read-only first: AirPrint discovery over the existing dns-sd finder (`_ipp._tcp`, `_ipps._tcp`; identity the TXT `UUID`), status over IPP Get-Printer-Attributes with a hand-rolled codec like Cast's, one shared rescan rather than a relocator per printer, an unreachable printer `offline` with its last seen for `offlineDays` (14) before `anomaly`, never `unavailable`. Identify-Printer then Print-Job come later, behind KO's hand.
99
+ - **Up Bank's token is `UP_TOKEN`**, in the repo's gitignored `.env` for testing; the connector issues GETs by default and its writes are explicit verbs.
100
+
101
+ # 2026-10-15
102
+
103
+ - **Rain soon from Google's minute forecast, two hours.** A `rain` slice on the weather connector, owned by Google alone, kept to 2 hours of 15-minute segments. `rainSoon` is the one function that says when rain starts and ends. Google's floor drops from 30 to 15 minutes; current conditions stay at 30 inside that read. The endpoint is Experimental, so its failure never stops the other slices. The hourly rain chance as a fallback, an `hourly` slice and one condition word come next, not now.
104
+ - **Calendar later, Google first.** A calendar connector reads Google Calendar; an ICS source (laggy) can come too. Google is not one connector: Weather and Calendar share a name only, and a shared Google helper comes when a third service needs it. The calendar waits. Design and open calls: `docs/google.md`.
105
+
106
+ # 2026-10-16
107
+
108
+ - **Going public: one squashed commit, a private repo first.** `khalido/home.ts` starts private with `main` as one commit under the GitHub noreply address; KO flips it public after a look. The history before it stays local.
109
+ - **Every export is named.** No `export *` anywhere: each entry point lists what it offers, and `test/exports.test.ts` fails when that list changes. The root drops `fetchTimeout`, `getJson`, `TIMEOUT_MS`, `MANIFESTS` and `manifestOf` (internals and CLI concerns); connectors drop their codecs, schemas, parsers and event streams. Done before npm, while no one imports it.
110
+ - **`weatherNow` is now `mergeWeather`**: it merges source readings into every slice, and the type `WeatherNow` is only the now slice. The old name does not come back with another meaning.
111
+ - **Up Bank's token works from the repo's `.env`.** A session whose environment holds an older `UP_TOKEN` wins over `.env` in Bun; unset it for live reads.
112
+ - **Up Bank is a connector**, built to `docs/up.md`: reads every 15 minutes, the category and tag writes and the webhook calls only as client methods and `home up` verbs. Accounts are picked by type and ownership (`JOINT TRANSACTIONAL`), never by id. "To categorise" counts spend only: salary and payments received are categorisable too, but are not what needs a category. Webhook secrets go to a 0600 file, never the terminal.
113
+ - **What the library covers: the house and what the household shares.** Devices on the LAN, plus what every app in the house wants: prices, weather, transport nearby, a family calendar, the household account. A per-person tool one consumer uses stays in that consumer and moves here when a second consumer wants it, the same rule as the Daikin ramp. So TickTick stays in chota-bot. The rule that a device KO does not own is not covered still holds; the survey in `docs/ideas.md` is a guide for anyone extending it.
114
+ - **Up has a household filter**: Up category ids and tags that count as the family's (`home` and `tag:home`, say), so the snapshot a dashboard shows holds shared spending only. Personal purchases stay out of it unless tagged in.
115
+ - **Weather by the hour and by part of the day.** An `hourly` slice (today and tomorrow, Open-Meteo's, from the call it already makes once an hour) and `dayBlocks`, the one owner of morning 6–12, afternoon 12–18 and evening 18–22, cut in the home's time zone. One `condition` word per hour, from a fixed list mildest first, so a block takes its worst hour. chota-bot asked for these to replace its own weather code. Weather times show in the home's zone (`location.timezone`, `HOME_TIMEZONE`), not the machine's.
116
+ - **A typed snapshot.** Each factory returns `Integration<State, "name">`, so `createHome` maps names to states: `home.part("weather")?.state` is a `WeatherState`, and a name the home lacks does not compile. `loadHome` returns `HomeIntegration[]`, the union of every connector, so the same holds for a home built from a config. The CLI stays untyped by name. Types only: nothing changes at runtime.
117
+ - **Discovery on Linux asks avahi.** On a Pi (Raspberry Pi OS, Debian trixie) the mDNS finders spawn `avahi-browse --parsable --terminate --resolve`, as they spawn `dns-sd` on macOS: the system daemon keeps the mDNS cache, so nothing binds UDP 5353 beside it and a slow device does not slow a scan. It needs `apt install avahi-utils` (avahi-daemon runs by default); without it discovery says so. Tried and dropped: `bonjour-service` (pure JS), which misread empty TXT values and, with no system cache, saw the Enphase gateway only after 4 s (2026-10-16); `multicast-dns` alone is stale. D-Bus to avahi would bind no port either but adds dependencies for the same answer. Checked in Debian trixie containers (avahi-utils, Bun 1.3.4, 2026-10-16): about 1 s per service type, the types in parallel. Not yet on the Pi itself.
118
+
119
+ # 2026-10-19
120
+
121
+ - **Writes coalesce; a runaway slows down, never gets refused.** Every device write goes through a write lane (`src/lane.ts`): one send at a time, spaced by the device's gap, coalesced per slot (a light, the AC, a speaker's volume). A newer write to a slot whose write is still waiting merges into it, field by field: the middle drops, the last value lands. A burst of ten sends the first at once and the last after the gap. A loop slower than the gap still gets every write through, so an effect built from writes keeps its rate. A slot over its per-minute rate waits for its turn rather than failing, so a person's last change always arrives. A merged write resolves with the send that carried it. Rates are guesses until a device shows otherwise: Hue as before (lights 100 ms apart, groups and scenes 1 s, no cap: the gap is the protection), Daikin 1 s apart and 20 a minute (the BRP084 publishes nothing; the compressor protects itself), Cast level and mute each 30 a minute per device, a lane per device. Not coalesced: Cast announcements and prints, since each is different content. For prints (chota's, not this library's): queue in order and never drop one; past a count an hour, say so rather than refuse. Reporting a slowed slot in the part's `anomaly` comes with writes that return the resulting state. Weighed: a budget that refuses writes past a count (Home Assistant's Digital Alchemy guards runaway service calls that way), dropped because a refused AC change or a refused print is worse than a slow one.
122
+
123
+ # 2026-10-28
124
+
125
+ - **Before 0.1.0, Hue's `ip` is `host`**, like every other connector. A breaking rename, done while nothing imports the package.
126
+ - **Node gets its own fetches: `@khalido/home/node`.** Node's `fetch` takes no CA, so Hue's pinned fetch and Enphase's unverified one use undici's `fetch` and `Agent`. `loadHome` takes them as `tls` (`NODE_TLS`); Bun's stay the default. undici is a dependency, not an optional peer: chota-bot on Node gets it with one install, at the cost of about 2.5 MB in a Bun install that never loads it.
127
+ - **`settle` is in the library**: run integrations until each has answered, then stop. The CLI and chota-bot's one-shot reads share it.
128
+ - **Amber reads its forecast; `bestWindow` picks the hours to run something.** The same 60 s call asks for `next=96`, so the forecast costs no extra calls. `bestWindow` (cheapest by price or greenest by renewables) lives beside `energyNow` as the one owner, so chota-bot's brief and jaldi say the same thing.
129
+ - **Health is judged from times `createHome` stamps.** Each part carries `lastGoodAt` (its last connected report) and `downSince` (when it stopped being connected, or started, if it never was). An integration sets neither, except that a connected report repeating an older read passes that read's time as `lastGoodAt` (weather's 5-minute sun tick, Hue's failed refresh), so a re-report never counts as a read. `health(snapshot, now)` says ok, stale or down per integration with its thresholds: a polling connector is stale after 2½ polls with no good read (two missed, and half of one so a read that times out does not flap it) and down after 3 polls not connected; a push one (Hue, Cast) is never stale and is down after 2 minutes not connected; `unavailable` is down at once. `onChange` gets the change as a second argument, `{ name, before, after }`, typed by name. chota-bot asked for both, for its admin page and its job alerts. Weather is `connected` only while some source's latest read brought something new; a read that returns only its last good slices is a failed read, `disconnected` with the data kept, as for every other connector.
130
+ - **The parts of the day are a consumer's choice.** `dayBlocks` and `weather()` take `blocks`, so chota-bot's Morning, Noon, Evening and Night come from the library; KO's three stay the default. A block's name is any string, and each block carries its highest UV index.
131
+ - **Writes hang off the integration.** Hue and Daikin expose `client`, Cast its actions; `home.integration(name)` hands one back, typed, so an app built from `loadHome` reaches the writes without keeping the array. Each client lives as long as its integration and reads the device's address at each send, so a move keeps the write lane and its waiting writes. A second `daikinClient` for writes is no longer needed.
132
+ - **A Daikin 404 is a failed read, not `unavailable`.** Another device may have taken the AC's address; discovery now looks for it. `unavailable` stays for a rejected key.
133
+ - **A move is reported by the read at the new address.** Daikin, Enphase and Hue no longer send a `disconnected` "moved" report before reading there, so a one-shot read (`settle`, `home status`) waits for that read, and the address book keeps the new address. If that read fails too, its failure is the detail and the move shows only once a read succeeds.
134
+ - **Under Node, `loadHome` names a missing `tls`** for Hue and Enphase, rather than defaulting to Bun's fetch and failing at the first read. Error details carry the cause (`fetch failed: connect ECONNREFUSED …`), which is where Node puts the reason.
135
+
136
+ # 2026-11-08
137
+
138
+ - **A `spotify` connector over Spotify Soloist**, for chota-bot first: Spotify's own headless Connect client, so the box is a speaker in every phone's Spotify app, and its local WebSocket gives state and control. Not go-librespot (reverse-engineered) and not the Web API (a cloud account, five users in development mode). Ported from chota-bot's client onto the connector shape: the repo's `Clock` for every timer, every field optional, zod per frame, writes through `writeLane`. `local_push`, one per home, no key (Soloist's key is the daemon's).
139
+ - **Signed out is `connected`, not `unavailable`.** The socket is up and Soloist is answering; `unavailable` means stopped for good, and a restart of Soloist with a good key fixes it. `loggedIn: false` and the `detail` say it, and writes are refused unsent. A live Soloist (2026-11-08) is signed out as its normal state until a phone picks it, which confirms `connected` rather than a fault; the detail names waiting for a phone first and a bad key second.
140
+ - **`maxVolume` is a write policy**, not a correction of a reading: requests are held to it, and a first play that starts above it (Soloist issue #23) is lowered once, said in `anomaly`. The reported volume stays what Soloist says.
141
+ - **No discovery, no `IDENTITY` entry**, a departure from "Adding a connector" steps 4 and 5, and no `probe` entry (step 6): the connector reaches a WebSocket on loopback, so there is no address to find, follow or probe. `url` is the whole of its reach.
142
+ - **The default URL is ours, not Soloist's**: `ws://127.0.0.1:9091`, the port chota-bot's unit opens. Soloist has no default; `--ws` turns the API on.
143
+ - **The systemd units stay in chota-bot** (and in a local, untracked draft until chota switches), not `examples/`: they carry chota's paths and user, and how to run Soloist is the consumer's business.
144
+ - **Spotify writes resolve with what Soloist then reported**, `{ state, confirmed }`, waiting up to 2 s for the event that shows the caller's own change (a write merged into another confirms only if its own change shows); unconfirmed is a result, not an error. The first connector to do Next item 2; Hue and Daikin follow the same shape when they get it. chota-bot asked, so the bot says "volume's at 40" only when Soloist said 40.
145
+ - **`duck` and `interrupt` leave a person's change alone.** If someone resumed, skipped or set the volume from a phone while `fn` ran, the music stays as they left it, and the result says so: `music` is `untouched`, `restored`, `kept` or `failed` (Gemini's point: one `restored: false` hid four outcomes a bot answers differently). One aside at a time. `duck` restores the earlier volume even above `maxVolume`: the cap holds requests, and the earlier volume was the person's.
146
+ - **Before 0.1.0, not after**, since these change the writes' return types.
147
+ - **Spotify's change events, agreed with chota-bot (8 Nov):** `spotifyChanges(before, after)` is a pure helper on two Parts, not part of `onChange`; a second connector that wants events gets its own until three share a shape. `resumed` stays apart from `started`; no volume or queue events; nothing on the first report, so a restart announces nothing; a start comes before its track. Playback events while the library's own `duck` or `interrupt` runs are marked `own` (through `state.aside`) rather than dropped, so a consumer can still see them; connection and sign-in events never are. The connector reports nothing while connecting, so a reconnect goes straight from `disconnected` to `connected` and no change hides in between.
148
+ - **Signed out reads as waiting**, everywhere: `speakerState` says `waiting`, the detail says "waiting for someone to pick it", the write error's code is `waiting`.
149
+ - **Stalls (Soloist issue #5) are noticed by default and repaired only when asked** (`repairStalls: true`): the repair is the library's first write nobody asked for, so a consumer opts in. Two signals, since the issue doesn't say whether the reported position freezes: a frozen position across fresh reports, and still playing well past the track's end, the symptom actually reported. One repair per track, never a loop. The nudges (seek, then pause and play; a skip past the end) are guesses until a stall is seen live.
150
+ - **The project is home.ts; the npm package stays `@khalido/home`** (KO, 2026-11-08), so imports stay short (`@khalido/home/spotify`). It is home automation for TypeScript, starting with the devices in KO's house, and is meant to grow into more of the basics.