@khalido/home 0.1.3 → 0.2.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 (129) hide show
  1. package/.env.example +2 -2
  2. package/CHANGELOG.md +56 -1
  3. package/README.md +7 -2
  4. package/dist/config.d.ts +5 -3
  5. package/dist/config.js +5 -1
  6. package/dist/connector.d.ts +2 -0
  7. package/dist/connector.js +3 -0
  8. package/dist/connectors/amber/client.d.ts +1 -1
  9. package/dist/connectors/amber/client.js +1 -1
  10. package/dist/connectors/amber/index.js +2 -2
  11. package/dist/connectors/cast/index.d.ts +1 -1
  12. package/dist/connectors/cast/index.js +1 -1
  13. package/dist/connectors/cast/state.d.ts +17 -3
  14. package/dist/connectors/cast/state.js +26 -7
  15. package/dist/connectors/daikin/client.d.ts +45 -2
  16. package/dist/connectors/daikin/client.js +67 -2
  17. package/dist/connectors/daikin/index.d.ts +2 -2
  18. package/dist/connectors/daikin/index.js +4 -3
  19. package/dist/connectors/enphase/client.d.ts +1 -1
  20. package/dist/connectors/enphase/client.js +1 -1
  21. package/dist/connectors/enphase/index.js +2 -1
  22. package/dist/connectors/hue/ca.d.ts +2 -0
  23. package/dist/connectors/hue/ca.js +38 -0
  24. package/dist/connectors/hue/client.d.ts +81 -6
  25. package/dist/connectors/hue/client.js +217 -6
  26. package/dist/connectors/hue/color.d.ts +30 -0
  27. package/dist/connectors/hue/color.js +92 -0
  28. package/dist/connectors/hue/events.d.ts +0 -2
  29. package/dist/connectors/hue/events.js +1 -2
  30. package/dist/connectors/hue/index.d.ts +15 -4
  31. package/dist/connectors/hue/index.js +27 -10
  32. package/dist/connectors/hue/schemas.d.ts +27 -0
  33. package/dist/connectors/hue/schemas.js +26 -0
  34. package/dist/connectors/hue/state.d.ts +10 -1
  35. package/dist/connectors/hue/state.js +20 -1
  36. package/dist/connectors/printers/client.d.ts +41 -6
  37. package/dist/connectors/printers/client.js +81 -8
  38. package/dist/connectors/printers/codec.d.ts +17 -3
  39. package/dist/connectors/printers/codec.js +32 -8
  40. package/dist/connectors/printers/index.d.ts +2 -0
  41. package/dist/connectors/printers/index.js +4 -0
  42. package/dist/connectors/printers/raster.d.ts +30 -0
  43. package/dist/connectors/printers/raster.js +175 -0
  44. package/dist/connectors/printers/state.d.ts +2 -0
  45. package/dist/connectors/spotify/changes.d.ts +19 -5
  46. package/dist/connectors/spotify/changes.js +29 -7
  47. package/dist/connectors/spotify/index.d.ts +1 -1
  48. package/dist/connectors/spotify/index.js +20 -3
  49. package/dist/connectors/up/client.d.ts +1 -1
  50. package/dist/connectors/up/client.js +1 -1
  51. package/dist/connectors/up/index.js +2 -2
  52. package/dist/connectors/weather/google.js +2 -2
  53. package/dist/devices.d.ts +24 -0
  54. package/dist/devices.js +56 -2
  55. package/dist/discover.js +1 -1
  56. package/dist/finders-entry.d.ts +1 -1
  57. package/dist/finders-entry.js +1 -1
  58. package/dist/finders.d.ts +2 -1
  59. package/dist/finders.js +16 -5
  60. package/dist/home.d.ts +11 -2
  61. package/dist/home.js +98 -34
  62. package/dist/index.d.ts +1 -1
  63. package/dist/index.js +1 -1
  64. package/dist/node-fetch.d.ts +2 -2
  65. package/dist/node-fetch.js +3 -2
  66. package/docs/cli.md +15 -7
  67. package/docs/config.md +11 -3
  68. package/docs/decisions.md +19 -0
  69. package/docs/hue.md +161 -0
  70. package/docs/ideas.md +2 -0
  71. package/docs/using.md +98 -3
  72. package/examples/home.jsonc +2 -1
  73. package/home.schema.json +10 -3
  74. package/llms.txt +19 -6
  75. package/package.json +1 -1
  76. package/skills/draw-home/README.md +2 -0
  77. package/skills/draw-home/SKILL.md +1 -1
  78. package/skills/use-home/SKILL.md +3 -3
  79. package/src/cli/cast.ts +51 -5
  80. package/src/cli/context.ts +2 -0
  81. package/src/cli/daikin.ts +71 -0
  82. package/src/cli/enphase.ts +31 -5
  83. package/src/cli/hue.ts +147 -59
  84. package/src/cli/main.ts +43 -13
  85. package/src/cli/printers.ts +254 -3
  86. package/src/cli/probe.ts +21 -17
  87. package/src/cli/status.ts +61 -10
  88. package/src/config.ts +8 -2
  89. package/src/connector.ts +5 -0
  90. package/src/connectors/amber/README.md +1 -1
  91. package/src/connectors/amber/client.ts +1 -1
  92. package/src/connectors/amber/index.ts +2 -2
  93. package/src/connectors/cast/README.md +6 -3
  94. package/src/connectors/cast/index.ts +7 -1
  95. package/src/connectors/cast/state.ts +32 -5
  96. package/src/connectors/daikin/README.md +16 -4
  97. package/src/connectors/daikin/client.ts +104 -3
  98. package/src/connectors/daikin/index.ts +8 -2
  99. package/src/connectors/enphase/README.md +3 -1
  100. package/src/connectors/enphase/client.ts +1 -1
  101. package/src/connectors/enphase/index.ts +2 -1
  102. package/src/connectors/hue/README.md +62 -17
  103. package/src/connectors/hue/ca.ts +39 -0
  104. package/src/connectors/hue/client.ts +362 -15
  105. package/src/connectors/hue/color.ts +123 -0
  106. package/src/connectors/hue/events.ts +1 -4
  107. package/src/connectors/hue/index.ts +46 -13
  108. package/src/connectors/hue/schemas.ts +26 -0
  109. package/src/connectors/hue/state.ts +27 -1
  110. package/src/connectors/printers/README.md +9 -4
  111. package/src/connectors/printers/client.ts +186 -12
  112. package/src/connectors/printers/codec.ts +44 -10
  113. package/src/connectors/printers/index.ts +17 -0
  114. package/src/connectors/printers/raster.ts +215 -0
  115. package/src/connectors/printers/state.ts +2 -0
  116. package/src/connectors/spotify/README.md +10 -2
  117. package/src/connectors/spotify/changes.ts +38 -9
  118. package/src/connectors/spotify/index.ts +21 -2
  119. package/src/connectors/up/README.md +1 -1
  120. package/src/connectors/up/client.ts +1 -1
  121. package/src/connectors/up/index.ts +2 -2
  122. package/src/connectors/weather/google.ts +6 -2
  123. package/src/devices.ts +83 -3
  124. package/src/discover.ts +1 -2
  125. package/src/finders-entry.ts +1 -1
  126. package/src/finders.ts +18 -6
  127. package/src/home.ts +99 -39
  128. package/src/index.ts +7 -1
  129. package/src/node-fetch.ts +6 -2
