@khalido/home 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/.env.example +25 -0
  2. package/CHANGELOG.md +47 -0
  3. package/LICENSE +21 -0
  4. package/README.md +77 -2
  5. package/dist/clock.d.ts +17 -0
  6. package/dist/clock.js +43 -0
  7. package/dist/config.d.ts +273 -0
  8. package/dist/config.js +392 -0
  9. package/dist/connector.d.ts +34 -0
  10. package/dist/connector.js +47 -0
  11. package/dist/connectors/amber/client.d.ts +61 -0
  12. package/dist/connectors/amber/client.js +106 -0
  13. package/dist/connectors/amber/index.d.ts +30 -0
  14. package/dist/connectors/amber/index.js +94 -0
  15. package/dist/connectors/cast/codec.d.ts +38 -0
  16. package/dist/connectors/cast/codec.js +102 -0
  17. package/dist/connectors/cast/index.d.ts +60 -0
  18. package/dist/connectors/cast/index.js +428 -0
  19. package/dist/connectors/cast/schemas.d.ts +167 -0
  20. package/dist/connectors/cast/schemas.js +87 -0
  21. package/dist/connectors/cast/serve.d.ts +18 -0
  22. package/dist/connectors/cast/serve.js +57 -0
  23. package/dist/connectors/cast/state.d.ts +62 -0
  24. package/dist/connectors/cast/state.js +133 -0
  25. package/dist/connectors/cast/tls.d.ts +3 -0
  26. package/dist/connectors/cast/tls.js +67 -0
  27. package/dist/connectors/cast/writes.d.ts +80 -0
  28. package/dist/connectors/cast/writes.js +390 -0
  29. package/dist/connectors/daikin/client.d.ts +65 -0
  30. package/dist/connectors/daikin/client.js +221 -0
  31. package/dist/connectors/daikin/index.d.ts +41 -0
  32. package/dist/connectors/daikin/index.js +108 -0
  33. package/dist/connectors/enphase/client.d.ts +65 -0
  34. package/dist/connectors/enphase/client.js +173 -0
  35. package/dist/connectors/enphase/index.d.ts +43 -0
  36. package/dist/connectors/enphase/index.js +174 -0
  37. package/dist/connectors/enphase/schemas.d.ts +51 -0
  38. package/dist/connectors/enphase/schemas.js +34 -0
  39. package/dist/connectors/hue/client.d.ts +48 -0
  40. package/dist/connectors/hue/client.js +95 -0
  41. package/dist/connectors/hue/events.d.ts +41 -0
  42. package/dist/connectors/hue/events.js +156 -0
  43. package/dist/connectors/hue/index.d.ts +42 -0
  44. package/dist/connectors/hue/index.js +205 -0
  45. package/dist/connectors/hue/schemas.d.ts +65 -0
  46. package/dist/connectors/hue/schemas.js +48 -0
  47. package/dist/connectors/hue/state.d.ts +43 -0
  48. package/dist/connectors/hue/state.js +84 -0
  49. package/dist/connectors/printers/client.d.ts +28 -0
  50. package/dist/connectors/printers/client.js +74 -0
  51. package/dist/connectors/printers/codec.d.ts +76 -0
  52. package/dist/connectors/printers/codec.js +246 -0
  53. package/dist/connectors/printers/found.d.ts +15 -0
  54. package/dist/connectors/printers/found.js +35 -0
  55. package/dist/connectors/printers/index.d.ts +52 -0
  56. package/dist/connectors/printers/index.js +282 -0
  57. package/dist/connectors/printers/state.d.ts +60 -0
  58. package/dist/connectors/printers/state.js +99 -0
  59. package/dist/connectors/spotify/changes.d.ts +32 -0
  60. package/dist/connectors/spotify/changes.js +84 -0
  61. package/dist/connectors/spotify/client.d.ts +199 -0
  62. package/dist/connectors/spotify/client.js +270 -0
  63. package/dist/connectors/spotify/index.d.ts +92 -0
  64. package/dist/connectors/spotify/index.js +754 -0
  65. package/dist/connectors/up/client.d.ts +183 -0
  66. package/dist/connectors/up/client.js +308 -0
  67. package/dist/connectors/up/index.d.ts +91 -0
  68. package/dist/connectors/up/index.js +213 -0
  69. package/dist/connectors/up/spend.d.ts +78 -0
  70. package/dist/connectors/up/spend.js +213 -0
  71. package/dist/connectors/up/webhook.d.ts +13 -0
  72. package/dist/connectors/up/webhook.js +32 -0
  73. package/dist/connectors/weather/blocks.d.ts +51 -0
  74. package/dist/connectors/weather/blocks.js +106 -0
  75. package/dist/connectors/weather/bom.d.ts +37 -0
  76. package/dist/connectors/weather/bom.js +237 -0
  77. package/dist/connectors/weather/ftp.d.ts +5 -0
  78. package/dist/connectors/weather/ftp.js +46 -0
  79. package/dist/connectors/weather/google.d.ts +31 -0
  80. package/dist/connectors/weather/google.js +364 -0
  81. package/dist/connectors/weather/index.d.ts +53 -0
  82. package/dist/connectors/weather/index.js +280 -0
  83. package/dist/connectors/weather/open-meteo.d.ts +15 -0
  84. package/dist/connectors/weather/open-meteo.js +285 -0
  85. package/dist/connectors/weather/rain.d.ts +19 -0
  86. package/dist/connectors/weather/rain.js +51 -0
  87. package/dist/connectors/weather/source.d.ts +149 -0
  88. package/dist/connectors/weather/source.js +77 -0
  89. package/dist/connectors/weather/sun.d.ts +48 -0
  90. package/dist/connectors/weather/sun.js +65 -0
  91. package/dist/discover.d.ts +197 -0
  92. package/dist/discover.js +452 -0
  93. package/dist/energy.d.ts +61 -0
  94. package/dist/energy.js +162 -0
  95. package/dist/finders-entry.d.ts +1 -0
  96. package/dist/finders-entry.js +2 -0
  97. package/dist/finders.d.ts +48 -0
  98. package/dist/finders.js +176 -0
  99. package/dist/health.d.ts +28 -0
  100. package/dist/health.js +35 -0
  101. package/dist/home.d.ts +81 -0
  102. package/dist/home.js +114 -0
  103. package/dist/index.d.ts +11 -0
  104. package/dist/index.js +11 -0
  105. package/dist/lane.d.ts +17 -0
  106. package/dist/lane.js +74 -0
  107. package/dist/locate.d.ts +27 -0
  108. package/dist/locate.js +59 -0
  109. package/dist/manifest.d.ts +31 -0
  110. package/dist/manifest.js +4 -0
  111. package/dist/manifests.d.ts +5 -0
  112. package/dist/manifests.js +23 -0
  113. package/dist/node-fetch.d.ts +9 -0
  114. package/dist/node-fetch.js +25 -0
  115. package/dist/poll.d.ts +10 -0
  116. package/dist/poll.js +33 -0
  117. package/docs/cast.md +133 -0
  118. package/docs/cli.md +41 -0
  119. package/docs/config.md +36 -0
  120. package/docs/decisions.md +150 -0
  121. package/docs/devices.md +128 -0
  122. package/docs/enphase-notes.md +24 -0
  123. package/docs/google.md +92 -0
  124. package/docs/ideas.md +69 -0
  125. package/docs/matter.md +55 -0
  126. package/docs/performance.md +12 -0
  127. package/docs/philosophy.md +23 -0
  128. package/docs/up.md +84 -0
  129. package/docs/using.md +65 -0
  130. package/docs/weather-probe.md +63 -0
  131. package/examples/home.jsonc +74 -0
  132. package/examples/home.svg +76 -0
  133. package/home.schema.json +474 -0
  134. package/llms.txt +32 -0
  135. package/package.json +143 -4
  136. package/skills/draw-home/README.md +25 -0
  137. package/skills/draw-home/SKILL.md +80 -0
  138. package/skills/use-home/SKILL.md +86 -0
  139. package/src/cli/cache.ts +29 -0
  140. package/src/cli/cast.ts +172 -0
  141. package/src/cli/config.ts +52 -0
  142. package/src/cli/context.ts +118 -0
  143. package/src/cli/discover.ts +182 -0
  144. package/src/cli/enphase.ts +62 -0
  145. package/src/cli/hue.ts +124 -0
  146. package/src/cli/locate.ts +47 -0
  147. package/src/cli/main.ts +182 -0
  148. package/src/cli/printers.ts +21 -0
  149. package/src/cli/probe.ts +274 -0
  150. package/src/cli/spotify.ts +76 -0
  151. package/src/cli/status.ts +228 -0
  152. package/src/cli/up.ts +350 -0
  153. package/src/clock.ts +62 -0
  154. package/src/config.ts +578 -0
  155. package/src/connector.ts +77 -0
  156. package/src/connectors/amber/README.md +40 -0
  157. package/src/connectors/amber/client.ts +157 -0
  158. package/src/connectors/amber/index.ts +118 -0
  159. package/src/connectors/cast/README.md +44 -0
  160. package/src/connectors/cast/codec.ts +126 -0
  161. package/src/connectors/cast/index.ts +552 -0
  162. package/src/connectors/cast/schemas.ts +101 -0
  163. package/src/connectors/cast/serve.ts +82 -0
  164. package/src/connectors/cast/state.ts +203 -0
  165. package/src/connectors/cast/tls.ts +100 -0
  166. package/src/connectors/cast/writes.ts +572 -0
  167. package/src/connectors/daikin/README.md +78 -0
  168. package/src/connectors/daikin/client.ts +295 -0
  169. package/src/connectors/daikin/index.ts +145 -0
  170. package/src/connectors/enphase/README.md +64 -0
  171. package/src/connectors/enphase/client.ts +244 -0
  172. package/src/connectors/enphase/index.ts +241 -0
  173. package/src/connectors/enphase/schemas.ts +45 -0
  174. package/src/connectors/hue/README.md +50 -0
  175. package/src/connectors/hue/client.ts +157 -0
  176. package/src/connectors/hue/events.ts +182 -0
  177. package/src/connectors/hue/index.ts +261 -0
  178. package/src/connectors/hue/schemas.ts +60 -0
  179. package/src/connectors/hue/state.ts +124 -0
  180. package/src/connectors/printers/README.md +46 -0
  181. package/src/connectors/printers/client.ts +114 -0
  182. package/src/connectors/printers/codec.ts +292 -0
  183. package/src/connectors/printers/found.ts +46 -0
  184. package/src/connectors/printers/index.ts +385 -0
  185. package/src/connectors/printers/state.ts +182 -0
  186. package/src/connectors/spotify/README.md +83 -0
  187. package/src/connectors/spotify/changes.ts +127 -0
  188. package/src/connectors/spotify/client.ts +387 -0
  189. package/src/connectors/spotify/index.ts +937 -0
  190. package/src/connectors/up/README.md +35 -0
  191. package/src/connectors/up/client.ts +518 -0
  192. package/src/connectors/up/index.ts +370 -0
  193. package/src/connectors/up/spend.ts +280 -0
  194. package/src/connectors/up/webhook.ts +62 -0
  195. package/src/connectors/weather/README.md +84 -0
  196. package/src/connectors/weather/blocks.ts +173 -0
  197. package/src/connectors/weather/bom.ts +344 -0
  198. package/src/connectors/weather/ftp.ts +48 -0
  199. package/src/connectors/weather/google.ts +425 -0
  200. package/src/connectors/weather/index.ts +396 -0
  201. package/src/connectors/weather/open-meteo.ts +309 -0
  202. package/src/connectors/weather/rain.ts +73 -0
  203. package/src/connectors/weather/source.ts +236 -0
  204. package/src/connectors/weather/sun.ts +114 -0
  205. package/src/discover.ts +638 -0
  206. package/src/energy.ts +221 -0
  207. package/src/finders-entry.ts +2 -0
  208. package/src/finders.ts +250 -0
  209. package/src/health.ts +74 -0
  210. package/src/home.ts +205 -0
  211. package/src/index.ts +62 -0
  212. package/src/lane.ts +110 -0
  213. package/src/locate.ts +85 -0
  214. package/src/manifest.ts +43 -0
  215. package/src/manifests.ts +27 -0
  216. package/src/node-fetch.ts +41 -0
  217. package/src/poll.ts +33 -0
