@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
@@ -0,0 +1,115 @@
1
+ import { castHomeDevices, } from "./connectors/cast/index.js";
2
+ import { daikinHomeDevices, } from "./connectors/daikin/index.js";
3
+ import { enphaseHomeDevices, } from "./connectors/enphase/index.js";
4
+ import { hueHomeDevices } from "./connectors/hue/index.js";
5
+ import { printersHomeDevices, } from "./connectors/printers/index.js";
6
+ import { playingElsewhere, spotifyHomeDevices, spotifyText, } from "./connectors/spotify/index.js";
7
+ import { IDENTITY, sameId } from "./discover.js";
8
+ import { manifestOf } from "./manifests.js";
9
+ /** Each connector's devices from its part, in the order `home status` lists the connectors. */
10
+ export const DEVICES = {
11
+ hue: hueHomeDevices,
12
+ daikin: daikinHomeDevices,
13
+ enphase: enphaseHomeDevices,
14
+ cast: castHomeDevices,
15
+ printers: printersHomeDevices,
16
+ spotify: spotifyHomeDevices,
17
+ };
18
+ /** Connectors that read a service, not a device: nothing to list. */
19
+ export const SERVICES = ["amber", "up", "weather"];
20
+ /**
21
+ * Every device in the snapshot. With the home `config`, a device takes the `room` its entry gives it, a Hue room or zone
22
+ * (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
23
+ * no name of its own (the Daikin, the Enphase gateway) the entry's `name`. A device's own name is never replaced.
24
+ */
25
+ export function homeDevices(snapshot, config) {
26
+ // Soloist doesn't say where its music went; a Cast device playing that track does, so the speaker's line names it.
27
+ const on = spotifyElsewhere(snapshot);
28
+ const all = Object.entries(DEVICES)
29
+ .flatMap(([name, devices]) => devices(snapshot[name]))
30
+ .map((d) => d.connector === "spotify" && on.length
31
+ ? { ...d, text: spotifyText(d.detail, on) }
32
+ : d);
33
+ if (!config)
34
+ return all;
35
+ const entries = config.devices ?? [];
36
+ const hueRooms = new Map(); // a Hue group's or light's id → the config room showing it
37
+ for (const d of all)
38
+ if (d.connector === "hue" && d.kind === "group") {
39
+ const room = config.rooms?.find((r) => r.hueRoom !== undefined &&
40
+ (r.hueRoom === d.id || r.hueRoom === d.detail.groupedLightId))?.id;
41
+ // A light in a room and a zone takes the room's: rooms come first, and the first one stays.
42
+ if (room)
43
+ for (const id of [d.id, ...d.detail.lightIds])
44
+ if (!hueRooms.has(id))
45
+ hueRooms.set(id, room);
46
+ }
47
+ return all.map((d) => {
48
+ const mine = entries.filter((e) => e.connector === d.connector);
49
+ const field = IDENTITY[d.connector];
50
+ // One device for the connector: its entry is this device. Many: the entry whose identity is this device's id.
51
+ const entry = d.id === d.connector
52
+ ? mine[0]
53
+ : mine.find((e) => {
54
+ const id = field && e[field];
55
+ return typeof id === "string" && sameId(id, d.id);
56
+ });
57
+ const room = d.room ?? entry?.room ?? hueRooms.get(d.id);
58
+ // A device with no name of its own carries its connector's title: only then does the entry's name replace it.
59
+ const unnamed = d.name === manifestOf(d.connector)?.title;
60
+ const name = unnamed ? (entry?.name ?? d.name) : d.name;
61
+ // Its line opens with its name, so a new name opens the line too.
62
+ const text = name !== d.name && d.text.startsWith(`${d.name}: `)
63
+ ? name + d.text.slice(d.name.length)
64
+ : d.text;
65
+ return { ...d, name, text, ...(room ? { room } : {}) };
66
+ });
67
+ }
68
+ /**
69
+ * Where the account's Spotify is playing when the Soloist speaker says it is on another device: the names of the Cast
70
+ * devices and groups playing a track of that name in Spotify. A group is named, not its members. Empty when the music is
71
+ * on the speaker itself, nothing is playing, or it is on something Cast can't see (a phone, a laptop). Matched by the
72
+ * track's name, since neither side names the other.
73
+ */
74
+ export function spotifyElsewhere(snapshot) {
75
+ const s = snapshot.spotify?.state;
76
+ const cast = snapshot.cast?.state;
77
+ const name = s?.track?.name;
78
+ if (!playingElsewhere(s) || !name || !cast)
79
+ return [];
80
+ return Object.values(cast.devices)
81
+ .filter((d) => d.status === "connected" &&
82
+ !d.playingVia && // its group's music: the group is named
83
+ d.app === "Spotify" &&
84
+ d.title === name &&
85
+ (d.playerState === "PLAYING" || d.playerState === "BUFFERING"))
86
+ .map((d) => d.name);
87
+ }
88
+ /**
89
+ * `homeDevices` room by room, in the config's order, for a map of the home. A connector that is down but has read before
90
+ * still lists its devices, `available: false`; `noData` names one the config places in the room (a device's `room`, or
91
+ * `hueRoom`) that has nothing to list. `unplaced`: devices with no room, or one the config doesn't have.
92
+ */
93
+ export function byRoom(snapshot, config) {
94
+ const all = homeDevices(snapshot, config);
95
+ const ids = new Set((config.rooms ?? []).map((r) => r.id));
96
+ return {
97
+ rooms: (config.rooms ?? []).map((r) => {
98
+ const devices = all.filter((d) => d.room === r.id);
99
+ const expected = new Set([
100
+ ...(config.devices ?? [])
101
+ .filter((d) => d.room === r.id)
102
+ .map((d) => d.connector),
103
+ ...(r.hueRoom === undefined ? [] : ["hue"]),
104
+ ]);
105
+ return {
106
+ id: r.id,
107
+ name: r.name,
108
+ ...(r.floor === undefined ? {} : { floor: r.floor }),
109
+ devices,
110
+ noData: [...expected].filter((c) => c in DEVICES && !devices.some((d) => d.connector === c)),
111
+ };
112
+ }),
113
+ unplaced: all.filter((d) => !d.room || !ids.has(d.room)),
114
+ };
115
+ }
package/dist/discover.js CHANGED
@@ -82,7 +82,7 @@ function newEntry(f) {
82
82
  [IDENTITY[f.connector]]: f.id,
83
83
  };