package/src/devices.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  type CastHomeDevice,
3
+ type CastSnapshot,
3
4
  castHomeDevices,
4
5
  } from "./connectors/cast/index.ts";
5
6
  import {
@@ -16,8 +17,11 @@ import {
16
17
  printersHomeDevices,
17
18
  } from "./connectors/printers/index.ts";
18
19
  import {
20
+ playingElsewhere,
19
21
  type SpotifyHomeDevice,
22
+ type SpotifyState,
20
23
  spotifyHomeDevices,
24
+ spotifyText,
21
25
  } from "./connectors/spotify/index.ts";
22
26
  import type { HomeConfig } from "./config.ts";
23
27
  import { IDENTITY, sameId } from "./discover.ts";
@@ -59,9 +63,17 @@ export function homeDevices(
59
63
  snapshot: Record<string, Part | undefined>,
60
64
  config?: Pick<HomeConfig, "devices" | "rooms">,
61
65
  ): HomeDevice[] {
62
- const all = Object.entries(DEVICES).flatMap(([name, devices]): HomeDevice[] =>
63
- devices(snapshot[name] as never),
64
- );
66
+ // Soloist doesn't say where its music went; a Cast device playing that track does, so the speaker's line names it.
67
+ const on = spotifyElsewhere(snapshot);
68
+ const all = Object.entries(DEVICES)
69
+ .flatMap(([name, devices]): HomeDevice[] =>
70
+ devices(snapshot[name] as never),
71
+ )
72
+ .map((d) =>
73
+ d.connector === "spotify" && on.length
74
+ ? { ...d, text: spotifyText(d.detail, on) }
75
+ : d,
76
+ );
65
77
  if (!config) return all;
66
78
  const entries = config.devices ?? [];
67
79
  const hueRooms = new Map<string, string>(); // a Hue group's or light's id → the config room showing it
@@ -100,3 +112,71 @@ export function homeDevices(
100
112
  return { ...d, name, text, ...(room ? { room } : {}) };
101
113
  });
102
114
  }
