@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,428 @@
1
+ import { z } from "zod";
2
+ import { asOf, realClock } from "../../clock.js";
3
+ import { errorText } from "../../connector.js";
4
+ import { HOUR, lostDetail, rescanDelay } from "../../discover.js";
5
+ import { poller } from "../../poll.js";
6
+ import { NS } from "./codec.js";
7
+ import { CastPayload, KNOWN_TYPES } from "./schemas.js";
8
+ import { castKey, connection, found, mediaApp, reduce, } from "./state.js";
9
+ import { castActions, } from "./writes.js";
10
+ /** A Cast device or speaker group in a home config. No host: a group's moves with its leader, so every address comes from discovery. */
11
+ export const configSchema = z.looseObject({
12
+ connector: z.literal("cast"),
13
+ name: z
14
+ .string()
15
+ .describe("the device's or group's name in the Google Home app (mDNS fn), for display; `home discover --write` fills it and refreshes it after a rename"),
16
+ castId: z
17
+ .string()
18
+ .describe("the mDNS id, the stable identity; `home discover --write` fills it"),
19
+ });
20
+ /** Discovery's answers as `resolve` returns them: Cast entries only, port 8009 unless discovery said otherwise. */
21
+ export const resolvedFrom = (found) => found.flatMap((f) => f.connector === "cast"
22
+ ? [
23
+ {
24
+ id: f.id,
25
+ name: f.name ?? f.id,
26
+ model: f.model,
27
+ host: f.host,
28
+ port: f.port ?? 8009,
29
+ group: f.group ?? false,
30
+ },
31
+ ]
32
+ : []);
33
+ const SENDER = "sender-0";
34
+ const RECEIVER = "receiver-0";
35
+ const MAX_BACKOFF_MS = 60_000;
36
+ /** A cached address gets this long to answer before discovery: live connects take 33–111 ms (Bun 1.4.2), a dead address hangs. */
37
+ const CACHED_CONNECT_MS = 1000;
38
+ /** A socket that has not answered its status request in this long is dropped, so the part never sits in "connecting". */
39
+ const STATUS_TIMEOUT_MS = 10_000;
40
+ /**
41
+ * Every Cast device and group as one integration, state only: one socket per entry, CONNECT, receiver and multizone
42
+ * status (a group's members, a device's own name), media status from a running media app's transport, then pushed updates; a PING every 5 s.
43
+ * A dropped socket starts one shared re-discovery (a group's leader may have changed) and reconnects with backoff at
44
+ * whatever address discovery last gave. While anything is down or missing, discovery repeats at 1, 5 and 15 minutes, then
45
+ * hourly, as the relocator does; never once per socket. The writes (`CastActions`) go over these same sockets.
46
+ */
47
+ export function cast(config) {
48
+ const clock = config.clock ?? realClock;
49
+ const pingMs = config.pingMs ?? 5000;
50
+ let snapshot = { devices: {}, asOf: asOf(clock) };
51
+ let resolved = false;
52
+ let resolveError;
53
+ let lastFound; // the ids the last discovery that ran returned
54
+ const anomalies = new Map();
55
+ const sessions = new Map();
56
+ const opened = new Set(); // woken whenever a socket opens, for a write waiting on a reconnect
57
+ const named = new Set(); // ids whose name came from the device (discovery's fn, its own multizone status), not a config
58
+ const loops = new Map();
59
+ let report;
60
+ let running;
61
+ let discovering;
62
+ const rescan = poller(clock); // the shared re-discovery's wait; a drop or a refresh nudges it
63
+ let attempt = 0; // re-discoveries since anything was last nudged or all up; a refresh starts it over
64
+ const publish = () => {
65
+ const all = Object.values(snapshot.devices);
66
+ const down = all.filter((d) => d.status === "disconnected");
67
+ const answered = (d) => d.status === "disconnected" || d.asOf !== undefined;
68
+ const status = !resolved || !all.every(answered)
69
+ ? "connecting"
70
+ : all.length && down.length < all.length
71
+ ? "connected"
72
+ : "disconnected";
73
+ const detail = resolveError ??
74
+ (all.length
75
+ ? down.length
76
+ ? `${down.map((d) => d.name).join(", ")} disconnected`
77
+ : undefined
78
+ : "no Cast devices found");
79
+ const anomaly = anomalies.size
80
+ ? [...anomalies]
81
+ .map(([id, a]) => `${snapshot.devices[id]?.name ?? id}: ${a}`)
82
+ .join("; ")
83
+ : undefined;
84
+ report?.({ status, detail, anomaly, state: snapshot });
85
+ };
86
+ /** One discovery at a time; each found entry not yet running gets its loop, each expected one not found shows as missing. */
87
+ const discover = () => (discovering ??= (async () => {
88
+ try {
89
+ const all = await config.resolve();
90
+ snapshot = found(snapshot, all, asOf(clock));
91
+ for (const r of all)
92
+ named.add(castKey(r.id));
93
+ lastFound = new Set(all.map((r) => castKey(r.id)));
94
+ resolveError = undefined;
95
+ }
96
+ catch (error) {
97
+ resolveError = `discovery failed: ${errorText(error)}`;
98
+ }
99
+ resolved = true;
100
+ const missing = (config.expect ?? []).filter((e) => !snapshot.devices[castKey(e.castId)]);
101
+ if (missing.length) {
102
+ const placeholders = missing.map((e) => [
103
+ castKey(e.castId),
104
+ {
105
+ id: castKey(e.castId),
106
+ name: e.name,
107
+ kind: "device",
108
+ status: "disconnected",
109
+ detail: "not found by discovery",
110
+ },
111
+ ]);
112
+ snapshot = {
113
+ ...snapshot,
114
+ devices: { ...snapshot.devices, ...Object.fromEntries(placeholders) },
115
+ };
116
+ }
117
+ for (const d of Object.values(snapshot.devices))
118
+ if (d.host && !loops.has(d.id) && running)
119
+ loops.set(d.id, keep(d.id, running));
120
+ publish();
121
+ })().finally(() => (discovering = undefined)));
122
+ /** The socket's certificate must name this device, or for a group a known device, its leader. Returns the leader. */
123
+ const checkPeer = (id, peer) => {
124
+ const d = snapshot.devices[id];
125
+ if (!peer)
126
+ throw new Error("the socket has no certificate name");
127
+ if (d.kind === "device") {
128
+ if (castKey(peer) !== id)
129
+ throw new Error("the certificate names another device; the address has moved");
130
+ return undefined;
131
+ }
132
+ const leader = snapshot.devices[castKey(peer)];
133
+ if (leader?.kind !== "device")
134
+ throw new Error("the certificate names no known Cast device");
135
+ return leader.id;
136
+ };
137
+ /** Whether anything is missing or down, so the shared re-discovery keeps going. */
138
+ const needed = () => !Object.keys(snapshot.devices).length ||
139
+ Object.values(snapshot.devices).some((d) => d.status === "disconnected");
140
+ /** The one re-discovery loop: at once when nudged by a drop or a refresh, then at the relocator's cadence while anything stays down. */
141
+ async function rediscover(signal) {
142
+ while (!signal.aborted) {
143
+ const nudged = await rescan.wait(needed() ? rescanDelay(attempt) : HOUR, signal);
144
+ if (signal.aborted)
145
+ return;
146
+ if (!needed()) {
147
+ attempt = 0;
148
+ continue;
149
+ }
150
+ if (!nudged)
151
+ attempt++;
152
+ await discover();
153
+ }
154
+ }
155
+ /** One socket's life: open, check, ask, then apply what arrives until it closes. Throws why it ended; `up` says whether it got as far as connected. */
156
+ async function session(id, signal, up, timeoutMs) {
157
+ const d = snapshot.devices[id];
158
+ const channel = await config.connect({
159
+ host: d.host,
160
+ port: d.port,
161
+ signal,
162
+ timeoutMs,
163
+ });
164
+ const stop = new AbortController();
165
+ let requestId = 0;
166
+ const send = (namespace, destination, payload) => channel.send({
167
+ source: SENDER,
168
+ destination,
169
+ namespace,
170
+ payload: JSON.stringify(payload),
171
+ });
172
+ let closed = false;
173
+ const end = () => closed || ((closed = true), channel.close()); // abort and the finally both end it: close once
174
+ const leave = () => closed || (send(NS.connection, RECEIVER, { type: "CLOSE" }), end());
175
+ signal.addEventListener("abort", leave, { once: true });
176
+ const listeners = new Set();
177
+ const joined = new Set(); // transports CONNECTed on this socket
178
+ const s = {
179
+ host: d.host,
180
+ port: d.port,
181
+ send,
182
+ nextId: () => ++requestId,
183
+ listen: (listener) => (listeners.add(listener),
184
+ () => void listeners.delete(listener)),
185
+ connect: (transport) => void (joined.has(transport) ||
186
+ (joined.add(transport),
187
+ send(NS.connection, transport, { type: "CONNECT" }))),
188
+ end: (why) => ((ended = why), (up.rediscovered = true), end()),
189
+ ask: (namespace, destination, type) => send(namespace, destination, { type, requestId: ++requestId }),
190
+ };
191
+ let heard = clock.now();
192
+ let ended; // why we closed it, if we did
193
+ let answered = false;
194
+ try {
195
+ const leader = checkPeer(id, channel.peer);
196
+ send(NS.connection, RECEIVER, { type: "CONNECT" });
197
+ s.ask(NS.receiver, RECEIVER, "GET_STATUS");
198
+ s.ask(NS.multizone, RECEIVER, "GET_STATUS"); // a group's members; a device's own current name
199
+ sessions.set(id, s);
200
+ for (const wake of [...opened])
201
+ wake();
202
+ up.connected = true;
203
+ snapshot = connection(snapshot, id, { status: "connected", leader }, asOf(clock));
204
+ config.seen?.(id, {
205
+ host: d.host,
206
+ port: d.port,
207
+ name: named.has(id) ? d.name : undefined,
208
+ }); // a config's name never reaches the cache
209
+ publish();
210
+ void (async () => {
211
+ while (!stop.signal.aborted) {
212
+ await clock.sleep(pingMs, stop.signal);
213
+ if (stop.signal.aborted)
214
+ return;
215
+ if (clock.now() - heard > 3 * pingMs)
216
+ return ((ended = `silent for ${(3 * pingMs) / 1000} s`), end());
217
+ send(NS.heartbeat, RECEIVER, { type: "PING" });
218
+ }
219
+ })();
220
+ void clock.sleep(STATUS_TIMEOUT_MS, stop.signal).then(() => {
221
+ if (!stop.signal.aborted && !answered)
222
+ ((ended = `no status in ${STATUS_TIMEOUT_MS / 1000} s`), end());
223
+ });
224
+ for await (const m of channel.messages) {
225
+ heard = clock.now();
226
+ let raw;
227
+ try {
228
+ raw = JSON.parse(m.payload ?? "");
229
+ }
230
+ catch {
231
+ continue;
232
+ }
233
+ if (typeof raw !== "object" || raw === null)
234
+ continue;
235
+ for (const listener of [...listeners])
236
+ listener(raw); // a write's replies, refusals included, before the reducer's filter
237
+ if (m.namespace === NS.heartbeat && raw?.type === "PING")
238
+ send(NS.heartbeat, m.source, { type: "PONG" });
239
+ if (m.namespace === NS.connection &&
240
+ raw?.type === "CLOSE" &&
241
+ m.source === RECEIVER)
242
+ break;
243
+ if (typeof raw?.type !== "string" || !KNOWN_TYPES.has(raw.type))
244
+ continue;
245
+ const parsed = CastPayload.safeParse(raw);
246
+ if (!parsed.success) {
247
+ anomalies.set(id, `a ${raw.type} the schema did not recognise`);
248
+ publish();
249
+ continue;
250
+ }
251
+ const p = parsed.data;
252
+ if (p.type === "RECEIVER_STATUS") {
253
+ answered = true;
254
+ const app = p.status?.applications?.[0];
255
+ const transport = mediaApp(app) ? app.transportId : undefined;
256
+ if (transport && transport !== s.transport) {
257
+ s.connect(transport);
258
+ s.ask(NS.media, transport, "GET_STATUS");
259
+ }
260
+ s.transport = transport;
261
+ }
262
+ const name = snapshot.devices[id]?.name;
263
+ snapshot = reduce(snapshot, id, p, asOf(clock));
264
+ const renamed = snapshot.devices[id]?.name;
265
+ if (p.type === "MULTIZONE_STATUS" &&
266
+ renamed !== undefined &&
267
+ renamed !== name)
268
+ (named.add(id),
269
+ config.seen?.(id, { host: d.host, port: d.port, name: renamed }));
270
+ publish();
271
+ }
272
+ throw new Error(ended ?? "the device closed the socket");
273
+ }
274
+ finally {
275
+ stop.abort();
276
+ for (const listener of [...listeners])
277
+ listener(undefined);
278
+ sessions.delete(id);
279
+ signal.removeEventListener("abort", leave);
280
+ end();
281
+ }
282
+ }
283
+ /** Keep one entry connected until `signal` aborts: a drop nudges the shared re-discovery, then it reconnects with backoff. */
284
+ async function keep(id, signal, cached = false) {
285
+ for (let backoff = 1000; !signal.aborted; backoff = Math.min(backoff * 2, MAX_BACKOFF_MS)) {
286
+ const began = clock.now();
287
+ const up = {
288
+ connected: false,
289
+ };
290
+ try {
291
+ await session(id, signal, up, cached ? CACHED_CONNECT_MS : undefined);
292
+ }
293
+ catch (error) {
294
+ if (signal.aborted)
295
+ return;
296
+ if (cached) {
297
+ // A cached address that fails is only stale: discover now and try where it answers, still "connecting".
298
+ cached = false;
299
+ await discover();
300
+ // Unless discovery can't find it: then the address is the one that just failed, and a second try only waits out the timeout. The backoff still retries.
301
+ if (!lastFound || lastFound.has(id))
302
+ continue;
303
+ }
304
+ const why = errorText(error);
305
+ // A cached address the last discovery did not find says so, as a polling connector's does.
306
+ snapshot = connection(snapshot, id, {
307
+ status: "disconnected",
308
+ detail: lostDetail(lastFound && !lastFound.has(id) ? { seenHere: false } : {}, why),
309
+ }, asOf(clock));
310
+ publish();
311
+ if (up.connected && !up.rediscovered)
312
+ rescan.nudge(); // a drop, not a failed retry: look once now; retries wait for the cadence. A write that closed it has just discovered.
313
+ }
314
+ if (clock.now() - began > MAX_BACKOFF_MS)
315
+ backoff = 1000; // it had been up a while: start the backoff over
316
+ await clock.sleep(backoff, signal);
317
+ }
318
+ }
319
+ /** The entry's socket once it is open at the address the snapshot now holds, within `ms`. */
320
+ async function connected(id, ms) {
321
+ const start = clock.now();
322
+ for (;;) {
323
+ const d = snapshot.devices[id];
324
+ const s = sessions.get(id);
325
+ if (s && d && s.host === d.host && s.port === d.port)
326
+ return s;
327
+ const left = start + ms - clock.now();
328
+ if (left <= 0)
329
+ throw new Error(`${d?.name ?? id} did not connect in ${ms / 1000} s`);
330
+ const woken = new AbortController();
331
+ const wake = () => woken.abort();
332
+ opened.add(wake);
333
+ await clock.sleep(left, woken.signal);
334
+ opened.delete(wake);
335
+ }
336
+ }
337
+ return {
338
+ ...castActions({
339
+ clock,
340
+ devices: () => snapshot.devices,
341
+ session: (id) => sessions.get(id),
342
+ discover,
343
+ connected,
344
+ serve: config.serve,
345
+ }),
346
+ name: "cast",
347
+ /** Re-ask every open socket for its status; while an entry is missing or down, discover again too. */
348
+ refresh() {
349
+ for (const s of sessions.values()) {
350
+ s.ask(NS.receiver, RECEIVER, "GET_STATUS");
351
+ s.ask(NS.multizone, RECEIVER, "GET_STATUS");
352
+ if (s.transport)
353
+ s.ask(NS.media, s.transport, "GET_STATUS");
354
+ }
355
+ attempt = 0; // as the relocator's reset: the cadence starts over
356
+ rescan.nudge(); // discovers if anything is missing or down, even with nothing found yet
357
+ },
358
+ async run(signal, update) {
359
+ report = update;
360
+ running = signal;
361
+ const expect = config.expect ?? [];
362
+ const cached = expect.filter((e) => e.host !== undefined && e.port !== undefined);
363
+ snapshot = found(snapshot, cached.map((e) => ({
364
+ id: e.castId,
365
+ name: e.name,
366
+ host: e.host,
367
+ port: e.port,
368
+ group: e.port !== 8009,
369
+ })), asOf(clock));
370
+ for (const d of Object.values(snapshot.devices))
371
+ loops.set(d.id, keep(d.id, signal, true));
372
+ if (expect.length && cached.length === expect.length)
373
+ resolved = true;
374
+ else
375
+ await discover();
376
+ publish();
377
+ const rediscovery = rediscover(signal);
378
+ await new Promise((resolve) => signal.aborted
379
+ ? resolve()
380
+ : signal.addEventListener("abort", () => resolve(), { once: true }));
381
+ await Promise.all([rediscovery, ...loops.values()]);
382
+ },
383
+ };
384
+ }
385
+ /** One Cast device or group in a few words: "Nest Hub: idle · 47%", "Group: idle · 43% · 3 members: A, B, C, led by A". */
386
+ export function castText(d, s) {
387
+ const name = (id) => (id && s.devices[id]?.name) || id;
388
+ if (d.status !== "connected")
389
+ return `${d.name}: ${d.status}${d.detail ? ` (${d.detail})` : ""}`;
390
+ const media = d.playerState &&
391
+ `${d.playerState.toLowerCase()}${d.title ? ` "${d.title}"` : ""}${d.artist ? ` by ${d.artist}` : ""}${d.playingVia ? ` via ${name(d.playingVia)}` : ""}`;
392
+ const parts = [
393
+ d.standby ? "standby" : d.idle ? "idle" : (d.app ?? ""),
394
+ media ?? "",
395
+ d.muted
396
+ ? "muted"
397
+ : d.volumeControl === "fixed"
398
+ ? "fixed volume"
399
+ : d.volume === undefined
400
+ ? ""
401
+ : `${Math.round(d.volume * 100)}%`,
402
+ d.kind === "group"
403
+ ? `${d.members?.length ?? "?"} members${d.members?.length ? `: ${d.members.map(name).join(", ")}` : ""}${d.leader ? `, led by ${name(d.leader)}` : ""}`
404
+ : "",
405
+ ];
406
+ return `${d.name}: ${parts.filter(Boolean).join(" · ")}`;
407
+ }
408
+ /** Devices first, then groups, each by name. */
409
+ export const castDevices = (s) => Object.values(s.devices).sort((a, b) => a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name));
410
+ /** How many entries have an address, and one line per device and group. */
411
+ const castView = (s) => ({
412
+ found: Object.values(s.devices).filter((d) => d.host).length,
413
+ lines: castDevices(s).map((d) => castText(d, s)),
414
+ });
415
+ export const manifest = {
416
+ name: "cast",
417
+ title: "Google Cast",
418
+ iotClass: "local_push",
419
+ access: "local CastV2 over TLS, no key; certificates self-signed, their names checked",
420
+ env: [],
421
+ lines({ state }) {
422
+ if (!state)
423
+ return [];
424
+ const { found, lines } = castView(state);
425
+ return [`${found} found`, ...lines];
426
+ },
427
+ record: ({ state }) => state ? { ...castView(state), devices: state.devices } : {},
428
+ };
@@ -0,0 +1,167 @@
1
+ import { z } from "zod";
2
+ export declare const Application: z.ZodObject<{
3
+ appId: z.ZodOptional<z.ZodString>;
4
+ displayName: z.ZodOptional<z.ZodString>;
5
+ isIdleScreen: z.ZodOptional<z.ZodBoolean>;
6
+ namespaces: z.ZodOptional<z.ZodArray<z.ZodObject<{
7
+ name: z.ZodOptional<z.ZodString>;
8
+ }, z.core.$loose>>>;
9
+ sessionId: z.ZodOptional<z.ZodString>;
10
+ statusText: z.ZodOptional<z.ZodString>;
11
+ transportId: z.ZodOptional<z.ZodString>;
12
+ }, z.core.$loose>;
13
+ export type Application = z.infer<typeof Application>;
14
+ export declare const ReceiverStatus: z.ZodObject<{
15
+ type: z.ZodLiteral<"RECEIVER_STATUS">;
16
+ status: z.ZodOptional<z.ZodObject<{
17
+ applications: z.ZodOptional<z.ZodArray<z.ZodObject<{
18
+ appId: z.ZodOptional<z.ZodString>;
19
+ displayName: z.ZodOptional<z.ZodString>;
20
+ isIdleScreen: z.ZodOptional<z.ZodBoolean>;
21
+ namespaces: z.ZodOptional<z.ZodArray<z.ZodObject<{
22
+ name: z.ZodOptional<z.ZodString>;
23
+ }, z.core.$loose>>>;
24
+ sessionId: z.ZodOptional<z.ZodString>;
25
+ statusText: z.ZodOptional<z.ZodString>;
26
+ transportId: z.ZodOptional<z.ZodString>;
27
+ }, z.core.$loose>>>;
28
+ isStandBy: z.ZodOptional<z.ZodBoolean>;
29
+ isActiveInput: z.ZodOptional<z.ZodBoolean>;
30
+ volume: z.ZodOptional<z.ZodObject<{
31
+ controlType: z.ZodOptional<z.ZodString>;
32
+ level: z.ZodOptional<z.ZodNumber>;
33
+ muted: z.ZodOptional<z.ZodBoolean>;
34
+ stepInterval: z.ZodOptional<z.ZodNumber>;
35
+ }, z.core.$loose>>;
36
+ }, z.core.$loose>>;
37
+ }, z.core.$loose>;
38
+ export declare const MediaStatus: z.ZodObject<{
39
+ type: z.ZodLiteral<"MEDIA_STATUS">;
40
+ status: z.ZodOptional<z.ZodArray<z.ZodObject<{
41
+ mediaSessionId: z.ZodOptional<z.ZodNumber>;
42
+ playerState: z.ZodOptional<z.ZodString>;
43
+ idleReason: z.ZodOptional<z.ZodString>;
44
+ currentTime: z.ZodOptional<z.ZodNumber>;
45
+ media: z.ZodOptional<z.ZodObject<{
46
+ contentId: z.ZodOptional<z.ZodString>;
47
+ contentType: z.ZodOptional<z.ZodString>;
48
+ streamType: z.ZodOptional<z.ZodString>;
49
+ duration: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
50
+ metadata: z.ZodOptional<z.ZodObject<{
51
+ title: z.ZodOptional<z.ZodString>;
52
+ artist: z.ZodOptional<z.ZodString>;
53
+ }, z.core.$loose>>;
54
+ }, z.core.$loose>>;
55
+ }, z.core.$loose>>>;
56
+ }, z.core.$loose>;
57
+ export declare const MultizoneStatus: z.ZodObject<{
58
+ type: z.ZodLiteral<"MULTIZONE_STATUS">;
59
+ status: z.ZodOptional<z.ZodObject<{
60
+ devices: z.ZodOptional<z.ZodArray<z.ZodObject<{
61
+ deviceId: z.ZodOptional<z.ZodString>;
62
+ name: z.ZodOptional<z.ZodString>;
63
+ volume: z.ZodOptional<z.ZodObject<{
64
+ controlType: z.ZodOptional<z.ZodString>;
65
+ level: z.ZodOptional<z.ZodNumber>;
66
+ muted: z.ZodOptional<z.ZodBoolean>;
67
+ stepInterval: z.ZodOptional<z.ZodNumber>;
68
+ }, z.core.$loose>>;
69
+ }, z.core.$loose>>>;
70
+ }, z.core.$loose>>;
71
+ }, z.core.$loose>;
72
+ /** A group's membership changing while connected: added and updated carry the member, removed only its id. */
73
+ export declare const MemberChange: z.ZodObject<{
74
+ type: z.ZodEnum<{
75
+ DEVICE_ADDED: "DEVICE_ADDED";
76
+ DEVICE_REMOVED: "DEVICE_REMOVED";
77
+ DEVICE_UPDATED: "DEVICE_UPDATED";
78
+ }>;
79
+ device: z.ZodOptional<z.ZodObject<{
80
+ deviceId: z.ZodOptional<z.ZodString>;
81
+ name: z.ZodOptional<z.ZodString>;
82
+ volume: z.ZodOptional<z.ZodObject<{
83
+ controlType: z.ZodOptional<z.ZodString>;
84
+ level: z.ZodOptional<z.ZodNumber>;
85
+ muted: z.ZodOptional<z.ZodBoolean>;
86
+ stepInterval: z.ZodOptional<z.ZodNumber>;
87
+ }, z.core.$loose>>;
88
+ }, z.core.$loose>>;
89
+ deviceId: z.ZodOptional<z.ZodString>;
90
+ }, z.core.$loose>;
91
+ /** Every payload the reducer reads; anything else (PONG, LAUNCH_STATUS, …) fails to parse and is ignored by type. */
92
+ export declare const CastPayload: z.ZodDiscriminatedUnion<[z.ZodObject<{
93
+ type: z.ZodLiteral<"RECEIVER_STATUS">;
94
+ status: z.ZodOptional<z.ZodObject<{
95
+ applications: z.ZodOptional<z.ZodArray<z.ZodObject<{
96
+ appId: z.ZodOptional<z.ZodString>;
97
+ displayName: z.ZodOptional<z.ZodString>;
98
+ isIdleScreen: z.ZodOptional<z.ZodBoolean>;
99
+ namespaces: z.ZodOptional<z.ZodArray<z.ZodObject<{
100
+ name: z.ZodOptional<z.ZodString>;
101
+ }, z.core.$loose>>>;
102
+ sessionId: z.ZodOptional<z.ZodString>;
103
+ statusText: z.ZodOptional<z.ZodString>;
104
+ transportId: z.ZodOptional<z.ZodString>;
105
+ }, z.core.$loose>>>;
106
+ isStandBy: z.ZodOptional<z.ZodBoolean>;
107
+ isActiveInput: z.ZodOptional<z.ZodBoolean>;
108
+ volume: z.ZodOptional<z.ZodObject<{
109
+ controlType: z.ZodOptional<z.ZodString>;
110
+ level: z.ZodOptional<z.ZodNumber>;
111
+ muted: z.ZodOptional<z.ZodBoolean>;
112
+ stepInterval: z.ZodOptional<z.ZodNumber>;
113
+ }, z.core.$loose>>;
114
+ }, z.core.$loose>>;
115
+ }, z.core.$loose>, z.ZodObject<{
116
+ type: z.ZodLiteral<"MEDIA_STATUS">;
117
+ status: z.ZodOptional<z.ZodArray<z.ZodObject<{
118
+ mediaSessionId: z.ZodOptional<z.ZodNumber>;
119
+ playerState: z.ZodOptional<z.ZodString>;
120
+ idleReason: z.ZodOptional<z.ZodString>;
121
+ currentTime: z.ZodOptional<z.ZodNumber>;
122
+ media: z.ZodOptional<z.ZodObject<{
123
+ contentId: z.ZodOptional<z.ZodString>;
124
+ contentType: z.ZodOptional<z.ZodString>;
125
+ streamType: z.ZodOptional<z.ZodString>;
126
+ duration: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
127
+ metadata: z.ZodOptional<z.ZodObject<{
128
+ title: z.ZodOptional<z.ZodString>;
129
+ artist: z.ZodOptional<z.ZodString>;
130
+ }, z.core.$loose>>;
131
+ }, z.core.$loose>>;
132
+ }, z.core.$loose>>>;
133
+ }, z.core.$loose>, z.ZodObject<{
134
+ type: z.ZodLiteral<"MULTIZONE_STATUS">;
135
+ status: z.ZodOptional<z.ZodObject<{
136
+ devices: z.ZodOptional<z.ZodArray<z.ZodObject<{
137
+ deviceId: z.ZodOptional<z.ZodString>;
138
+ name: z.ZodOptional<z.ZodString>;
139
+ volume: z.ZodOptional<z.ZodObject<{
140
+ controlType: z.ZodOptional<z.ZodString>;
141
+ level: z.ZodOptional<z.ZodNumber>;
142
+ muted: z.ZodOptional<z.ZodBoolean>;
143
+ stepInterval: z.ZodOptional<z.ZodNumber>;
144
+ }, z.core.$loose>>;
145
+ }, z.core.$loose>>>;
146
+ }, z.core.$loose>>;
147
+ }, z.core.$loose>, z.ZodObject<{
148
+ type: z.ZodEnum<{
149
+ DEVICE_ADDED: "DEVICE_ADDED";
150
+ DEVICE_REMOVED: "DEVICE_REMOVED";
151
+ DEVICE_UPDATED: "DEVICE_UPDATED";
152
+ }>;
153
+ device: z.ZodOptional<z.ZodObject<{
154
+ deviceId: z.ZodOptional<z.ZodString>;
155
+ name: z.ZodOptional<z.ZodString>;
156
+ volume: z.ZodOptional<z.ZodObject<{
157
+ controlType: z.ZodOptional<z.ZodString>;
158
+ level: z.ZodOptional<z.ZodNumber>;
159
+ muted: z.ZodOptional<z.ZodBoolean>;
160
+ stepInterval: z.ZodOptional<z.ZodNumber>;
161
+ }, z.core.$loose>>;
162
+ }, z.core.$loose>>;
163
+ deviceId: z.ZodOptional<z.ZodString>;
164
+ }, z.core.$loose>], "type">;
165
+ export type CastPayload = z.infer<typeof CastPayload>;
166
+ /** The types the reducer knows; a payload of one of these that fails its schema is an anomaly, any other type is ignored. */
167
+ export declare const KNOWN_TYPES: Set<string>;
@@ -0,0 +1,87 @@
1
+ import { z } from "zod";
2
+ // The JSON payloads a state reader receives, every field optional, unknown fields kept. Shapes from the live devices
3
+ // (2026-10-13, see README) and pychromecast for media status, which nothing was playing to record.
4
+ const Volume = z.looseObject({
5
+ controlType: z.string().optional(), // "master" | "attenuation" | "fixed"
6
+ level: z.number().optional(),
7
+ muted: z.boolean().optional(),
8
+ stepInterval: z.number().optional(),
9
+ });
10
+ export const Application = z.looseObject({
11
+ appId: z.string().optional(),
12
+ displayName: z.string().optional(),
13
+ isIdleScreen: z.boolean().optional(),
14
+ namespaces: z
15
+ .array(z.looseObject({ name: z.string().optional() }))
16
+ .optional(),
17
+ sessionId: z.string().optional(),
18
+ statusText: z.string().optional(),
19
+ transportId: z.string().optional(),
20
+ });
21
+ export const ReceiverStatus = z.looseObject({
22
+ type: z.literal("RECEIVER_STATUS"),
23
+ status: z
24
+ .looseObject({
25
+ applications: z.array(Application).optional(),
26
+ isStandBy: z.boolean().optional(),
27
+ isActiveInput: z.boolean().optional(),
28
+ volume: Volume.optional(),
29
+ })
30
+ .optional(),
31
+ });
32
+ export const MediaStatus = z.looseObject({
33
+ type: z.literal("MEDIA_STATUS"),
34
+ status: z
35
+ .array(z.looseObject({
36
+ mediaSessionId: z.number().optional(),
37
+ playerState: z.string().optional(), // "PLAYING" | "PAUSED" | "BUFFERING" | "IDLE"
38
+ idleReason: z.string().optional(), // "FINISHED" | "CANCELLED" | "INTERRUPTED" | "ERROR"
39
+ currentTime: z.number().optional(), // seconds into the media
40
+ media: z
41
+ .looseObject({
42
+ contentId: z.string().optional(),
43
+ contentType: z.string().optional(),
44
+ streamType: z.string().optional(),
45
+ duration: z.number().nullish(), // seconds; null for a live stream
46
+ metadata: z
47
+ .looseObject({
48
+ title: z.string().optional(),
49
+ artist: z.string().optional(),
50
+ })
51
+ .optional(),
52
+ })
53
+ .optional(),
54
+ }))
55
+ .optional(),
56
+ });
57
+ const Member = z.looseObject({
58
+ deviceId: z.string().optional(),
59
+ name: z.string().optional(),
60
+ volume: Volume.optional(),
61
+ });
62
+ export const MultizoneStatus = z.looseObject({
63
+ type: z.literal("MULTIZONE_STATUS"),
64
+ status: z.looseObject({ devices: z.array(Member).optional() }).optional(),
65
+ });
66
+ /** A group's membership changing while connected: added and updated carry the member, removed only its id. */
67
+ export const MemberChange = z.looseObject({
68
+ type: z.enum(["DEVICE_ADDED", "DEVICE_UPDATED", "DEVICE_REMOVED"]),
69
+ device: Member.optional(),
70
+ deviceId: z.string().optional(),
71
+ });
72
+ /** Every payload the reducer reads; anything else (PONG, LAUNCH_STATUS, …) fails to parse and is ignored by type. */
73
+ export const CastPayload = z.discriminatedUnion("type", [
74
+ ReceiverStatus,
75
+ MediaStatus,
76
+ MultizoneStatus,
77
+ MemberChange,
78
+ ]);
79
+ /** The types the reducer knows; a payload of one of these that fails its schema is an anomaly, any other type is ignored. */
80
+ export const KNOWN_TYPES = new Set([
81
+ "RECEIVER_STATUS",
82
+ "MEDIA_STATUS",
83
+ "MULTIZONE_STATUS",
84
+ "DEVICE_ADDED",
85
+ "DEVICE_UPDATED",
86
+ "DEVICE_REMOVED",
87
+ ]);