@@ -0,0 +1,638 @@
1
+ import { applyEdits, modify } from "jsonc-parser";
2
+ import type { Clock } from "./clock.ts";
3
+ import type { HomeConfig } from "./config.ts";
4
+ import { errorText } from "./connector.ts";
5
+
6
+ /** mDNS service types worth looking for at home, and what this package does with each. Extend the list as needed. */
7
+ export const KNOWN_SERVICES: Record<
8
+ string,
9
+ { what: string; connector?: string }
10
+ > = {
11
+ "_hue._tcp": { what: "Philips Hue bridge", connector: "hue" },
12
+ "_dkapi._tcp": {
13
+ what: "Daikin AC (Home Assistant's zeroconf type)",
14
+ connector: "daikin",
15
+ },
16
+ "_enphase-envoy._tcp": { what: "Enphase IQ Gateway", connector: "enphase" },
17
+ "_googlecast._tcp": {
18
+ what: "Google Cast (Nest speakers and hubs, Chromecast, TVs, speaker groups)",
19
+ connector: "cast",
20
+ },
21
+ "_ipp._tcp": { what: "AirPrint printer (IPP)", connector: "printers" },
22
+ "_ipps._tcp": {
23
+ what: "AirPrint printer (IPP over TLS)",
24
+ connector: "printers",
25
+ },
26
+ "_airplay._tcp": { what: "AirPlay (Apple TV, HomePod, speakers)" },
27
+ "_raop._tcp": { what: "AirPlay audio receiver" },
28
+ "_matter._tcp": { what: "Matter device (commissioned)" },
29
+ "_matterc._udp": { what: "Matter device waiting to be commissioned" },
30
+ "_hap._tcp": { what: "HomeKit accessory" },
31
+ "_sonos._tcp": { what: "Sonos speaker" },
32
+ "_esphomelib._tcp": { what: "ESPHome device" },
33
+ "_shelly._tcp": { what: "Shelly relay or plug" },
34
+ "_wled._tcp": { what: "WLED light strip" },
35
+ "_nanoleafapi._tcp": { what: "Nanoleaf panels" },
36
+ "_eero._tcp": { what: "eero Wi-Fi router (cloud-only API)" },
37
+ "_home-assistant._tcp": { what: "Home Assistant server" },
38
+ };
39
+
40
+ // Identity and address: a device in the home config is recognised by the id its own discovery reports, and `host` is
41
+ // only its last known address. These are pure: the CLI finds devices, these match them and edit the config's text.
42
+
43
+ /** A device a discovery answer named: its connector, the identity it reported, and where it answered; Cast adds its name, model, port and whether it is a group; a printer adds its port, whether it answered on `_ipps._tcp`, and its TXT record. */
44
+ export type Found = {
45
+ connector: Connector;
46
+ id: string;
47
+ host: string;
48
+ name?: string;
49
+ model?: string;
50
+ port?: number;
51
+ group?: boolean;
52
+ secure?: boolean;
53
+ txt?: Record<string, string>;
54
+ };
55
+ /** One connector's discovery, as the caller can run it (mDNS, UDP): every device of that kind that answered. */
56
+ export type Finder = () => Promise<Found[]>;
57
+
58
+ /** Which config field holds each connector's identity, as its discovery reports it. */
59
+ export const IDENTITY = {
60
+ hue: "bridgeId",
61
+ daikin: "mac",
62
+ enphase: "serial",
63
+ cast: "castId",
64
+ printers: "uuid",
65
+ } as const;
66
+ export type Connector = keyof typeof IDENTITY;
67
+
68
+ /** Connectors with many devices in one integration (every Cast device and group, every printer); the rest allow one device for now. */
69
+ export const MANY: ReadonlySet<string> = new Set(["cast", "printers"]);
70
+
71
+ /** An identity without case, separators or a `urn:uuid:` prefix: "00:17:88:B0" is "001788b0", "urn:uuid:AB-CD" is "abcd". */
72
+ export const idKey = (id: string) =>
73
+ id
74
+ .toLowerCase()
75
+ .replace(/^urn:uuid:/, "")
76
+ .replace(/[^0-9a-z]/g, "");
77
+
78
+ /** Identities compared by `idKey`. */
79
+ export const sameId = (a: string, b: string) => idKey(a) === idKey(b);
80
+
81
+ export type Match = {
82
+ moved: {
83
+ index: number;
84
+ id: string;
85
+ connector: Connector;
86
+ from: string;
87
+ to: string;
88
+ }[];
89
+ renamed: {
90
+ index: number;
91
+ id: string;
92
+ connector: Connector;
93
+ from: string;
94
+ to: string;
95
+ }[]; // Cast: the device's name (mDNS fn) changed in the Google Home app
96
+ added: { found: Found; entry: Record<string, string | number> }[];
97
+ skipped: { found: Found; why: string }[]; // found but not written: a second device of a connector, for now
98
+ known: { id: string; connector: Connector; hint?: string }[];
99
+ };
100
+
101
+ /** A new config entry for a found device, with the CLI's default key name and file paths, which `home config check` then verifies. */
102
+ function newEntry(f: Found): Record<string, string | number> {
103
+ if (f.connector === "cast") {
104
+ const name = f.name ?? f.id;
105
+ return {
106
+ id:
107
+ name
108
+ .toLowerCase()
109
+ .replace(/[^a-z0-9]+/g, "-")
110
+ .replace(/^-|-$/g, "") || "cast",
111
+ connector: "cast",
112
+ name,
113
+ castId: f.id,
114
+ };
115
+ }
116
+ if (f.connector === "printers") {
117
+ const name = f.name ?? f.id;
118
+ return {
119
+ id:
120
+ name
121
+ .toLowerCase()
122
+ .replace(/[^a-z0-9]+/g, "-")
123
+ .replace(/^-|-$/g, "") || "printer",
124
+ connector: "printers",
125
+ name,
126
+ uuid: f.id,
127
+ host: f.host,
128
+ ...(f.port === undefined ? {} : { port: f.port }),
129
+ };
130
+ }
131
+ const base = {
132
+ id: f.connector,
133
+ connector: f.connector,
134
+ host: f.host,
135
+ [IDENTITY[f.connector]]: f.id,
136
+ };
137
+ if (f.connector === "hue")
138
+ return { ...base, key: "HUE_APPLICATION_KEY", ca: "hue-root-ca.pem" };
139
+ if (f.connector === "enphase")
140
+ return { ...base, token: "~/.config/home/enphase-token.json" };
141
+ return base;
142
+ }
143
+
144
+ /** `id`, or `id-2`, `id-3`… : the first not in `taken`. */
145
+ function unique(id: string, taken: Set<string>): string {
146
+ let out = id;
147
+ for (let n = 2; taken.has(out); n++) out = `${id}-${n}`;
148
+ return out;
149
+ }
150
+
151
+ /**
152
+ * Match what discovery found against the config by identity. An entry with no identity yet is matched by host, and a
153
+ * device at an entry's host with a different identity is a conflict, not a match. New entries get ids no entry has.
154
+ */
155
+ export function matchFound(config: HomeConfig, found: Found[]): Match {
156
+ const devices = config.devices ?? [];
157
+ const match: Match = {
158
+ moved: [],
159
+ renamed: [],
160
+ added: [],
161
+ skipped: [],
162
+ known: [],
163
+ };
164
+ const taken = new Set(devices.map((d) => d.id));
165
+ const identity = (x: (typeof devices)[number], field: string) =>
166
+ (x as Record<string, unknown>)[field] as string | undefined;
167
+ const hostOf = (x: (typeof devices)[number] | undefined) =>
168
+ (x as { host?: string } | undefined)?.host;
169
+ for (const f of found) {
170
+ const field = IDENTITY[f.connector];
171
+ const same = devices.filter((x) => x.connector === f.connector);
172
+ const index = devices.findIndex(
173
+ (d) =>
174
+ d.connector === f.connector &&
175
+ typeof identity(d, field) === "string" &&
176
+ sameId(identity(d, field)!, f.id),
177
+ );
178
+ const d = devices[index];
179
+ const atHost = same.find((x) => hostOf(x) === f.host);
180
+ const from = hostOf(d);
181
+ const named =
182
+ d && f.connector === "cast" && f.name !== undefined
183
+ ? (d as { name?: string }).name
184
+ : undefined;
185
+ if (d && named !== undefined && named !== f.name)
186
+ match.renamed.push({
187
+ index,
188
+ id: d.id,
189
+ connector: f.connector,
190
+ from: named,
191
+ to: f.name!,
192
+ });
193
+ if (d && from !== undefined && from !== f.host)
194
+ match.moved.push({
195
+ index,
196
+ id: d.id,
197
+ connector: f.connector,
198
+ from,
199
+ to: f.host,
200
+ });
201
+ else if (d) match.known.push({ id: d.id, connector: f.connector });
202
+ else if (atHost && identity(atHost, field) === undefined)
203
+ match.known.push({
204
+ id: atHost.id,
205
+ connector: f.connector,
206
+ hint: `add "${field}": "${f.id}" to recognise it after a move`,
207
+ });
208
+ else if (atHost)
209
+ match.skipped.push({
210
+ found: f,
211
+ why: `answers at ${atHost.id}'s host with a different ${field}`,
212
+ });
213
+ else if (
214
+ !MANY.has(f.connector) &&
215
+ same.some((x) => identity(x, field) === undefined)
216
+ )
217
+ match.skipped.push({
218
+ found: f,
219
+ why: `could be ${same.find((x) => identity(x, field) === undefined)!.id} moved; add "${field}" to tell`,
220
+ });
221
+ else if (
222
+ !MANY.has(f.connector) &&
223
+ (same.length ||
224
+ match.added.some((a) => a.found.connector === f.connector))
225
+ )
226
+ match.skipped.push({
227
+ found: f,
228
+ why: `a second ${f.connector} device; one per connector for now`,
229
+ });
230
+ else {
231
+ const entry = newEntry(f);
232
+ entry.id = unique(String(entry.id), taken);
233
+ taken.add(String(entry.id));
234
+ match.added.push({ found: f, entry });
235
+ }
236
+ }
237
+ return match;
238
+ }
239
+
240
+ /** One entry as one line of JSON, keys in order: `{ "id": "ac", "connector": "daikin", "host": "…" }`. */
241
+ const oneLine = (entry: Record<string, string | number>) =>
242
+ `{ ${Object.entries(entry)
243
+ .map(([k, v]) => `${JSON.stringify(k)}: ${JSON.stringify(v)}`)
244
+ .join(", ")} }`;
245
+
246
+ /**
247
+ * The config's text with moved hosts and renamed Cast entries updated and new devices appended, through jsonc-parser's edits: comments, unknown
248
+ * keys and every other line stay as they were. A new device goes on its own line at its neighbour's indent.
249
+ */
250
+ export function applyFound(
251
+ text: string,
252
+ match: Match,
253
+ hasDevices: boolean,
254
+ ): string {
255
+ // hasDevices: the config has a non-empty devices list
256
+ let out = text;
257
+ for (const m of match.moved)
258
+ out = applyEdits(out, modify(out, ["devices", m.index, "host"], m.to, {}));
259
+ for (const r of match.renamed)
260
+ out = applyEdits(out, modify(out, ["devices", r.index, "name"], r.to, {}));
261
+ if (!hasDevices && match.added.length)
262
+ return applyEdits(
263
+ out,
264
+ modify(
265
+ out,
266
+ ["devices"],
267
+ match.added.map((a) => a.entry),
268
+ { formattingOptions: { insertSpaces: true, tabSize: 2 } },
269
+ ),
270
+ );
271
+ for (const { entry } of match.added) {
272
+ const edits = modify(out, ["devices", -1], entry, {
273
+ isArrayInsertion: true,
274
+ });
275
+ const before = out;
276
+ out = applyEdits(
277
+ out,
278
+ edits.map((e) => {
279
+ const indent = before
280
+ .slice(before.lastIndexOf("\n", e.offset - 1) + 1)
281
+ .match(/^\s*/)![0];
282
+ return {
283
+ ...e,
284
+ content: e.content.replace(
285
+ JSON.stringify(entry),
286
+ `\n${indent}${oneLine(entry)}`,
287
+ ),
288
+ };
289
+ }),
290
+ );
291
+ }
292
+ return out;
293
+ }
294
+
295
+ /** mDNS and Daikin's broadcast drop answers now and then, so discovery runs this many passes by default… */
296
+ export const DISCOVERY_PASSES = 2;
297
+ /** …this far apart. */
298
+ export const DISCOVERY_GAP_MS = 5000;
299
+
300
+ /** A device's key, across discovery passes and in the address cache: its connector and identity, without case or separators. */
301
+ export const addressKey = (connector: Connector, id: string) =>
302
+ `${connector}:${idKey(id)}`;
303
+ export const foundKey = (f: Found) => addressKey(f.connector, f.id);
304
+
305
+ /** Where a device last answered, and when that was last confirmed. `name` is kept for Cast, whose config entry may be absent. */
306
+ export type CachedAddress = {
307
+ host: string;
308
+ port?: number;
309
+ name?: string;
310
+ seenAt: string;
311
+ };
312
+
313
+ /**
314
+ * Addresses kept between runs, so discovery stays rare. The caller persists them (the CLI: a file); the library writes
315
+ * no files. `write` gets the whole map each time an address is new or has changed.
316
+ */
317
+ export type AddressCache = {
318
+ read(): Record<string, CachedAddress>;
319
+ write(entries: Record<string, CachedAddress>): void;
320
+ };
321
+
322
+ /**
323
+ * One run's view of an address cache. `fresh` is a cached address confirmed since the config was edited (`configAt`,
324
+ * the config file's time; a config edited since wins); `seen` records an address that answered, writing through when it
325
+ * changed or was not fresh, so an address confirmed after a config edit is trusted again. A Cast name, when given, is the
326
+ * device's own (discovery's fn, or its own multizone status, never a config's), so a new one is written too; a call
327
+ * without a name keeps the one cached.
328
+ */
329
+ export function addressBook(
330
+ cache: AddressCache | undefined,
331
+ options: { configAt?: number; now?: () => number } = {},
332
+ ) {
333
+ const entries = cache?.read() ?? {};
334
+ const now = options.now ?? Date.now;
335
+ // `seenAt` has whole milliseconds and a file time a fraction: a confirmation in the same millisecond as the edit counts.
336
+ const isFresh = (c: CachedAddress) =>
337
+ options.configAt === undefined ||
338
+ Date.parse(c.seenAt) >= Math.floor(options.configAt);
339
+ return {
340
+ fresh(connector: Connector, id?: string): CachedAddress | undefined {
341
+ const c =
342
+ id === undefined ? undefined : entries[addressKey(connector, id)];
343
+ return c && isFresh(c) ? c : undefined;
344
+ },
345
+ seen(
346
+ connector: Connector,
347
+ id: string | undefined,
348
+ address: { host: string; port?: number; name?: string },
349
+ ): void {
350
+ if (!cache || id === undefined) return;
351
+ const key = addressKey(connector, id);
352
+ const c = entries[key];
353
+ if (
354
+ c &&
355
+ c.host === address.host &&
356
+ c.port === address.port &&
357
+ isFresh(c) &&
358
+ (!address.name || address.name === c.name)
359
+ )
360
+ return;
361
+ const name = address.name || c?.name;
362
+ entries[key] = {
363
+ host: address.host,
364
+ ...(address.port === undefined ? {} : { port: address.port }),
365
+ ...(name ? { name } : {}),
366
+ seenAt: new Date(now()).toISOString(),
367
+ };
368
+ cache.write({ ...entries });
369
+ },
370
+ entries: () => ({ ...entries }),
371
+ };
372
+ }
373
+
374
+ /**
375
+ * Run one discovery `passes` times, `gapMs` apart, and return the union (first answer wins). `onPass` sees each pass's
376
+ * additions as they come; `done` ends it early once what was looked for has answered. A pass that throws adds nothing;
377
+ * when every pass threw, discovery never ran, so it throws the last error rather than reporting nothing found. An error
378
+ * `stopOn` accepts is thrown at once: no later pass could do better.
379
+ */
380
+ export async function discover<T = Found>(
381
+ pass: () => Promise<T[]>,
382
+ options: {
383
+ clock: Clock;
384
+ passes?: number;
385
+ gapMs?: number;
386
+ key?: (t: T) => string;
387
+ done?: (all: T[]) => boolean;
388
+ stopOn?: (error: unknown) => boolean;
389
+ onPass?: (n: number, added: T[]) => void;
390
+ signal?: AbortSignal;
391
+ },
392
+ ): Promise<T[]> {
393
+ const key = options.key ?? (foundKey as unknown as (t: T) => string);
394
+ const all = new Map<string, T>();
395
+ const passes = options.passes ?? DISCOVERY_PASSES;
396
+ let ran = 0;
397
+ const errors: unknown[] = [];
398
+ for (let n = 1; n <= passes && !options.signal?.aborted; n++) {
399
+ if (n > 1)
400
+ await options.clock.sleep(
401
+ options.gapMs ?? DISCOVERY_GAP_MS,
402
+ options.signal,
403
+ );
404
+ if (options.signal?.aborted) break;
405
+ ran++;
406
+ const answers = await pass().catch((error) => {
407
+ if (options.stopOn?.(error)) throw error;
408
+ errors.push(error);
409
+ return [] as T[];
410
+ });
411
+ const added = answers.filter((t) => !all.has(key(t)) && all.set(key(t), t));
412
+ options.onPass?.(n, added);
413
+ if (options.done?.([...all.values()])) break;
414
+ }
415
+ if (ran && errors.length === ran) throw errors.at(-1);
416
+ return [...all.values()];
417
+ }
418
+
419
+ /** Discovery runs after this many failed reads in a row. */
420
+ export const RESCAN_AFTER_FAILURES = 3;
421
+ /** Then again after these delays, then hourly for as long as reads fail. */
422
+ export const RESCAN_DELAYS_MS = [60_000, 5 * 60_000, 15 * 60_000];
423
+ export const HOUR = 3_600_000;
424
+ /** The wait before the next scan after `attempt` scans have missed: 1, 5 and 15 minutes, then hourly. The relocator and Cast's re-discovery share it. */
425
+ export const rescanDelay = (attempt: number) =>
426
+ RESCAN_DELAYS_MS[attempt] ?? HOUR;
427
+ /** After this long without a read, the detail says a day has passed; the scans carry on hourly. */
428
+ export const DAY_MS = 24 * HOUR;
429
+
430
+ /** What `failed()` learned: a move, that a day has passed since the scans began, whether the last scan found it at its address, or why the last scan could not run. */
431
+ export type Lost = {
432
+ moved?: string;
433
+ overADay?: boolean;
434
+ seenHere?: boolean;
435
+ discoveryFailed?: string;
436
+ };
437
+
438
+ /**
439
+ * A polling connector's address, rescanned only on failure. `failed()` after each failed read runs `find` once reads
440
+ * have failed `RESCAN_AFTER_FAILURES` times in a row, then on the backoff, then hourly with no end, so a device that comes
441
+ * back is found without a restart. `ok()` after a good read, `reset()` on a refresh (the next failure scans at once).
442
+ * Without `find` or an identity it never scans. `cached`: the starting host came from an address cache, so the first
443
+ * failure scans at once. `failed(signal)` takes the run's signal, so a shutdown does not wait out a scan.
444
+ */
445
+ export function relocator(options: {
446
+ host: string;
447
+ id?: string;
448
+ find?: Finder;
449
+ clock: Clock;
450
+ cached?: boolean;
451
+ }) {
452
+ let host = options.host;
453
+ let failures = 0;
454
+ let fromCache = options.cached === true; // a cached address that fails is stale: scan on its first failure
455
+ let attempts = 0;
456
+ let firstScan: number | undefined;
457
+ let nextScan = 0;
458
+ let seenHere: boolean | undefined;
459
+ let discoveryFailed: string | undefined;
460
+ const reset = () => (
461
+ (attempts = 0),
462
+ (firstScan = undefined),
463
+ (nextScan = 0),
464
+ (seenHere = undefined),
465
+ (discoveryFailed = undefined)
466
+ );
467
+ return {
468
+ host: () => host,
469
+ ok: () => ((failures = 0), (fromCache = false), reset()),
470
+ reset,
471
+ async failed(signal?: AbortSignal): Promise<Lost> {
472
+ failures++;
473
+ const { find, id, clock } = options;
474
+ if (!find || !id || failures < (fromCache ? 1 : RESCAN_AFTER_FAILURES))
475
+ return {};
476
+ const now = clock.now();
477
+ const state = (): Lost => ({
478
+ ...(firstScan !== undefined && now - firstScan >= DAY_MS
479
+ ? { overADay: true }
480
+ : {}),
481
+ ...(seenHere === undefined ? {} : { seenHere }),
482
+ ...(discoveryFailed === undefined ? {} : { discoveryFailed }),
483
+ });
484
+ if (now < nextScan) return state();
485
+ firstScan ??= now;
486
+ let at: string | undefined;
487
+ try {
488
+ at = (
489
+ await discover(find, {
490
+ clock,
491
+ signal,
492
+ done: (all) => all.some((f) => sameId(f.id, id)),
493
+ })
494
+ ).find((f) => sameId(f.id, id))?.host;
495
+ discoveryFailed = undefined;
496
+ } catch (error) {
497
+ discoveryFailed = errorText(error);
498
+ }
499
+ if (at && at !== host) {
500
+ const from = host;
501
+ host = at;
502
+ failures = 0;
503
+ reset();
504
+ return { moved: `moved from ${from} to ${at}` };
505
+ }
506
+ seenHere = discoveryFailed === undefined ? at === host : undefined;
507
+ nextScan = now + rescanDelay(attempts);
508
+ attempts++;
509
+ return state();
510
+ },
511
+ };
512
+ }
513
+
514
+ /** A failed read's detail, saying what discovery found: never "not found" unless a scan looked and missed it. */
515
+ export function lostDetail(lost: Lost, error: unknown): string {
516
+ const what =
517
+ lost.discoveryFailed !== undefined
518
+ ? `discovery failed: ${lost.discoveryFailed}`
519
+ : lost.seenHere === true
520
+ ? "discovery finds it at its address but reads fail"
521
+ : lost.seenHere === false
522
+ ? "not found by discovery"
523
+ : "";
524
+ const why = errorText(error);
525
+ return [lost.overADay ? "no answer for a day" : "", what, why]
526
+ .filter(Boolean)
527
+ .join("; ");
528
+ }
529
+
530
+ /** Instance names from `dns-sd -B`, each once. */
531
+ export const parseBrowse = (text: string): string[] => [
532
+ ...new Set(
533
+ [...text.matchAll(/ Add .*?\.\s+\S+\.\s+(.+)$/gm)].map((m) => m[1]!.trim()),
534
+ ),
535
+ ];
536
+
537
+ const utf8 = new TextEncoder();
538
+
539
+ /** Undo DNS-SD text escapes, as `dns-sd` and `avahi-browse` print them: `\ ` or `\.` for the character, `\226\128\153` for each byte of a UTF-8 character. */
540
+ function unescapeDnsSd(token: string): string {
541
+ const bytes: number[] = [];
542
+ for (let i = 0; i < token.length; i++) {
543
+ const decimal = token[i] === "\\" ? token.slice(i + 1, i + 4) : "";
544
+ if (/^\d{3}$/.test(decimal)) (bytes.push(Number(decimal)), (i += 3));
545
+ else bytes.push(...utf8.encode(token[token[i] === "\\" ? ++i : i]!));
546
+ }
547
+ return new TextDecoder().decode(Uint8Array.from(bytes));
548
+ }
549
+
550
+ /** TXT strings as a record: `key=value`, and a bare `key` or `key=` as an empty value. */
551
+ const txtRecord = (pairs: string[]) =>
552
+ Object.fromEntries(
553
+ pairs
554
+ .filter((p) => p && !p.startsWith("="))
555
+ .map((p) =>
556
+ p.includes("=")
557
+ ? [p.slice(0, p.indexOf("=")), p.slice(p.indexOf("=") + 1)]
558
+ : [p, ""],
559
+ ),
560
+ );
561
+
562
+ /**
563
+ * `avahi-browse --parsable --resolve` (Linux): each resolved instance once, IPv4 only (the config keeps IPv4 addresses).
564
+ * A resolved line is `=;interface;IPv4;name;type;domain;hostname;address;port;"txt" "txt"`; names and TXT strings use
565
+ * the same escapes as `dns-sd` (`\059` for a `;` in a name, `\"` inside TXT). An instance seen on two interfaces is kept
566
+ * once, the first; loopback is skipped. Checked against avahi-utils on Debian trixie, 2026-10-16.
567
+ */
568
+ export function parseAvahi(text: string): {
569
+ instance: string;
570
+ hostname: string;
571
+ host: string;
572
+ port: number;
573
+ txt: Record<string, string>;
574
+ }[] {
575
+ const seen = new Map<
576
+ string,
577
+ {
578
+ instance: string;
579
+ hostname: string;
580
+ host: string;
581
+ port: number;
582
+ txt: Record<string, string>;
583
+ }
584
+ >();
585
+ for (const line of text.split("\n")) {
586
+ const f = line.split(";");
587
+ // By the address itself: column 3 is the protocol the service was seen on, not necessarily the address's. Loopback is a service on this machine.
588
+ if (
589
+ f[0] !== "=" ||
590
+ f.length < 10 ||
591
+ !/^\d+\.\d+\.\d+\.\d+$/.test(f[7]!) ||
592
+ f[1] === "lo" ||
593
+ f[7]!.startsWith("127.")
594
+ )
595
+ continue;
596
+ const instance = unescapeDnsSd(f[3]!);
597
+ const port = Number(f[8]);
598
+ if (seen.has(instance) || !f[7] || !Number.isInteger(port)) continue;
599
+ const txt = txtRecord(
600
+ [
601
+ ...f
602
+ .slice(9)
603
+ .join(";")
604
+ .matchAll(/"((?:\\.|[^"\\])*)"/g),
605
+ ].map((m) => unescapeDnsSd(m[1]!)),
606
+ );
607
+ seen.set(instance, { instance, hostname: f[6]!, host: f[7]!, port, txt });
608
+ }
609
+ return [...seen.values()];
610
+ }
611
+
612
+ /** Instance names from `avahi-browse --parsable` (a `+` line per instance and interface), each once. */
613
+ export const parseAvahiBrowse = (text: string): string[] =>
614
+ [
615
+ ...new Set(
616
+ text
617
+ .split("\n")
618
+ .flatMap((l) =>
619
+ l.startsWith("+;") ? [unescapeDnsSd(l.split(";")[3] ?? "")] : [],
620
+ ),
621
+ ),
622
+ ].filter(Boolean);
623
+
624
+ /**
625
+ * One `dns-sd -L` answer: the host and port it can be reached at, and its TXT record. TXT values come escaped (`\ ` for
626
+ * a space, `\226\128\153` for each byte of a UTF-8 character); these are undone, so `fn` reads as the Google Home app shows it.
627
+ */
628
+ export function parseResolve(
629
+ text: string,
630
+ ): { hostname: string; port: number; txt: Record<string, string> } | undefined {
631
+ const at = text.match(/can be reached at (\S+?)\.?:(\d+)/);
632
+ if (!at) return undefined;
633
+ const line = text.slice(at.index).split("\n")[1] ?? "";
634
+ const tokens = (line.trim().match(/(?:\\.|[^\s\\])+/g) ?? []).map(
635
+ unescapeDnsSd,
636
+ );
637
+ return { hostname: at[1]!, port: Number(at[2]), txt: txtRecord(tokens) };
638
+ }