@khalido/home 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/.env.example +2 -2
  2. package/CHANGELOG.md +74 -2
  3. package/README.md +7 -2
  4. package/dist/clock.js +2 -0
  5. package/dist/config.d.ts +5 -3
  6. package/dist/config.js +5 -1
  7. package/dist/connector.d.ts +9 -2
  8. package/dist/connectors/amber/index.js +1 -0
  9. package/dist/connectors/cast/index.d.ts +8 -6
  10. package/dist/connectors/cast/index.js +30 -4
  11. package/dist/connectors/cast/state.d.ts +66 -0
  12. package/dist/connectors/cast/state.js +141 -0
  13. package/dist/connectors/cast/writes.d.ts +0 -2
  14. package/dist/connectors/cast/writes.js +25 -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 +8 -4
  18. package/dist/connectors/daikin/index.js +25 -4
  19. package/dist/connectors/enphase/index.d.ts +5 -1
  20. package/dist/connectors/enphase/index.js +34 -2
  21. package/dist/connectors/hue/ca.d.ts +2 -0
  22. package/dist/connectors/hue/ca.js +38 -0
  23. package/dist/connectors/hue/client.d.ts +81 -6
  24. package/dist/connectors/hue/client.js +217 -6
  25. package/dist/connectors/hue/color.d.ts +30 -0
  26. package/dist/connectors/hue/color.js +92 -0
  27. package/dist/connectors/hue/index.d.ts +21 -6
  28. package/dist/connectors/hue/index.js +59 -9
  29. package/dist/connectors/hue/schemas.d.ts +27 -0
  30. package/dist/connectors/hue/schemas.js +26 -0
  31. package/dist/connectors/hue/state.d.ts +10 -1
  32. package/dist/connectors/hue/state.js +20 -1
  33. package/dist/connectors/printers/index.d.ts +6 -2
  34. package/dist/connectors/printers/index.js +23 -0
  35. package/dist/connectors/spotify/changes.d.ts +19 -5
  36. package/dist/connectors/spotify/changes.js +29 -7
  37. package/dist/connectors/spotify/index.d.ts +7 -2
  38. package/dist/connectors/spotify/index.js +34 -3
  39. package/dist/connectors/up/index.js +1 -0
  40. package/dist/connectors/weather/bom.js +1 -0
  41. package/dist/connectors/weather/google.js +52 -9
  42. package/dist/connectors/weather/index.d.ts +2 -1
  43. package/dist/connectors/weather/index.js +7 -0
  44. package/dist/connectors/weather/source.d.ts +2 -0
  45. package/dist/devices.d.ts +51 -0
  46. package/dist/devices.js +115 -0
  47. package/dist/discover.js +1 -1
  48. package/dist/finders-entry.d.ts +1 -1
  49. package/dist/finders-entry.js +1 -1
  50. package/dist/finders.d.ts +2 -1
  51. package/dist/finders.js +16 -5
  52. package/dist/health.d.ts +2 -1
  53. package/dist/health.js +2 -1
  54. package/dist/home.d.ts +52 -5
  55. package/dist/home.js +114 -33
  56. package/dist/index.d.ts +3 -2
  57. package/dist/index.js +2 -1
  58. package/dist/node-fetch.d.ts +2 -2
  59. package/dist/node-fetch.js +3 -2
  60. package/docs/cli.md +13 -7
  61. package/docs/config.md +18 -5
  62. package/docs/decisions.md +16 -0
  63. package/docs/hue.md +161 -0
  64. package/docs/using.md +115 -3
  65. package/examples/home.jsonc +2 -1
  66. package/home.schema.json +10 -3
  67. package/llms.txt +19 -6
  68. package/package.json +1 -1
  69. package/skills/draw-home/SKILL.md +1 -1
  70. package/skills/use-home/SKILL.md +10 -10
  71. package/src/cli/cast.ts +51 -5
  72. package/src/cli/context.ts +2 -0
  73. package/src/cli/daikin.ts +71 -0
  74. package/src/cli/enphase.ts +5 -1
  75. package/src/cli/hue.ts +147 -59
  76. package/src/cli/main.ts +35 -10
  77. package/src/cli/probe.ts +21 -17
  78. package/src/cli/status.ts +87 -13
  79. package/src/clock.ts +1 -0
  80. package/src/config.ts +8 -2
  81. package/src/connector.ts +18 -2
  82. package/src/connectors/amber/index.ts +1 -0
  83. package/src/connectors/cast/README.md +10 -3
  84. package/src/connectors/cast/index.ts +65 -7
  85. package/src/connectors/cast/state.ts +204 -0
  86. package/src/connectors/cast/writes.ts +29 -8
  87. package/src/connectors/daikin/README.md +20 -4
  88. package/src/connectors/daikin/client.ts +104 -3
  89. package/src/connectors/daikin/index.ts +49 -4
  90. package/src/connectors/enphase/index.ts +49 -2
  91. package/src/connectors/hue/README.md +62 -17
  92. package/src/connectors/hue/ca.ts +39 -0
  93. package/src/connectors/hue/client.ts +362 -15
  94. package/src/connectors/hue/color.ts +123 -0
  95. package/src/connectors/hue/index.ts +94 -12
  96. package/src/connectors/hue/schemas.ts +26 -0
  97. package/src/connectors/hue/state.ts +27 -1
  98. package/src/connectors/printers/README.md +4 -2
  99. package/src/connectors/printers/index.ts +43 -1
  100. package/src/connectors/spotify/README.md +21 -3
  101. package/src/connectors/spotify/changes.ts +38 -9
  102. package/src/connectors/spotify/index.ts +55 -3
  103. package/src/connectors/up/index.ts +1 -0
  104. package/src/connectors/weather/README.md +1 -1
  105. package/src/connectors/weather/bom.ts +1 -0
  106. package/src/connectors/weather/google.ts +66 -10
  107. package/src/connectors/weather/index.ts +16 -2
  108. package/src/connectors/weather/source.ts +2 -0
  109. package/src/devices.ts +182 -0
  110. package/src/discover.ts +1 -2
  111. package/src/finders-entry.ts +1 -1
  112. package/src/finders.ts +18 -6
  113. package/src/health.ts +4 -2
  114. package/src/home.ts +166 -39
  115. package/src/index.ts +11 -1
  116. package/src/node-fetch.ts +6 -2