115
+
116
+ /**
117
+ * Where the account's Spotify is playing when the Soloist speaker says it is on another device: the names of the Cast
118
+ * devices and groups playing a track of that name in Spotify. A group is named, not its members. Empty when the music is
119
+ * on the speaker itself, nothing is playing, or it is on something Cast can't see (a phone, a laptop). Matched by the
120
+ * track's name, since neither side names the other.
121
+ */
122
+ export function spotifyElsewhere(
123
+ snapshot: Record<string, Part | undefined>,
124
+ ): string[] {
125
+ const s = snapshot.spotify?.state as SpotifyState | undefined;
126
+ const cast = snapshot.cast?.state as CastSnapshot | undefined;
127
+ const name = s?.track?.name;
128
+ if (!playingElsewhere(s) || !name || !cast) return [];
129
+ return Object.values(cast.devices)
130
+ .filter(
131
+ (d) =>
132
+ d.status === "connected" &&
133
+ !d.playingVia && // its group's music: the group is named
134
+ d.app === "Spotify" &&
135
+ d.title === name &&
136
+ (d.playerState === "PLAYING" || d.playerState === "BUFFERING"),
137
+ )
138
+ .map((d) => d.name);
139
+ }
140
+
141
+ /** A room of the home config with the devices placed in it. `noData`: device connectors the config places here that listed nothing in it (never reached, not set up, or a `hueRoom` the bridge doesn't have), so the room isn't empty, it is unread. Per connector, not per device. */
142
+ export type RoomDevices = {
143
+ id: string;
144
+ name: string;
145
+ floor?: string;
146
+ devices: HomeDevice[];
147
+ noData: string[];
148
+ };
149
+
150
+ /**
151
+ * `homeDevices` room by room, in the config's order, for a map of the home. A connector that is down but has read before
152
+ * still lists its devices, `available: false`; `noData` names one the config places in the room (a device's `room`, or
153
+ * `hueRoom`) that has nothing to list. `unplaced`: devices with no room, or one the config doesn't have.
154
+ */
155
+ export function byRoom(
156
+ snapshot: Record<string, Part | undefined>,
157
+ config: Pick<HomeConfig, "devices" | "rooms">,
158
+ ): { rooms: RoomDevices[]; unplaced: HomeDevice[] } {
159
+ const all = homeDevices(snapshot, config);
160
+ const ids = new Set((config.rooms ?? []).map((r) => r.id));
161
+ return {
162
+ rooms: (config.rooms ?? []).map((r) => {
163
+ const devices = all.filter((d) => d.room === r.id);
164
+ const expected = new Set([
165
+ ...(config.devices ?? [])
166
+ .filter((d) => d.room === r.id)
167
+ .map((d) => d.connector),
168
+ ...(r.hueRoom === undefined ? [] : ["hue"]),
169
+ ]);
170
+ return {
171
+ id: r.id,
172
+ name: r.name,
173
+ ...(r.floor === undefined ? {} : { floor: r.floor }),
174
+ devices,
175
+ noData: [...expected].filter(
176
+ (c) => c in DEVICES && !devices.some((d) => d.connector === c),
177
+ ),
178
+ };
179
+ }),
180
+ unplaced: all.filter((d) => !d.room || !ids.has(d.room)),
181
+ };
182
+ }
package/src/discover.ts CHANGED
@@ -134,8 +134,7 @@ function newEntry(f: Found): Record<string, string | number> {
134
134
  host: f.host,
135
135
  [IDENTITY[f.connector]]: f.id,
136
136
  };