84
84
  if (f.connector === "hue")
85
- return { ...base, key: "HUE_APPLICATION_KEY", ca: "hue-root-ca.pem" };
85
+ return { ...base, key: "HUE_APPLICATION_KEY" };
86
86
  if (f.connector === "enphase")
87
87
  return { ...base, token: "~/.config/home/enphase-token.json" };
88
88
  return base;
@@ -1 +1 @@
1
- export { FINDERS, fileCache } from "./finders.ts";
1
+ export { FINDERS, fileCache, MissingTool } from "./finders.ts";
@@ -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.js";
2
+ export { FINDERS, fileCache, MissingTool } from "./finders.js";
package/dist/finders.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type AddressCache, type Connector, type Finder, type Found } from "./discover.ts";
2
2
  /** The system's mDNS tool is not installed: discovery can't run until it is. */
3
+ /** The discovery tool isn't installed (`dns-sd` on macOS, `avahi-browse` from avahi-utils on Linux): the message names it. */
3
4
  export declare class MissingTool extends Error {
4
5
  }
5
6
  /**
@@ -27,7 +28,7 @@ export declare function mdnsResolve(type: string): Promise<{
27
28
  }[]>;
28
29
  /** Daikin's `key=value,key=value` answers, percent-decoded. */
29
30
  export declare const daikinFields: (text: string) => any;