@@ -284,6 +284,7 @@ export function bom(options: BomOptions = {}): WeatherSource {
284
284
  if (!options.fetch && base.startsWith("ftp:"))
285
285
  return {
286
286
  stop: "bom: plain fetch cannot read ftp://; pass fetch: ftpFetch() from @khalido/home/weather/ftp",
287
+ stopReason: "config",
287
288
  nextAt: Infinity,
288
289
  };
289
290
  if (force || at >= obsDue) {
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { type ConnectorError, getJson } from "../../connector.ts";
2
+ import { ConnectorError, fetchTimeout } from "../../connector.ts";
3
3
  import { RAIN_INTENSITIES } from "./rain.ts";
4
4
  import { sunDisagrees } from "./sun.ts";
5
5
  import {
@@ -324,6 +324,33 @@ export type GoogleOptions = {
324
324
  export const MIN_POLL_MINUTES = 15;
325
325
  export const CURRENT_MINUTES = 30;
326
326
 
327
+ /**
328
+ * Why Google refused: the `reason` of its error body's ErrorInfo ("API_KEY_INVALID"), since the status alone doesn't say.
329
+ * A bad key and a bad latitude are both HTTP 400 INVALID_ARGUMENT; only the key's carries a reason (live, 2026-11-10).
330
+ */
331
+ async function refusal(res: Response): Promise<string | undefined> {
332
+ try {
333
+ const body = (await res.json()) as {
334
+ error?: { details?: { reason?: unknown }[] };
335
+ };
336
+ const reason = body.error?.details?.find((d) => d.reason)?.reason;
337
+ return typeof reason === "string" ? reason : undefined;
338
+ } catch {
339
+ return undefined; // not JSON: the status is all there is
340
+ }
341
+ }
342
+
343
+ /** Google's refusal: the HTTP status, and `why`, the reason its error body gave. */
344
+ class GoogleError extends ConnectorError {
345
+ constructor(
346
+ message: string,
347
+ status: number,
348
+ readonly why?: string,
349
+ ) {
350
+ super(message, { status });
351
+ }
352
+ }
353
+
327
354
  /**
328
355
  * Google Weather as a source: modelled `now`, `forecast` (10 days available, 2 read), `sky` without radiation, and `rain` from the
329
356
  * minute forecast (Experimental at Google, so its failure is a `problem` on `rain` and never stops the other slices).
@@ -353,12 +380,28 @@ export function google(options: GoogleOptions): WeatherSource {
353
380
  signal,
354
381
  }): Promise<SourceReading> {
355
382
  const q = (extra: string) =>
356
- `key=${encodeURIComponent(options.key)}&location.latitude=${place.latitude}&location.longitude=${place.longitude}${extra}`;
357
- const json = (path: string) =>
358
- getJson(fetcher, `${base}/${path}`, {
359
- init: { headers: { "user-agent": USER_AGENT } },
360
- signal,
361
- });
383
+ `location.latitude=${place.latitude}&location.longitude=${place.longitude}${extra}`;
384
+ // The key goes in a header, never the URL, so no error, log or proxy that shows a URL can show it (all four lookups answered 200 this way, live 2026-11-10).
385
+ const json = async (path: string): Promise<unknown> => {
386
+ const res = await fetchTimeout(
387
+ fetcher,
388
+ `${base}/${path}`,
389
+ {
390
+ headers: {
391
+ "user-agent": USER_AGENT,
392
+ "x-goog-api-key": options.key,
393
+ },
394
+ },
395
+ { signal },
396
+ );
397
+ if (res.ok) return res.json();
398
+ const why = await refusal(res);
399
+ throw new GoogleError(
400
+ `HTTP ${res.status}${why ? ` ${why}` : ""} on GET /${path.split("?")[0]}`,
401
+ res.status,
402
+ why,
403
+ );
404
+ };
362
405
  // Started first and never throws: a failed minute forecast must not sink the rest.
363
406
  const minutes = json(`forecast/minutes:lookup?${q("")}`).then(
364
407
  (value) => ({ value }),
@@ -385,10 +428,23 @@ export function google(options: GoogleOptions): WeatherSource {
385
428
  slowDue = nextPoll(at, Math.max(pollMinutes, CURRENT_MINUTES));
386
429
  }
387
430
  } catch (error) {
388
- const status = (error as ConnectorError).status;
389
- if (status === 400 || status === 401 || status === 403)
431
+ // Only a GoogleError is Google's answer; a timeout or an abort is a DOMException, with fields of its own.
432
+ const { status, why } =
433
+ error instanceof GoogleError
434
+ ? error
435
+ : ({} as { status?: number; why?: string });
436
+ const said = `HTTP ${status}${why ? ` ${why}` : ""}`;
437
+ if (status === 401 || status === 403 || why?.startsWith("API_KEY_"))
438
+ return {
439
+ stop: `google: the key was rejected (${said})`,
440
+ stopReason: "auth",
441
+ nextAt: Infinity,
442
+ };
443
+ // A 400 with a good key is the request itself, and here that is the location: asking again won't change it.
444
+ if (status === 400)
390
445
  return {
391
- stop: `google: key rejected or request refused (HTTP ${status})`,
446
+ stop: `google: the request was refused (${said}); check the latitude and longitude`,
447
+ stopReason: "config",
392
448
  nextAt: Infinity,
393
449
  };
394
450
  problem = `google: ${(error as Error).message}`; // the old slices stay; the minute forecast below is still used
@@ -1,5 +1,5 @@
1
1
  import { type Clock, localTime, realClock } from "../../clock.ts";
2
- import type { Integration, Status } from "../../home.ts";
2
+ import type { Integration, Reason, Status } from "../../home.ts";
3
3
  import { field, type Manifest } from "../../manifest.ts";
4
4
  import { poller } from "../../poll.ts";
5
5
  import {
@@ -88,7 +88,13 @@ export type WeatherState = Slices & {
88
88
  timeZone?: string; // the config's, which the blocks and the text use
89
89
  sources: Record<
90
90
  string,
91
- { read: boolean; problem?: string; stopped?: string; anomaly?: string }
91
+ {
92
+ read: boolean;
93
+ problem?: string;
94
+ stopped?: string; // it will not be read again, and why
95
+ reason?: Exclude<Reason, "crashed">; // with `stopped`, for code: a dead key shows here even while another source keeps the part connected
96
+ anomaly?: string;
97
+ }
92
98
  >;
93
99
  };
94
100
 
@@ -149,6 +155,7 @@ export function weather(
149
155
  read: r !== undefined,
150
156
  problem: r?.problem,
151
157
  stopped: r?.stop,
158
+ reason: r?.stop ? (r.stopReason ?? "config") : undefined,
152
159
  anomaly: r?.anomaly,
153
160
  },
154
161
  ];
@@ -174,6 +181,13 @@ export function weather(
174
181
  : undefined;
175
182
  update({
176
183
  status,
184
+ // Every source has stopped: a rejected key outranks a wrong setting, since it needs a person.
185
+ reason:
186
+ status !== "unavailable"
187
+ ? undefined
188
+ : Object.values(report).some((r) => r.reason === "auth")
189
+ ? "auth"
190
+ : "config",
177
191
  detail,
178
192
  anomaly: notes("anomaly"),
179
193
  lastGoodAt,
@@ -1,4 +1,5 @@
1
1
  import { ConnectorError, fetchTimeout } from "../../connector.ts";
2
+ import type { Reason } from "../../home.ts";
2
3
 
3
4
  // The one place a weather provider plugs in: a `WeatherSource` returns slices, and `mergeWeather` picks one per slice.
4
5
 
@@ -142,6 +143,7 @@ export type SourceReading = Slices & {
142
143
  problem?: string; // a failed product, a missing option; the source keeps going
143
144
  anomaly?: string; // a value the source sent that could not be read as given
144
145
  stop?: string; // a terminal problem (a rejected key): the integration stops calling this source
146
+ stopReason?: Exclude<Reason, "crashed">; // with `stop`: a person must re-key, or fix a setting (the default)
145
147
  };
146
148
 
147
149
  /**
package/src/devices.ts ADDED
@@ -0,0 +1,182 @@
1
+ import {
2
+ type CastHomeDevice,
3
+ type CastSnapshot,
4
+ castHomeDevices,
5
+ } from "./connectors/cast/index.ts";
6
+ import {
7
+ type DaikinHomeDevice,
8
+ daikinHomeDevices,
9
+ } from "./connectors/daikin/index.ts";
10
+ import {
11
+ type EnphaseHomeDevice,
12
+ enphaseHomeDevices,
13
+ } from "./connectors/enphase/index.ts";
14
+ import { type HueHomeDevice, hueHomeDevices } from "./connectors/hue/index.ts";
15
+ import {
16
+ type PrinterHomeDevice,
17
+ printersHomeDevices,
18
+ } from "./connectors/printers/index.ts";
19
+ import {
20
+ playingElsewhere,
21
+ type SpotifyHomeDevice,
22
+ type SpotifyState,
23
+ spotifyHomeDevices,
24
+ spotifyText,
25
+ } from "./connectors/spotify/index.ts";
26
+ import type { HomeConfig } from "./config.ts";
27
+ import { IDENTITY, sameId } from "./discover.ts";
28
+ import type { DeviceRecord, Part } from "./home.ts";
29
+ import { manifestOf } from "./manifests.ts";
30
+
31
+ // Every device in the home in one list, each answering the same few questions (`DeviceRecord`), with its connector's own
32
+ // typed row in `detail`. A connector with devices exports `<name>HomeDevices(part)` and is listed here, once in the union
33
+ // and once in DEVICES; test/devices.test.ts fails for a manifest that is in neither DEVICES nor SERVICES.
34
+
35
+ /** Any device this library reads. `connector`, then `kind` where a connector has more than one, narrows `detail` and `state`. */
36
+ export type HomeDevice =
37
+ | HueHomeDevice
38
+ | DaikinHomeDevice
39
+ | EnphaseHomeDevice
40
+ | CastHomeDevice
41
+ | PrinterHomeDevice
42
+ | SpotifyHomeDevice;
43
+
44
+ /** Each connector's devices from its part, in the order `home status` lists the connectors. */
45
+ export const DEVICES = {
46
+ hue: hueHomeDevices,
47
+ daikin: daikinHomeDevices,
48
+ enphase: enphaseHomeDevices,
49
+ cast: castHomeDevices,
50
+ printers: printersHomeDevices,
51
+ spotify: spotifyHomeDevices,
52
+ } satisfies Record<string, (part: Part<any> | undefined) => DeviceRecord[]>;
53
+
54
+ /** Connectors that read a service, not a device: nothing to list. */
55
+ export const SERVICES: readonly string[] = ["amber", "up", "weather"];
56
+
57
+ /**
58
+ * Every device in the snapshot. With the home `config`, a device takes the `room` its entry gives it, a Hue room or zone
59
+ * (and its lights) the room whose `hueRoom` names it (a light in a room and a zone, the room's), and a device that reports
60
+ * no name of its own (the Daikin, the Enphase gateway) the entry's `name`. A device's own name is never replaced.
61
+ */
62
+ export function homeDevices(
63
+ snapshot: Record<string, Part | undefined>,
64
+ config?: Pick<HomeConfig, "devices" | "rooms">,
65
+ ): HomeDevice[] {
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
+ );
77
+ if (!config) return all;
78
+ const entries = config.devices ?? [];
79
+ const hueRooms = new Map<string, string>(); // a Hue group's or light's id → the config room showing it
80
+ for (const d of all)
81
+ if (d.connector === "hue" && d.kind === "group") {
82
+ const room = config.rooms?.find(
83
+ (r) =>
84
+ r.hueRoom !== undefined &&
85
+ (r.hueRoom === d.id || r.hueRoom === d.detail.groupedLightId),
86
+ )?.id;
87
+ // A light in a room and a zone takes the room's: rooms come first, and the first one stays.
88
+ if (room)
89
+ for (const id of [d.id, ...d.detail.lightIds])
90
+ if (!hueRooms.has(id)) hueRooms.set(id, room);
91
+ }
92
+ return all.map((d) => {
93
+ const mine = entries.filter((e) => e.connector === d.connector);
94
+ const field = (IDENTITY as Record<string, string>)[d.connector];
95
+ // One device for the connector: its entry is this device. Many: the entry whose identity is this device's id.
96
+ const entry =
97
+ d.id === d.connector
98
+ ? mine[0]
99
+ : mine.find((e) => {
100
+ const id = field && (e as Record<string, unknown>)[field];
101
+ return typeof id === "string" && sameId(id, d.id);
102
+ });
103
+ const room = d.room ?? entry?.room ?? hueRooms.get(d.id);
104
+ // A device with no name of its own carries its connector's title: only then does the entry's name replace it.
105
+ const unnamed = d.name === manifestOf(d.connector)?.title;
106
+ const name = unnamed ? (entry?.name ?? d.name) : d.name;
107
+ // Its line opens with its name, so a new name opens the line too.
108
+ const text =
109
+ name !== d.name && d.text.startsWith(`${d.name}: `)
110
+ ? name + d.text.slice(d.name.length)
111
+ : d.text;
112
+ return { ...d, name, text, ...(room ? { room } : {}) };
113
+ });
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/health.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Part, Status } from "./home.ts";
1
+ import type { Part, Reason, Status } from "./home.ts";
2
2
  import { MANIFESTS } from "./manifests.ts";
3
3
 
4
4
  /**
@@ -14,6 +14,7 @@ export type Health = {
14
14
  staleAfterMs?: number;
15
15
  downAfterMs: number;
16
16
  detail?: string;
17
+ reason?: Reason;
17
18
  };
18
19
 
19
20
  /** A polling connector is stale after this many polls with no good read: two missed, plus half of one so a slow read that ends in a timeout does not flap it, */
@@ -61,7 +62,7 @@ function healthOf(
61
62
  (part.status !== "connected" && (age(part.downSince) ?? 0) >= downAfterMs);
62
63
  const goodAge = age(part.lastGoodAt) ?? age(part.downSince); // never good: as old as the wait for it
63
64
  const stale = staleAfterMs !== undefined && (goodAge ?? 0) >= staleAfterMs;
64
- const { status, lastGoodAt, downSince, detail } = part;
65
+ const { status, lastGoodAt, downSince, detail, reason } = part;
65
66
  return {
66
67
  level: down ? "down" : stale ? "stale" : "ok",
67
68
  status,
@@ -70,5 +71,6 @@ function healthOf(
70
71
  staleAfterMs,
71
72
  downAfterMs,
72
73
  detail,
74
+ reason,
73
75
  };
74
76
  }