137
- if (f.connector === "hue")
138
- return { ...base, key: "HUE_APPLICATION_KEY", ca: "hue-root-ca.pem" };
137
+ if (f.connector === "hue") return { ...base, key: "HUE_APPLICATION_KEY" };
139
138
  if (f.connector === "enphase")
140
139
  return { ...base, token: "~/.config/home/enphase-token.json" };
141
140
  return base;
@@ -1,2 +1,2 @@
1
1
  // `@khalido/home/finders`: what a Node consumer passes to `loadHome`. The rest of finders.ts is the CLI's.
2
- export { FINDERS, fileCache } from "./finders.ts";
2
+ export { FINDERS, fileCache, MissingTool } from "./finders.ts";
package/src/finders.ts CHANGED
@@ -32,6 +32,7 @@ import {
32
32
  // `fileCache` keeps the addresses discovery found in a file between runs, so the next run connects straight away.
33
33
 
34
34
  /** The system's mDNS tool is not installed: discovery can't run until it is. */
35
+ /** The discovery tool isn't installed (`dns-sd` on macOS, `avahi-browse` from avahi-utils on Linux): the message names it. */
35
36
  export class MissingTool extends Error {}
36
37
 
37
38
  /**
@@ -153,21 +154,32 @@ function decoded(v: string): string {
153
154
  }
154
155
  }
155
156
 
156
- /** Broadcast pydaikin's discovery packet (UDP 30000 → 30050) and collect who answers within `ms`. */
157
+ /** Broadcast pydaikin's discovery packet (UDP 30000 → 30050), three times across `ms`, and collect who answers, each unit once. */
157
158
  export function findDaikins(
158
159
  ms = 1500,
159
160
  ): Promise<{ ip: string; fields: Record<string, string> }[]> {
160
161
  return new Promise((resolve) => {
161
162
  const found: { ip: string; fields: Record<string, string> }[] = [];
162
163
  const sock = createSocket({ type: "udp4", reuseAddr: true });
163
- sock.on("message", (msg, from) =>
164
- found.push({ ip: from.address, fields: daikinFields(msg.toString()) }),
164
+ sock.on("message", (msg, from) => {
165
+ if (!found.some((f) => f.ip === from.address))
166
+ found.push({ ip: from.address, fields: daikinFields(msg.toString()) });
167
+ });
168
+ const asks: ReturnType<typeof setTimeout>[] = [];
169
+ const done = () => (
170
+ asks.forEach(clearTimeout),
171
+ sock.close(),
172
+ resolve(found)
165
173
  );
166
- sock.on("error", () => (sock.close(), resolve(found)));
174
+ sock.on("error", done);
167
175
  sock.bind(30000, () => {
168
176
  sock.setBroadcast(true);
169
- sock.send("DAIKIN_UDP/common/basic_info", 30050, "255.255.255.255");
170
- setTimeout(() => (sock.close(), resolve(found)), ms);
177
+ // UDP drops packets, more so while mDNS is busy beside it: one ask missed KO's unit in 2 runs of 3 (2026-11-11). Ask three times.
178
+ const ask = () =>
179
+ sock.send("DAIKIN_UDP/common/basic_info", 30050, "255.255.255.255");
180
+ ask();
181
+ asks.push(setTimeout(ask, ms / 3), setTimeout(ask, (2 * ms) / 3));
182
+ setTimeout(done, ms);
171
183
  });
172
184
  });
173
185
  }
package/src/home.ts CHANGED
@@ -140,7 +140,7 @@ function stamped(part: Part, before: Part | undefined, now: string): Part {
140
140
 
141
141
  /**
142
142
  * Load a home: run each integration, keep one snapshot, call `onChange` with it and what changed after every report.
143
- * `clock` stamps each part's `lastGoodAt` and `downSince`.
143
+ * `clock` stamps each part's `lastGoodAt` and `downSince`. `add` and `remove` change the set while it runs.
144
144
  */
145
145
  export function createHome<I extends Integration<any, string>>(
146
146
  integrations: readonly I[],
@@ -148,14 +148,34 @@ export function createHome<I extends Integration<any, string>>(
148
148
  options: { clock?: Clock } = {},
149
149
  ) {
150
150
  type Name = I["name"];
151
+ type Run = { stop: AbortController; done: Promise<void> };
151
152
  const clock = options.clock ?? realClock;
153
+ const list: I[] = [...integrations]; // the integrations now: `add` and `remove` change it
154
+ const parts = () => snapshot as Record<string, Part>;
152
155
  let snapshot = Object.fromEntries(
153
- integrations.map((i) => [i.name, { status: "connecting" }]),
156
+ list.map((i) => [i.name, { status: "connecting" }]),
154
157
  ) as HomeSnapshot<I>;
155
158
  const waiting = new Map<string, (() => void)[]>(); // refresh() callers, resolved by the integration's next report
156
- const finished = new Set<string>(integrations.map((i) => i.name)); // not running, before start or after its run ended: a refresh of one resolves at once, nothing will report
157
- const set = (name: Name, reported: Part) => {
158
- const before = (snapshot as Record<string, Part>)[name]!;
159
+ const runs = new Map<I, Run>(); // the ones running: before start or after its run ended, a refresh resolves at once, nothing will report
160
+ const latest = new WeakMap<I, Run>(); // each integration's newest run: a report from an older run of the same object is dropped
161
+ let started: AbortSignal | undefined; // start()'s signal, while it runs
162
+ let changing: Promise<unknown> = Promise.resolve(); // add and remove, one at a time: two on one name must not overlap
163
+ let changes = 0; // those under way or queued: start outlasts a replace of its only integration
164
+ const change = <T>(fn: () => Promise<T>): Promise<T> => {
165
+ changes++;
166
+ const done = changing.then(fn).finally(() => changes--);
167
+ changing = done.catch(() => {});
168
+ return done;
169
+ };
170
+ const heard = (name: string) => {
171
+ for (const resolve of waiting.get(name) ?? []) resolve();
172
+ waiting.delete(name);
173
+ };
174
+ // A report counts only from the integration now listed under its name: one replaced or removed may still be finishing.
175
+ const set = (i: I, run: Run, reported: Part) => {
176
+ const name: Name = i.name;
177
+ const before = parts()[name];
178
+ if (latest.get(i) !== run || !list.includes(i) || !before) return;
159
179
  const part = stamped(reported, before, asOf(clock));
160
180
  snapshot = { ...snapshot, [name]: part };
161
181
  Promise.try(() =>
@@ -163,11 +183,45 @@ export function createHome<I extends Integration<any, string>>(
163
183
  ).catch((error) =>
164
184
  console.error(`onChange threw on ${name}: ${errorText(error)}`),
165
185
  ); // the caller's bug, thrown or async, not the device's: the integration keeps running
166
- const w = waiting.get(name);
167
- if (w) {
168
- waiting.delete(name);
169
- for (const resolve of w) resolve();
170
- }
186
+ heard(name);
187
+ };
188
+ /** Run one integration until the home's signal or its own `stop` aborts. One failing marks itself unavailable. */
189
+ const launch = (i: I, signal: AbortSignal) => {
190
+ const stop = new AbortController();
191
+ const run: Run = { stop, done: Promise.resolve() };
192
+ runs.set(i, run);
193
+ latest.set(i, run);
194
+ run.done = Promise.try(() =>
195
+ i.run(AbortSignal.any([signal, stop.signal]), (part) =>
196
+ set(i, run, part),
197
+ ),
198
+ )
199
+ .catch((error) =>
200
+ set(i, run, {
201
+ status: "unavailable",
202
+ reason: "crashed",
203
+ detail: errorText(error),
204
+ state: parts()[i.name]?.state, // the last good state stays, as it does through a failed read
205
+ }),
206
+ )
207
+ .finally(() => {
208
+ if (runs.get(i) !== run) return;
209
+ runs.delete(i);
210
+ if (list.includes(i)) heard(i.name); // nothing more will report
211
+ });
212
+ };
213
+ /** Take one out of the list and the snapshot, and wait for its run to end. */
214
+ const drop = async (name: string) => {
215
+ const at = list.findIndex((i) => i.name === name);
216
+ if (at < 0) return false;
217
+ const [old] = list.splice(at, 1);
218
+ const { [name]: _, ...rest } = parts();
219
+ snapshot = rest as HomeSnapshot<I>;
220
+ heard(name);
221
+ const run = runs.get(old!);
222
+ run?.stop.abort();
223
+ await run?.done;
224
+ return true;
171
225
  };
172
226
  return {
173
227
  /** The latest snapshot. Never touches a device. */
@@ -178,61 +232,67 @@ export function createHome<I extends Integration<any, string>>(
178
232
  integration: <N extends Name>(
179
233
  name: N,
180
234
  ): Extract<I, { name: N }> | undefined =>
181
- integrations.find((i) => i.name === name) as
182
- Extract<I, { name: N }> | undefined,
235
+ list.find((i) => i.name === name) as Extract<I, { name: N }> | undefined,
183
236
  /**
184
237
  * Read now: one integration by name, or every one. Results arrive through the snapshot and `onChange` as usual; the
185
238
  * promise resolves once each target has reported (its next update, good or bad), or at once for one that isn't running.
186
239
  */
187
240
  refresh(name?: Name): Promise<void> {
188
241
  const targets =
189
- name === undefined
190
- ? integrations
191
- : integrations.filter((i) => i.name === name);
192
- if (!targets.length)
242
+ name === undefined ? [...list] : list.filter((i) => i.name === name);
243
+ if (!targets.length && name !== undefined)
193
244
  throw new Error(
194
- `no integration "${name}"; have ${integrations.map((i) => i.name).join(", ")}`,
245
+ `no integration "${name}"; have ${list.map((i) => i.name).join(", ")}`,
195
246
  );
196
247
  const reported = targets.map(
197
248
  (i) =>
198
249
  new Promise<void>((resolve) => {
199
- if (finished.has(i.name)) return resolve();
250
+ if (!runs.has(i)) return resolve();
200
251
  waiting.set(i.name, [...(waiting.get(i.name) ?? []), resolve]);
201
252
  }),
202
253
  );
203
254
  for (const i of targets) i.refresh();
204
255
  return Promise.all(reported).then(() => undefined);
205
256
  },
206
- /** Run every integration until `signal` aborts. One failing marks itself unavailable; the rest carry on. */
257
+ /**
258
+ * Add an integration while the home runs, or before it starts: a bridge paired just now, a key that arrived late. One
259
+ * already there under that name is stopped first and its part dropped, so the new one starts from `connecting` and
260
+ * the two never run together. Resolves once the new one is running; its reports arrive as any other's. Calls on
261
+ * one home run one at a time. Once `start`'s signal has aborted, an added one waits for the next `start`.
262
+ */
263
+ add: (integration: I): Promise<void> =>
264
+ change(async () => {
265
+ await drop(integration.name);
266
+ list.push(integration);
267
+ const live = started && !started.aborted ? started : undefined;
268
+ snapshot = {
269
+ ...snapshot,
270
+ [integration.name]: {
271
+ status: "connecting",
272
+ ...(live ? { downSince: asOf(clock) } : {}),
273
+ },
274
+ };
275
+ if (live) launch(integration, live);
276
+ }),
277
+ /** Stop one integration and drop its part from the snapshot. Resolves once its run has ended; false if there was none by that name. */
278
+ remove: (name: Name): Promise<boolean> => change(() => drop(name)),
279
+ /** Run every integration, and any added meanwhile, until `signal` aborts. One failing marks itself unavailable; the rest carry on. */
207
280
  async start(signal: AbortSignal): Promise<void> {
208
- finished.clear(); // a second start, after the first has stopped, runs them all again
281
+ started = signal;
209
282
  const now = asOf(clock); // the wait for a first report counts from here, not from createHome
210
283
  snapshot = Object.fromEntries(
211
- Object.entries(snapshot as Record<string, Part>).map(([name, p]) => [
284
+ Object.entries(parts()).map(([name, p]) => [
212
285
  name,
213
286
  p.status === "connecting"
214
287
  ? { ...p, downSince: p.downSince ?? now }
215
288
  : p,
216
289
  ]),
217
290
  ) as HomeSnapshot<I>;
218
- await Promise.all(
219
- integrations.map((i) =>
220
- Promise.try(() => i.run(signal, (part) => set(i.name, part)))
221
- .catch((error) =>
222
- set(i.name, {
223
- status: "unavailable",
224
- reason: "crashed",
225
- detail: errorText(error),
226
- state: (snapshot as Record<string, Part>)[i.name]!.state, // the last good state stays, as it does through a failed read
227
- }),
228
- )
229
- .finally(() => {
230
- finished.add(i.name);
231
- for (const resolve of waiting.get(i.name) ?? []) resolve(); // nothing more will report
232
- waiting.delete(i.name);
233
- }),
234
- ),
235
- );
291
+ for (const i of list) launch(i, signal); // a second start, after the first has stopped, runs them all again
292
+ // Until every run has ended, those added meanwhile too.
293
+ while (runs.size || changes)
294
+ await Promise.all([...[...runs.values()].map((r) => r.done), changing]);
295
+ // `started` stays: one added after every run has ended still runs, until the signal aborts.
236
296
  },
237
297
  };
238
298
  }
package/src/index.ts CHANGED
@@ -22,7 +22,13 @@ export {
22
22
  PUSH_DOWN_MS,
23
23
  STALE_POLLS,
24
24
  } from "./health.ts";
25
- export { type HomeDevice, homeDevices } from "./devices.ts";
25
+ export {
26
+ byRoom,
27
+ type HomeDevice,
28
+ homeDevices,
29
+ type RoomDevices,
30
+ spotifyElsewhere,
31
+ } from "./devices.ts";
26
32
  export { asOf, type Clock, realClock } from "./clock.ts";
27
33
  export { type ConnectorCode, ConnectorError } from "./connector.ts";
28
34
  export {
package/src/node-fetch.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Agent, fetch as undiciFetch } from "undici";
2
2
  import type { LoadOptions } from "./config.ts";
3
+ import { HUE_BRIDGE_ROOT_CA } from "./connectors/hue/ca.ts";
3
4
 
4
5
  // `@khalido/home/node`: the fetches Hue and Enphase need under Node, where Bun's `tls` fetch option does not exist. Node's
5
6
  // global `fetch` takes no CA, so these use undici's own `fetch` and `Agent` (one undici on both sides). Outside the
@@ -13,8 +14,11 @@ const through = (dispatcher: Agent) =>
13
14
  { ...init, dispatcher } as never,
14
15
  )) as unknown as typeof fetch;
15
16
 
16
- /** A `fetch` that trusts only `ca` and accepts only a certificate whose CN is `bridgeId`: Node's `bunPinnedFetch`. */
17
- export function nodePinnedFetch(bridgeId: string, ca: string): typeof fetch {
17
+ /** A `fetch` that trusts only `ca` (Signify's roots when left out) and accepts only a certificate whose CN is `bridgeId`: Node's `bunPinnedFetch`. */
18
+ export function nodePinnedFetch(
19
+ bridgeId: string,
20
+ ca: string = HUE_BRIDGE_ROOT_CA,
21
+ ): typeof fetch {
18
22
  const checkServerIdentity = (
19
23
  _host: string,
20
24
  cert: { subject?: { CN?: string | string[] } },