30
- /** Broadcast pydaikin's discovery packet (UDP 30000 → 30050) and collect who answers within `ms`. */
31
+ /** Broadcast pydaikin's discovery packet (UDP 30000 → 30050), three times across `ms`, and collect who answers, each unit once. */
31
32
  export declare function findDaikins(ms?: number): Promise<{
32
33
  ip: string;
33
34
  fields: Record<string, string>;
package/dist/finders.js CHANGED
@@ -13,6 +13,7 @@ import { parseAvahi, parseAvahiBrowse, parseBrowse, parseResolve, } from "./disc
13
13
  // does not slow a scan. Without the tool the mDNS finders throw, naming it, rather than "nothing answered".
14
14
  // `fileCache` keeps the addresses discovery found in a file between runs, so the next run connects straight away.
15
15
  /** The system's mDNS tool is not installed: discovery can't run until it is. */
16
+ /** The discovery tool isn't installed (`dns-sd` on macOS, `avahi-browse` from avahi-utils on Linux): the message names it. */
16
17
  export class MissingTool extends Error {
17
18
  }
18
19
  /**
@@ -94,17 +95,27 @@ function decoded(v) {
94
95
  return v;
95
96
  }
96
97
  }
97
- /** Broadcast pydaikin's discovery packet (UDP 30000 → 30050) and collect who answers within `ms`. */
98
+ /** Broadcast pydaikin's discovery packet (UDP 30000 → 30050), three times across `ms`, and collect who answers, each unit once. */
98
99
  export function findDaikins(ms = 1500) {
99
100
  return new Promise((resolve) => {
100
101
  const found = [];
101
102
  const sock = createSocket({ type: "udp4", reuseAddr: true });
102
- sock.on("message", (msg, from) => found.push({ ip: from.address, fields: daikinFields(msg.toString()) }));
103
- sock.on("error", () => (sock.close(), resolve(found)));
103
+ sock.on("message", (msg, from) => {
104
+ if (!found.some((f) => f.ip === from.address))
105
+ found.push({ ip: from.address, fields: daikinFields(msg.toString()) });
106
+ });
107
+ const asks = [];
108
+ const done = () => (asks.forEach(clearTimeout),
109
+ sock.close(),
110
+ resolve(found));
111
+ sock.on("error", done);
104
112
  sock.bind(30000, () => {
105
113
  sock.setBroadcast(true);
106
- sock.send("DAIKIN_UDP/common/basic_info", 30050, "255.255.255.255");
107
- setTimeout(() => (sock.close(), resolve(found)), ms);
114
+ // 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.
115
+ const ask = () => sock.send("DAIKIN_UDP/common/basic_info", 30050, "255.255.255.255");
116
+ ask();
117
+ asks.push(setTimeout(ask, ms / 3), setTimeout(ask, (2 * ms) / 3));
118
+ setTimeout(done, ms);
108
119
  });
109
120
  });
110
121
  }
package/dist/health.d.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
  /**
3
3
  * How one integration is doing, judged by time: `ok`, `stale` (no good read for a while, last values still shown) or `down`.
4
4
  * The thresholds come with it, so a page can say "stale after 4 min". Push connectors (Hue, Cast) have no poll to be late
@@ -12,6 +12,7 @@ export type Health = {
12
12
  staleAfterMs?: number;
13
13
  downAfterMs: number;
14
14
  detail?: string;
15
+ reason?: Reason;
15
16
  };
16
17
  /** 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, */
17
18
  export declare const STALE_POLLS = 2.5;
package/dist/health.js CHANGED
@@ -22,7 +22,7 @@ function healthOf(part, now, pollSeconds) {
22
22
  (part.status !== "connected" && (age(part.downSince) ?? 0) >= downAfterMs);
23
23
  const goodAge = age(part.lastGoodAt) ?? age(part.downSince); // never good: as old as the wait for it
24
24
  const stale = staleAfterMs !== undefined && (goodAge ?? 0) >= staleAfterMs;
25
- const { status, lastGoodAt, downSince, detail } = part;
25
+ const { status, lastGoodAt, downSince, detail, reason } = part;
26
26
  return {
27
27
  level: down ? "down" : stale ? "stale" : "ok",
28
28
  status,
@@ -31,5 +31,6 @@ function healthOf(part, now, pollSeconds) {
31
31
  staleAfterMs,
32
32
  downAfterMs,
33
33
  detail,
34
+ reason,
34
35
  };
35
36
  }
package/dist/home.d.ts CHANGED
@@ -2,12 +2,14 @@ import { z } from "zod";
2
2
  import { type Clock } from "./clock.ts";
3
3
  /** A secret in a home config is the name of an environment variable, never the secret: a pasted key fails here. */
4
4
  export declare const envName: z.ZodString;
5
- /** A file a connector needs (a CA, a token), relative to the config file. The caller reads it; the library reads no files. */
5
+ /** A file a connector needs (a CA, a token), relative to the config file or from `~/`. The caller reads it, and expands `~/`; the library reads no files. */
6
6
  export declare const filePath: z.ZodString;
7
7
  /** How an integration is doing. "unavailable" means it stopped and will not retry by itself. */
8
8
  export type Status = "connecting" | "connected" | "disconnected" | "unavailable";
9
+ /** Why a part is `unavailable`, for code to switch on; `detail` is the text for people. `auth`: a key or token was rejected or is unusable, so a person must re-key. `config`: a setting or a file is wrong. `crashed`: its `run` threw. */
10
+ export type Reason = "auth" | "config" | "crashed";
9
11
  /**
10
- * One integration's slice of the home: its status, a human detail, anything odd the device reported, and its state.
12
+ * One integration's slice of the home: its status, a human detail, `reason` (why it is `unavailable`, for code to switch on), anything odd the device reported, and its state.
11
13
  * `createHome` stamps the times (UTC ISO). `lastGoodAt`: its last report while connected, or the read time a connected report
12
14
  * gives when it repeats an older read (weather's 5-minute sun tick). `downSince`: when it stopped being connected (or started,
13
15
  * if it never has); none while connected. Any other time an integration sets is ignored.
@@ -15,11 +17,47 @@ export type Status = "connecting" | "connected" | "disconnected" | "unavailable"
15
17
  export type Part<S = unknown> = {
16
18
  status: Status;
17
19
  detail?: string;
20
+ reason?: Reason;
18
21
  anomaly?: string;
19
22
  state?: S;
20
23
  lastGoodAt?: string;
21
24
  downSince?: string;
22
25
  };
26
+ /**
27
+ * The few things every device answers, whatever it is, in the same words; a connector's `<name>HomeDevices(part)` returns
28
+ * these and `homeDevices(snapshot)` collects them. Everything particular to its kind is in `detail`, the connector's own
29
+ * typed row, and `connector` (with `kind`) says which type that is. Unknown stays absent, never guessed.
30
+ * - `id`: the device's own stable id (a Hue light id, a castId, a printer's UUID), or the connector's name when it has one device.
31
+ * - `kind`: light, group, speaker, display, tv, ac, printer, solar (and `cast` for a Cast model this library doesn't know).
32
+ * - `available`: it answered and can be used now. When it is false, `reason` says why in one word: `offline`, or the
33
+ * connector's own reason for being `unavailable` (`auth`, `config`, `crashed`).
34
+ * - `asOf`: when the device last reported (a push connector) or was last read (a polled one). A quiet light can be hours
35
+ * old and current: `available` says whether it is.
36
+ * - `state`: one short word in that kind's vocabulary: on, off, idle, busy, cool, printing.
37
+ * - `text`: one line for people, "Kitchen: on · 60%".
38
+ * - `room`: a room id from the home config, when one places it.
39
+ * - `members`: a group's device ids.
40
+ */
41
+ export type DeviceRecord<C extends string = string, K extends string = string, S extends string = string, D = unknown> = {
42
+ connector: C;
43
+ id: string;
44
+ name: string;
45
+ kind: K;
46
+ available?: boolean;
47
+ reason?: Reason | "offline";
48
+ asOf?: string;
49
+ state?: S;
50
+ text: string;
51
+ room?: string;
52
+ members?: string[];
53
+ detail: D;
54
+ };
55
+ /**
56
+ * A connector's device records from its part. While the connector isn't connected none is `available`, and each keeps its
57
+ * last values, as the part's state does. A device that isn't available gets its `reason`: the part's when it stopped for
58
+ * good, else `offline`.
59
+ */
60
+ export declare const deviceRecords: <R extends DeviceRecord>(part: Part | undefined, records: R[]) => R[];
23
61
  /**
24
62
  * An integration is a folder in `src/` that exports a factory returning this.
25
63
  * `run` keeps its state current until `signal` aborts, reporting each change through `update`.
@@ -49,7 +87,7 @@ export type Change<I extends Integration<any, string> = Integration<unknown, str
49
87
  }[I["name"]];
50
88
  /**
51
89
  * Load a home: run each integration, keep one snapshot, call `onChange` with it and what changed after every report.
52
- * `clock` stamps each part's `lastGoodAt` and `downSince`.
90
+ * `clock` stamps each part's `lastGoodAt` and `downSince`. `add` and `remove` change the set while it runs.
53
91
  */
54
92
  export declare function createHome<I extends Integration<any, string>>(integrations: readonly I[], onChange?: (home: HomeSnapshot<I>, change: Change<I>) => void, options?: {
55
93
  clock?: Clock;
@@ -64,10 +102,19 @@ export declare function createHome<I extends Integration<any, string>>(integrati
64
102
  }> | undefined;
65
103
  /**
66
104
  * Read now: one integration by name, or every one. Results arrive through the snapshot and `onChange` as usual; the
67
- * promise resolves once each target has reported (its next update, good or bad), or at once for one whose run has ended.
105
+ * promise resolves once each target has reported (its next update, good or bad), or at once for one that isn't running.
68
106
  */
69
107
  refresh(name?: I["name"]): Promise<void>;
70
- /** Run every integration until `signal` aborts. One failing marks itself unavailable; the rest carry on. */
108
+ /**
109
+ * Add an integration while the home runs, or before it starts: a bridge paired just now, a key that arrived late. One
110
+ * already there under that name is stopped first and its part dropped, so the new one starts from `connecting` and
111
+ * the two never run together. Resolves once the new one is running; its reports arrive as any other's. Calls on
112
+ * one home run one at a time. Once `start`'s signal has aborted, an added one waits for the next `start`.
113
+ */
114
+ add: (integration: I) => Promise<void>;
115
+ /** Stop one integration and drop its part from the snapshot. Resolves once its run has ended; false if there was none by that name. */
116
+ remove: (name: I["name"]) => Promise<boolean>;
117
+ /** Run every integration, and any added meanwhile, until `signal` aborts. One failing marks itself unavailable; the rest carry on. */
71
118
  start(signal: AbortSignal): Promise<void>;
72
119
  };
73
120
  /**
package/dist/home.js CHANGED
@@ -5,8 +5,21 @@ import { errorText } from "./connector.js";
5
5
  export const envName = z
6
6
  .string()
7
7
  .regex(/^[A-Z_][A-Z0-9_]*$/, "an environment variable name (e.g. AMBER_KEY), not the secret itself");
8
- /** A file a connector needs (a CA, a token), relative to the config file. The caller reads it; the library reads no files. */
8
+ /** A file a connector needs (a CA, a token), relative to the config file or from `~/`. The caller reads it, and expands `~/`; the library reads no files. */
9
9
  export const filePath = z.string().min(1);
10
+ /**
11
+ * A connector's device records from its part. While the connector isn't connected none is `available`, and each keeps its
12
+ * last values, as the part's state does. A device that isn't available gets its `reason`: the part's when it stopped for
13
+ * good, else `offline`.
14
+ */
15
+ export const deviceRecords = (part, records) => {
16
+ const up = part?.status === "connected";
17
+ // A part that stopped for good without saying why is a custom integration's: `config` is the nearest true word, since it won't come back by itself.
18
+ const why = part?.status === "unavailable" ? (part.reason ?? "config") : "offline";
19
+ return records.map((r) => up && r.available !== false
20
+ ? r
21
+ : { ...r, available: false, reason: up ? "offline" : why });
22
+ };
10
23
  /** The times `createHome` keeps on a part, from the one before it: when it was last good, and when it stopped being. */
11
24
  function stamped(part, before, now) {
12
25
  if (part.status === "connected")
@@ -23,24 +36,75 @@ function stamped(part, before, now) {
23
36
  }
24
37
  /**
25
38
  * Load a home: run each integration, keep one snapshot, call `onChange` with it and what changed after every report.
26
- * `clock` stamps each part's `lastGoodAt` and `downSince`.
39
+ * `clock` stamps each part's `lastGoodAt` and `downSince`. `add` and `remove` change the set while it runs.
27
40
  */
28
41
  export function createHome(integrations, onChange, options = {}) {
29
42
  const clock = options.clock ?? realClock;
30
- let snapshot = Object.fromEntries(integrations.map((i) => [i.name, { status: "connecting" }]));
43
+ const list = [...integrations]; // the integrations now: `add` and `remove` change it
44
+ const parts = () => snapshot;
45
+ let snapshot = Object.fromEntries(list.map((i) => [i.name, { status: "connecting" }]));
31
46
  const waiting = new Map(); // refresh() callers, resolved by the integration's next report
32
- const finished = new Set(); // integrations whose run has ended: a refresh of one resolves at once, nothing will report
33
- const set = (name, reported) => {
34
- const before = snapshot[name];
47
+ const runs = new Map(); // the ones running: before start or after its run ended, a refresh resolves at once, nothing will report
48
+ const latest = new WeakMap(); // each integration's newest run: a report from an older run of the same object is dropped
49
+ let started; // start()'s signal, while it runs
50
+ let changing = Promise.resolve(); // add and remove, one at a time: two on one name must not overlap
51
+ let changes = 0; // those under way or queued: start outlasts a replace of its only integration
52
+ const change = (fn) => {
53
+ changes++;
54
+ const done = changing.then(fn).finally(() => changes--);
55
+ changing = done.catch(() => { });
56
+ return done;
57
+ };
58
+ const heard = (name) => {
59
+ for (const resolve of waiting.get(name) ?? [])
60
+ resolve();
61
+ waiting.delete(name);
62
+ };
63
+ // A report counts only from the integration now listed under its name: one replaced or removed may still be finishing.
64
+ const set = (i, run, reported) => {
65
+ const name = i.name;
66
+ const before = parts()[name];
67
+ if (latest.get(i) !== run || !list.includes(i) || !before)
68
+ return;
35
69
  const part = stamped(reported, before, asOf(clock));
36
70
  snapshot = { ...snapshot, [name]: part };
37
- onChange?.(snapshot, { name, before, after: part });
38
- const w = waiting.get(name);
39
- if (w) {
40
- waiting.delete(name);
41
- for (const resolve of w)
42
- resolve();
43
- }
71
+ Promise.try(() => onChange?.(snapshot, { name, before, after: part })).catch((error) => console.error(`onChange threw on ${name}: ${errorText(error)}`)); // the caller's bug, thrown or async, not the device's: the integration keeps running
72
+ heard(name);
73
+ };
74
+ /** Run one integration until the home's signal or its own `stop` aborts. One failing marks itself unavailable. */
75
+ const launch = (i, signal) => {
76
+ const stop = new AbortController();
77
+ const run = { stop, done: Promise.resolve() };
78
+ runs.set(i, run);
79
+ latest.set(i, run);
80
+ run.done = Promise.try(() => i.run(AbortSignal.any([signal, stop.signal]), (part) => set(i, run, part)))
81
+ .catch((error) => set(i, run, {
82
+ status: "unavailable",
83
+ reason: "crashed",
84
+ detail: errorText(error),
85
+ state: parts()[i.name]?.state, // the last good state stays, as it does through a failed read
86
+ }))
87
+ .finally(() => {
88
+ if (runs.get(i) !== run)
89
+ return;
90
+ runs.delete(i);
91
+ if (list.includes(i))
92
+ heard(i.name); // nothing more will report
93
+ });
94
+ };
95
+ /** Take one out of the list and the snapshot, and wait for its run to end. */
96
+ const drop = async (name) => {
97
+ const at = list.findIndex((i) => i.name === name);
98
+ if (at < 0)
99
+ return false;
100
+ const [old] = list.splice(at, 1);
101
+ const { [name]: _, ...rest } = parts();
102
+ snapshot = rest;
103
+ heard(name);
104
+ const run = runs.get(old);
105
+ run?.stop.abort();
106
+ await run?.done;
107
+ return true;
44
108
  };
45
109
  return {
46
110
  /** The latest snapshot. Never touches a device. */
@@ -48,19 +112,17 @@ export function createHome(integrations, onChange, options = {}) {
48
112
  /** One integration's part, typed by its name: `home.part("weather")?.state?.blocks`. */
49
113
  part: (name) => snapshot[name],
50
114
  /** One integration by name, for its writes: `home.integration("daikin")?.client.write(setPower(true))`. */
51
- integration: (name) => integrations.find((i) => i.name === name),
115
+ integration: (name) => list.find((i) => i.name === name),
52
116
  /**
53
117
  * Read now: one integration by name, or every one. Results arrive through the snapshot and `onChange` as usual; the
54
- * promise resolves once each target has reported (its next update, good or bad), or at once for one whose run has ended.
118
+ * promise resolves once each target has reported (its next update, good or bad), or at once for one that isn't running.
55
119
  */
56
120
  refresh(name) {
57
- const targets = name === undefined
58
- ? integrations
59
- : integrations.filter((i) => i.name === name);
60
- if (!targets.length)
61
- throw new Error(`no integration "${name}"; have ${integrations.map((i) => i.name).join(", ")}`);
121
+ const targets = name === undefined ? [...list] : list.filter((i) => i.name === name);
122
+ if (!targets.length && name !== undefined)
123
+ throw new Error(`no integration "${name}"; have ${list.map((i) => i.name).join(", ")}`);
62
124
  const reported = targets.map((i) => new Promise((resolve) => {
63
- if (finished.has(i.name))
125
+ if (!runs.has(i))
64
126
  return resolve();
65
127
  waiting.set(i.name, [...(waiting.get(i.name) ?? []), resolve]);
66
128
  }));
@@ -68,25 +130,44 @@ export function createHome(integrations, onChange, options = {}) {
68
130
  i.refresh();
69
131
  return Promise.all(reported).then(() => undefined);
70
132
  },
71
- /** Run every integration until `signal` aborts. One failing marks itself unavailable; the rest carry on. */
133
+ /**
134
+ * Add an integration while the home runs, or before it starts: a bridge paired just now, a key that arrived late. One
135
+ * already there under that name is stopped first and its part dropped, so the new one starts from `connecting` and
136
+ * the two never run together. Resolves once the new one is running; its reports arrive as any other's. Calls on
137
+ * one home run one at a time. Once `start`'s signal has aborted, an added one waits for the next `start`.
138
+ */
139
+ add: (integration) => change(async () => {
140
+ await drop(integration.name);
141
+ list.push(integration);
142
+ const live = started && !started.aborted ? started : undefined;
143
+ snapshot = {
144
+ ...snapshot,
145
+ [integration.name]: {
146
+ status: "connecting",
147
+ ...(live ? { downSince: asOf(clock) } : {}),
148
+ },
149
+ };
150
+ if (live)
151
+ launch(integration, live);
152
+ }),
153
+ /** Stop one integration and drop its part from the snapshot. Resolves once its run has ended; false if there was none by that name. */
154
+ remove: (name) => change(() => drop(name)),
155
+ /** Run every integration, and any added meanwhile, until `signal` aborts. One failing marks itself unavailable; the rest carry on. */
72
156
  async start(signal) {
73
- finished.clear(); // a second start, after the first has stopped, runs them all again
157
+ started = signal;
74
158
  const now = asOf(clock); // the wait for a first report counts from here, not from createHome
75
- snapshot = Object.fromEntries(Object.entries(snapshot).map(([name, p]) => [
159
+ snapshot = Object.fromEntries(Object.entries(parts()).map(([name, p]) => [
76
160
  name,
77
161
  p.status === "connecting"
78
162
  ? { ...p, downSince: p.downSince ?? now }
79
163
  : p,
80
164
  ]));
81
- await Promise.all(integrations.map((i) => i
82
- .run(signal, (part) => set(i.name, part))
83
- .catch((error) => set(i.name, { status: "unavailable", detail: errorText(error) }))
84
- .finally(() => {
85
- finished.add(i.name);
86
- for (const resolve of waiting.get(i.name) ?? [])
87
- resolve(); // nothing more will report
88
- waiting.delete(i.name);
89
- })));
165
+ for (const i of list)
166
+ launch(i, signal); // a second start, after the first has stopped, runs them all again
167
+ // Until every run has ended, those added meanwhile too.
168
+ while (runs.size || changes)
169
+ await Promise.all([...[...runs.values()].map((r) => r.done), changing]);
170
+ // `started` stays: one added after every run has ended still runs, until the signal aborts.
90
171
  },
91
172
  };
92
173
  }
package/dist/index.d.ts CHANGED
@@ -1,7 +1,8 @@
1
- export { type Change, createHome, envName, filePath, settle, type HomeSnapshot, type Integration, type Part, type StateOf, type Status, } from "./home.ts";
1
+ export { type Change, createHome, type DeviceRecord, deviceRecords, envName, filePath, settle, type HomeSnapshot, type Integration, type Part, type Reason, type StateOf, type Status, } from "./home.ts";
2
2
  export { DOWN_POLLS, type Health, health, PUSH_DOWN_MS, STALE_POLLS, } from "./health.ts";
3
+ export { byRoom, type HomeDevice, homeDevices, type RoomDevices, spotifyElsewhere, } from "./devices.ts";
3
4
  export { asOf, type Clock, realClock } from "./clock.ts";
4
- export { ConnectorError } from "./connector.ts";
5
+ export { type ConnectorCode, ConnectorError } from "./connector.ts";
5
6
  export { bestWindow, type EnergyNow, energyNow, gridText, type PriceWindow, solarLevel, } from "./energy.ts";
6
7
  export { checkHome, checkPlan, Device, HomeConfig, HomeConfigError, type HomeIntegration, type LoadOptions, loadHome, parseJsonc, Room, type Setup, } from "./config.ts";
7
8
  export type { AddressCache, CachedAddress, Connector, Finder, Found, } from "./discover.ts";
package/dist/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  // The root entry: the home, its config, discovery types, the clock, energy, and the sun, rain and day-block helpers. The weather types are in `./weather`.
2
2
  // Each connector has its own entry point (`@khalido/home/hue` and so on). Every export is named; test/exports.test.ts lists them.
3
- export { createHome, envName, filePath, settle, } from "./home.js";
3
+ export { createHome, deviceRecords, envName, filePath, settle, } from "./home.js";
4
4
  export { DOWN_POLLS, health, PUSH_DOWN_MS, STALE_POLLS, } from "./health.js";
5
+ export { byRoom, homeDevices, spotifyElsewhere, } from "./devices.js";
5
6
  export { asOf, realClock } from "./clock.js";
6
7
  export { ConnectorError } from "./connector.js";
7
8
  export { bestWindow, energyNow, gridText, solarLevel, } from "./energy.js";
@@ -1,5 +1,5 @@
1
- /** A `fetch` that trusts only `ca` and accepts only a certificate whose CN is `bridgeId`: Node's `bunPinnedFetch`. */
2
- export declare function nodePinnedFetch(bridgeId: string, ca: string): typeof fetch;
1
+ /** A `fetch` that trusts only `ca` (Signify's roots when left out) and accepts only a certificate whose CN is `bridgeId`: Node's `bunPinnedFetch`. */
2
+ export declare function nodePinnedFetch(bridgeId: string, ca?: string): typeof fetch;
3
3
  /** A `fetch` for the Enphase gateway that skips certificate verification, for its requests only: Node's `bunGatewayFetch`. */
4
4
  export declare function nodeGatewayFetch(): typeof fetch;
5
5
  /** What `loadHome` takes as `tls` under Node. */
@@ -1,11 +1,12 @@
1
1
  import { Agent, fetch as undiciFetch } from "undici";
2
+ import { HUE_BRIDGE_ROOT_CA } from "./connectors/hue/ca.js";
2
3
  // `@khalido/home/node`: the fetches Hue and Enphase need under Node, where Bun's `tls` fetch option does not exist. Node's
3
4
  // global `fetch` takes no CA, so these use undici's own `fetch` and `Agent` (one undici on both sides). Outside the
4
5
  // runtime-neutral build, like cast/tls.ts; a Node consumer passes `NODE_TLS` to `loadHome`, or one fetch to `hue()` or `enphase()`.
5
6
  /** undici's `fetch` through `dispatcher`. Takes a URL, not a `Request`, and returns undici's own `Response`. */
6
7
  const through = (dispatcher) => ((input, init) => undiciFetch(input, { ...init, dispatcher }));
7
- /** A `fetch` that trusts only `ca` and accepts only a certificate whose CN is `bridgeId`: Node's `bunPinnedFetch`. */
8
- export function nodePinnedFetch(bridgeId, ca) {
8
+ /** A `fetch` that trusts only `ca` (Signify's roots when left out) and accepts only a certificate whose CN is `bridgeId`: Node's `bunPinnedFetch`. */
9
+ export function nodePinnedFetch(bridgeId, ca = HUE_BRIDGE_ROOT_CA) {
9
10
  const checkServerIdentity = (_host, cert) => {
10
11
  const cn = [cert.subject?.CN].flat()[0];
11
12
  return cn?.toLowerCase() === bridgeId.toLowerCase()
package/docs/cli.md CHANGED
@@ -3,11 +3,16 @@
3
3
  ```sh
4
4
  home <connector> [--json] # one connector's lines: amber, cast, daikin, enphase, hue, printers, spotify, up, weather
5
5
  home [status|doctor] [--json] # each integration: connected or not, which env names it still needs, rooms
6
+ home devices [--json] # every device the connectors read, one line each: its state, its room (with a config), and why when it isn't available
6
7
  home up transactions | categories | uncategorised | spend | categorise | tag | webhooks # Up Bank's lists and reports; categorise and tag write
7
- home lights off # one write to the whole-home group
8
- home room <name> on|off|<1-100>
9
- home scene <name>
10
- home pair # press the bridge's button first; prints the env lines to set
8
+ home lights off # one write to the whole-home group; prints how many lights are off, or how many the bridge still reports on
9
+ home light <name> [on|off] [<1-100>] [<kelvin>k] [<hex colour>] # one light: `home light "Desk lamp" 40 2700k`, `home light Shelf ff8800`
10
+ home room <name> [on|off] [<1-100>] [<kelvin>k] [<hex colour>] # every light in a room or zone, one write
11
+ home scene <name> [in <room>] # a scene; name the room when several have one by that name
12
+ home pair # waits 60 s for the bridge's button, counting down; prints the env lines to set
13
+ home daikin on | off # power only: the AC comes back as it was last left
14
+ home daikin cool | heat | auto [°C] | dry | fan # mode, on, and the setpoint: the one given, else cool 25 and heat 27; prints what the unit then reports. Live on KO's unit: on, and dry to cool at 25 (2026-11-11); the rest untried
15
+ # --dry-run on a hue or daikin write (lights, light, room, scene, daikin …): check it and print what would be sent; nothing is sent. Refused on every other command
11
16
  home enphase login # once a year: mints the Enphase owner token into ~/.config/home/ (0600)
12
17
  home discover [--json] [--passes n] # which smart-home devices answer here (mDNS + Daikin UDP), which this package supports, then with a config how they match it; two passes 5 s apart
13
18
  home cache [clear] # the addresses kept between runs, or delete them so the next run discovers afresh
@@ -19,6 +24,7 @@ home cast announce --to <name> [--to <name>…] (--say "<text>" | --file <path>)
19
24
  home cast volume <name> <0-100>
20
25
  home cast mute <name> on|off
21
26
  home cast stop <name> # end the running app; the device returns to its idle screen
27
+ # volume, mute and stop print the device's line once it reports the change (within 2 s), else "sent; the device has not reported it yet"
22
28
  home spotify activate | pause | play [link] | next | volume <0-100> # the Soloist speaker at SOLOIST_WS; volume held to SOLOIST_MAX_VOLUME; prints what Soloist then reported
23
29
  ```
24
30
 
@@ -26,16 +32,16 @@ home spotify activate | pause | play [link] | next | volume <0-100> # the Soloi
26
32
 
27
33
  - **stdout is data, stderr everything else.** Notes such as "nothing to categorise" or "no webhooks" go to stderr, so `home … | jq` never sees them.
28
34
  - **Bun on PATH.** The command starts with `#!/usr/bin/env bun`. A non-login SSH shell may not have `~/.bun/bin` on its PATH: run `PATH="$HOME/.bun/bin:$PATH" home`.
29
- - **`--json` shapes.** `home --json` is one object keyed by connector, with `now` (`energyNow`) first. `home <connector> --json` is that connector's object: `status`, `needs`, `access`, `detail`, `anomaly`, then its state (the weather adds `line`, its text). Lists (`home up transactions`, `uncategorised`, `spend --by merchant`) are arrays, `[]` when empty.
35
+ - **`--json` shapes.** `home --json` is one object keyed by connector, with `now` (`energyNow`) first. `home devices --json` is the array `homeDevices` returns; a connector that isn't connected is named on stderr, and the command exits 0. `home <connector> --json` is that connector's object: `status`, `needs`, `access`, `detail`, `reason` (why it is `unavailable`, when known: `auth`, `config` or `crashed`), `anomaly`, then its state (the weather adds `line`, its text). Lists (`home up transactions`, `uncategorised`, `spend --by merchant`) are arrays, `[]` when empty.
30
36
  - **Errors under `--json`** are one object on stderr: `{"error": "…", "code": "usage" | "auth" | "network" | "error", "fix": "home enphase login"}`; `fix` is there only when one command resolves it. In text: `home: <error> (run \`<fix>\`)`.
31
37
  - **Exit codes.** 0 success, empty results included. 1 a failure, and `home <connector>` when that connector is not `connected`, so a script can test one. 2 a wrong command, verb or argument. `home` alone lists every connector and exits 0 whatever their state.
32
38
 
33
39
  With `--config <home.jsonc>` or `HOME_CONFIG`, `status` runs the integrations the config describes until each has answered (at most 15 s) and `probe` takes its addresses from it; keys still come from the shell, by the names in the file. Without one, it reads `HUE_*` and the other names in `.env.example` from the shell, and parses arguments with [cac](https://github.com/cacjs/cac), which also generates `home --help`. Every connector has a verb of its own: `home amber`, `home cast`, `home hue` print only that connector's lines (`--json` for its record), and the verbs that need more sit under it (`home hue pair`, `home enphase login`, `home cast announce …`). The commands come from each connector's manifest (`src/manifest.ts`), as does `.env.example` (`bun run gen`). With no `HUE_BRIDGE_HOST` it finds the bridge by mDNS (`dns-sd`, macOS) every time and caches nothing. `home` with no arguments is `status`:
34
40
 
35
41
  ```
36
- hue not set up · needs HUE_APPLICATION_KEY, HUE_CA_FILE (local HTTPS, key from `home pair`)
42
+ hue not set up · needs HUE_APPLICATION_KEY (local HTTPS, key from `home pair`)
37
43
  daikin connected · off · cool 25 °C · room 24 °C · outdoor unit 19.5 °C (local HTTP JSON (dsiot), no key)
38
44
  weather connected · Sydney - Observatory Hill (1.1 km) 20:20: 16.6 °C, feels 16.7, 93%, 23.6 mm since 9am to 20:23 · today showers rain 80%, tomorrow 14–19 °C cloudy rain 30% (bom) · cloud 100%, UV 0, 0 W/m² (open-meteo) · sun -17°, setting, rises 06:15 (BOM anonymous FTP, Open-Meteo, Google with GOOGLE_WEATHER_KEY; sun computed)
39
45
  ```
40
46
 
41
- Startup is about 10 ms and 38 MB RSS for `home status` without a key; with Enphase, Amber and the Daikin live it takes 0.4–0.8 s, the devices read in parallel (Bun 1.4.2, Apple M5, measured 2026-10-13). The weather line adds 1.8–2.0 s, nearly all of it BOM's FTP (two files, one connection each). Cast takes about 0.3 s: `dns-sd` answers from the system's mDNS cache in 4–13 ms, the browse waits 200 ms of quiet for stragglers, each resolve ends on its TXT line, and seven sockets connect and answer in 50–80 ms (`home status` from a Cast-only config: 0.33–0.44 s, was 3.2 s with fixed 1.5 s windows; Bun 1.4.2, measured 2026-10-13). A configured entry discovery misses costs the second pass, 5 s more. `status` reads addresses from the environment only, except Cast, which has none to read; `discover` and `probe` do the other network searches. A full-screen TUI was considered (OpenTUI 0.6.2, 52 MB of native binaries) and skipped for now.
47
+ Startup is about 10 ms and 38 MB RSS for `home status` without a key; with Enphase, Amber and the Daikin live it takes 0.4–0.8 s, the devices read in parallel (Bun 1.4.2, Apple M5, measured 2026-10-13). The weather line adds 1.8–2.0 s, nearly all of it BOM's FTP (two files, one connection each). Cast takes about 0.3 s: `dns-sd` answers from the system's mDNS cache in 4–13 ms, the browse waits 200 ms of quiet for stragglers, each resolve ends on its TXT line, and seven sockets connect and answer in 50–80 ms (`home status` from a Cast-only config: 0.33–0.44 s, was 3.2 s with fixed 1.5 s windows; Bun 1.4.2, measured 2026-10-13). A configured entry discovery misses costs the second pass, 5 s more. `status` reads addresses from the environment, except Cast, which has none to read, and the Daikin, found by UDP broadcast when `DAIKIN_HOST` is unset (1.5 s); `discover` and `probe` do the other network searches. A full-screen TUI was considered (OpenTUI 0.6.2, 52 MB of native binaries) and skipped for now.