@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/docs/using.md CHANGED
@@ -13,12 +13,12 @@ Give every device a fixed address in your router (a DHCP reservation; eero, UniF
13
13
  ```ts
14
14
  import { NODE_TLS, nodePinnedFetch } from "@khalido/home/node";
15
15
  loadHome(config, { env: process.env, readFile, tls: NODE_TLS }); // from a home config
16
- hue({ host, bridgeId, key, ca, fetch: nodePinnedFetch(bridgeId, ca) }); // or one integration
16
+ hue({ host, bridgeId, key, fetch: nodePinnedFetch(bridgeId) }); // or one integration
17
17
  ```
18
18
 
19
19
  Checked on Node 24.20 and 26.11 against a local TLS server (2026-10-28): the right CA and CN pass, another certificate or CN is rejected. Not yet against a real bridge.
20
20
 
21
- Pairing, pinning, the event stream and the rate limits are in [`src/connectors/hue/README.md`](../src/connectors/hue/README.md). On the local network the bridge still needs an application key: without one it answers 403. `home pair` gets one with the link button; no cloud account is involved.
21
+ Pairing from code is `pairWhenPressed` ([hue.md](hue.md#pairing) has the whole flow); pinning, the event stream and the rate limits are in [`src/connectors/hue/README.md`](../src/connectors/hue/README.md). On the local network the bridge still needs an application key: without one it answers 403. `home pair` gets one with the link button; no cloud account is involved.
22
22
 
23
23
  ## One home
24
24
 
@@ -37,6 +37,8 @@ health(home.snapshot(), Date.now()); // per integration: ok, stale or down, with
37
37
 
38
38
  `createHome` stamps each part with `lastGoodAt` (its last good read) and `downSince` (when it stopped being connected). `health` judges from those: a polling connector is stale after 2½ polls with no good read and down after 3 polls not connected; Hue and Cast are down after 2 minutes not connected. Health takes each connector's interval from its manifest; a home that changes one (the weather's `pollMinutes`) passes it: `health(snapshot, now, { pollSeconds: { weather: 60 * 60 } })`. For a one-shot read (a script, a printed brief), `settle(integrations, { ms })` runs them until each has answered and returns the snapshot.
39
39
 
40
+ From a config file, with everything a Bun app on the home's network passes (Cast, discovery, an address cache), the call is at the top of [config.md](config.md).
41
+
40
42
  ## Every device, the same way
41
43
 
42
44
  `homeDevices(home.snapshot(), config)` lists every device the home has: each Hue light, room and zone, the Daikin, the Enphase gateway, each Cast device and group, each printer and the Soloist speaker. Each answers the same questions in the same words: `connector`, `id`, `name`, `kind` (light, group, speaker, display, tv, ac, printer, solar; `cast` for a Cast model the library doesn't know), `available` (and when it is false, `reason`: `offline`, `auth`, `config` or `crashed`), `asOf` (when the device last reported or was last read; a quiet light can be hours old and current), `state` (one word: on, off, idle, busy, cool, printing), `text` (one line for people), `room` and, for a group, `members`. A field the device hasn't reported is absent. The config is optional: with it a device takes its entry's `room`, and a device with no name of its own (the Daikin, the gateway) its entry's `name`; a device's own name is never replaced. While a connector isn't connected none of its devices is `available`, and each keeps its last values.
@@ -54,9 +56,102 @@ for (const d of homeDevices(home.snapshot(), config)) {
54
56
 
55
57
  Below that is the connector's snapshot, exactly as the device sent it (`home.part("hue")?.state`). One connector alone: `castHomeDevices(home.part("cast"))`, and likewise `hueHomeDevices`, `daikinHomeDevices`, `enphaseHomeDevices`, `printersHomeDevices`, `spotifyHomeDevices`. The list is for reading; a write goes to the device's connector, with `d.id` where it takes one.
56
58
 
59
+ For a map of the home, `byRoom(home.snapshot(), config)` gives `{ rooms, unplaced }`: each room of the config in order (`id`, `name`, `floor`, `devices`), and `noData`, the connectors the config places in that room that have nothing to list yet, so an unread room isn't drawn as an empty one. A connector that is down but has read before still lists its devices, `available: false`.
60
+
57
61
  ## Writes
58
62
 
59
- Writes hang off the integration: `home.integration("daikin")?.client.write(setMode("cool"))`, `home.integration("hue")?.client.recallScene(id)`, `home.integration("cast")?.setVolume(…)`, `home.integration("spotify")?.play(link)`. Each goes through the device's write lane: spaced, and a burst to one light or the AC merges so the last value lands. Spotify's writes resolve with `{ state, confirmed }`, the state once Soloist reported the change, and `duck(fn, { to })` and `interrupt(fn)` turn the music down or pause it around an announcement, returning what happened to it (`music`: `untouched`, `restored`, `kept` or `failed`). `speakerState(part)` says the speaker in one word (offline, waiting, idle, playing, paused) and `spotifyChanges(change.before, change.after)` turns an `onChange` into events (started, resumed, paused, stopped, track, stalled, recovered, offline, waiting, picked), those from the library's own `duck` or `interrupt` marked `own`.
63
+ A write changes someone's house: make one only when a person asked for it. Writes hang off the integration, `home.integration(name)`, and each goes through the device's write lane: spaced, and a burst to one light or the AC merges so the last value lands.
64
+
65
+ | Connector | Call | Resolves with |
66
+ | --------- | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ |
67
+ | daikin | `.client.power(on, opts?)`, `.client.mode(mode, targetC?, opts?)` | `{ state, confirmed }` |
68
+ | spotify | `.play(link?)`, `.pause()`, `.next()`, `.setVolume(0–100)`, `.activate()` and the rest | `{ state, confirmed }` |
69
+ | spotify | `.duck(fn, { to })`, `.interrupt(fn)` | `{ value, music, why? }`: `fn`'s result, and `music` `untouched`, `restored`, `kept` or `failed` |
70
+ | cast | `.announce(target, clip, { volume? })` | `{ played, why?, restored, notRestored }` |
71
+ | cast | `.announceAll(targets, clip, { volume? })` | one of those per target, each with its `target` |
72
+ | cast | `.setVolume(target, 0–1)`, `.setMuted(target, on)`, `.stop(target)` | nothing |
73
+ | hue | `.client.light(name, change, opts?)`, `.room(name, change, opts?)`, `.scene(name, room?, opts?)`, `.allOff(opts?)` | `{ state, confirmed }`, `state` the light, the room, or every light |
74
+ | hue | `.client.put(kind, id, body)`, `.client.recallScene(id)` | the bridge's answer, for what the typed writes don't cover |
75
+
76
+ - **`confirmed`** is true once the device reported what was asked. False is a result, not an error: the write was sent and the device hasn't shown it yet, and `state` is how it stood.
77
+ - **Dry run.** `opts` is `{ dryRun: true }` on Hue's and the Daikin's writes: every check made before sending runs (the name, the range, a lost light; a colour off the gamut shows moved in the body) and nothing is sent. The device can still refuse a real write that a dry run passed. The result has `wouldSend`, the request it would make (Hue: `{ method, path, body }`; Daikin adds `sets`, `{ on?, mode?, targetC? }`), `state` as things stand now, and `confirmed: false`, so check `wouldSend` first. A wrong name still throws with the names that exist. For testing a bot end to end with people home; `home … --dry-run` does the same from the terminal.
78
+ - **Use the returned state.** The Daikin's part in the snapshot is from its last poll and can be 120 s behind, unless you call `home.refresh("daikin")`.
79
+ - **Daikin defaults.** `mode("cool")` is 25 °C and `mode("heat")` 27 (`DEFAULT_TARGET_C`); `mode("cool", 23)` for another. `power(true)` brings the unit back as it was last left.
80
+ - **A target** for Cast is a device's or group's name or id. A clip is `{ url, type? }` or `{ bytes, type }`; bytes need `castServe`.
81
+ - **Clips: MP3 or AAC.** A 24 kHz mono WAV played on the displays and failed on the audio-only speakers (2026-11-09), so convert a WAV from a text-to-speech engine first.
82
+ - **Adding one while it runs.** `await home.add(integration)` starts it on the running home; the rest keep their sockets. One already there under that name is stopped first, so this is also how an `unavailable` part comes back once its key is fixed. `await home.remove(name)` stops one and drops its part. After pairing Hue from a chat, add the bridge's entry to the config (`loadHome` refuses an entry whose key is unset, so it can't be there before), build it as the config would, so it gets the pinned fetch and discovery, and add that one:
83
+
84
+ ```ts
85
+ const { key } = await pairWhenPressed({
86
+ host,
87
+ fetch: bunPinnedFetch(bridgeId),
88
+ deviceType: "chota#pi",
89
+ });
90
+ const built = loadHome(config, {
91
+ ...options,
92
+ env: { ...process.env, HUE_APPLICATION_KEY: key },
93
+ });
94
+ await home.add(built.find((i) => i.name === "hue")!);
95
+ ```
96
+
97
+ A replaced part starts over from `connecting`, with no state and no `lastGoodAt`, and no `onChange` until its first report. The second `loadHome` keeps its own copy of the address cache, so pass the same `cache` and expect the last writer to win.
98
+
99
+ - **Hue by name.** `room("Bedroom", { brightness: 40, kelvin: 2700 })`, `light("Desk lamp", { color: "#ff8800" })`, `scene("Relax", "Bedroom")`, and `allOff()` for every light in the home. A change is `{ on?, brightness?, color?, kelvin?, transitionMs? }`; an unknown name throws with the names that exist.
100
+ - **Volume scales differ:** Cast 0–1, Spotify 0–100.
101
+
102
+ Announce only where nothing is playing, with the music on this machine turned down meanwhile:
103
+
104
+ ```ts
105
+ import { castFreeSpeakers } from "@khalido/home/cast";
106
+
107
+ const cast = home.integration("cast");
108
+ const state = home.part("cast")?.state;
109
+ if (cast && state) {
110
+ // Every free speaker and display, a whole group where all of it is free, never a TV.
111
+ const { free, skipped } = castFreeSpeakers(state); // skipped: { name, why }
112
+ const announce = () =>
113
+ cast.announceAll(free, { url: clipUrl }, { volume: 0.3 });
114
+ const speaker = home.integration("spotify");
115
+ const results = speaker
116
+ ? (await speaker.duck(announce, { to: 20 })).value
117
+ : await announce();
118
+ for (const r of results) if (!r.played) log(`${r.target}: ${r.why}`);
119
+ }
120
+ ```
121
+
122
+ `castStatus(state)` gives each device's and group's `idle`, `restorable`, `busy` or `offline`, with the app and title. For Spotify, `speakerState(part)` says the speaker in one word (offline, waiting, idle, playing, paused, or elsewhere when the account's music is on another Spotify device; `spotifyElsewhere(home.snapshot())` names the Cast device when it is one) and `spotifyChanges(change.before, change.after)` turns an `onChange` into events (started, resumed, paused, stopped, track, stalled, recovered, offline, waiting, picked), those from the library's own `duck` or `interrupt` marked `own`.
123
+
124
+ ## When something is wrong
125
+
126
+ Two places say so, for two different things.
127
+
128
+ **A part's status** is about the connection, and reading it never throws:
129
+
130
+ | `status` | Means | Do |
131
+ | -------------- | ----------------------------------------- | ----------------------------------------------------------- |
132
+ | `connecting` | not answered yet | wait |
133
+ | `connected` | reading | |
134
+ | `disconnected` | unreachable now; the last `state` is kept | nothing: it keeps trying, and looks for a device that moved |
135
+ | `unavailable` | stopped for good | read `reason`, fix it, `home.add` it again |
136
+
137
+ `reason` is set on `unavailable`, when known: `auth` (a key or token was rejected: a person must re-key), `config` (a setting or file is wrong) or `crashed` (its `run` threw; a bug). `detail` is the sentence for a person, `anomaly` an odd reading passed through. An `unavailable` part does not recover by itself: after the fix, `home.add` a fresh one under the same name.
138
+
139
+ **A write that can't be made throws** a `ConnectorError` (`HueError`, `DaikinError` and so on are subclasses), with the HTTP `status` when there was one and, where code can act on it, a `code`:
140
+
141
+ | `code` | From | Means |
142
+ | ------------- | ------------ | ----------------------------------------------------------------------------- |
143
+ | `link_button` | hue | pairing: the bridge's button has not been pressed |
144
+ | `offline` | spotify, hue | not connected; for Hue, a light the bridge has lost |
145
+ | `waiting` | spotify | signed out: someone must pick the speaker in the Spotify app |
146
+ | `inactive` | spotify | it is not the active device; `activate()` first, if that is wanted |
147
+ | `refused` | spotify | Soloist answered no |
148
+ | `timeout` | spotify | no answer in time |
149
+ | `invalid` | spotify, hue | a value it can't take, or for Hue a name that doesn't exist; nothing was sent |
150
+ | `ipp` | printers | the printer answered an IPP error, its number in `status` |
151
+
152
+ Other failures carry no `code`: read the message (a Daikin setpoint off its range, a Cast target that doesn't exist, a device that didn't answer). Cast's `announce` throws only before anything changed; after that a failure is `played: false` with `why`.
153
+
154
+ `ConnectorCode` is the union, so a `switch` over it fails to compile when one is added.
60
155
 
61
156
  ## Text for people and agents
62
157
 
@@ -22,6 +22,8 @@
22
22
  "name": "Living",
23
23
  "floor": "ground",
24
24
  "hueRoom": "11111111-1111-4111-8111-111111111111",
25
+ // Photos of the room, for whoever draws or shows it; the library only carries the paths.
26
+ "photos": ["photos/living-1.jpg", "photos/living-2.jpg"],
25
27
  },
26
28
  {
27
29
  "id": "room-kitchen",
@@ -49,7 +51,6 @@
49
51
  "host": "192.0.2.2",
50
52
  "bridgeId": "0017880000000000",
51
53
  "key": "HUE_APPLICATION_KEY",
52
- "ca": "hue-root-ca.pem",
53
54
  },
54
55
  {
55
56
  "id": "ac",
package/home.schema.json CHANGED
@@ -82,6 +82,14 @@
82
82
  "hueRoom": {
83
83
  "description": "the Hue room, zone or grouped_light id whose lights this room's marker shows",
84
84
  "type": "string"
85
+ },
86
+ "photos": {
87
+ "description": "photos of the room, as paths relative to the config or from ~/; the library never reads them, a drawing agent or an app does",
88
+ "type": "array",
89
+ "items": {
90
+ "type": "string",
91
+ "minLength": 1
92
+ }
85
93
  }
86
94
  },
87
95
  "required": [
@@ -116,9 +124,9 @@
116
124
  "description": "env name holding the application key from `home pair`"
117
125
  },
118
126
  "ca": {
127
+ "description": "a root CA (PEM) to trust instead of Signify's two, which the package carries; relative to the config file",
119
128
  "type": "string",
120
- "minLength": 1,
121
- "description": "Signify's root CA (PEM), relative to the config file"
129
+ "minLength": 1
122
130
  },
123
131
  "id": {
124
132
  "type": "string"
@@ -137,7 +145,6 @@
137
145
  "host",
138
146
  "bridgeId",
139
147
  "key",
140
- "ca",
141
148
  "id"
142
149
  ],
143
150
  "additionalProperties": {}
package/llms.txt CHANGED
@@ -2,22 +2,35 @@
2
2
 
3
3
  > Small, typed home automation for TypeScript, published on npm as `@khalido/home`, with a `home` command. It starts with one home's devices: Philips Hue, a Daikin AC, Enphase solar, Google Cast, AirPrint printers and a Spotify Soloist speaker on the local network; Amber Electric prices, Up Bank spending and the weather from the cloud. Runs on Bun 1.4+ and Node 24+; the CLI needs Bun. MIT.
4
4
 
5
- The library never reads the environment or a file: callers pass addresses, keys and a `fetch`. `createHome` runs integrations and keeps one typed snapshot; `loadHome` builds them from a home config that names env vars, never secrets. Every field a device reports is optional, and odd readings pass through in `anomaly` rather than being corrected. Writes go through a per-device lane: spaced, bursts merged.
5
+ The library never reads the environment or a file: callers pass addresses, keys and a `fetch`. `createHome` runs integrations and keeps one typed snapshot; `loadHome` builds them from a home config that names env vars, never secrets. Every field a device reports is optional, and odd readings pass through in `anomaly` rather than being corrected. Writes go through a per-device lane: spaced, bursts merged. Write to a device only when a person asked for that change.
6
+
7
+ ## What you call
8
+
9
+ - **One home:** `loadHome(config, options)` builds the integrations and `createHome(integrations, onChange)` runs them: `home.start(signal)`, `home.snapshot()`, `home.part(name)`, `home.refresh(name)`, `home.integration(name)` for the writes, and `home.add(integration)` / `home.remove(name)` to change the set while it runs. `health(home.snapshot(), Date.now())` judges each part. All from `@khalido/home`.
10
+ - **Every device, one shape:** `homeDevices(snapshot, config?)` gives a `DeviceRecord` per device (`connector`, `id`, `name`, `kind`, `available`, `reason`, `state`, `text`, `room`), with the connector's own typed row in `detail`. `byRoom(snapshot, config)` groups them by the config's rooms, for a map.
11
+ - **Why a part is down:** `part.status` (`connected`, `disconnected`, `unavailable`) and, when `unavailable`, `part.reason` (`auth`, `config`, `crashed`). Errors are `ConnectorError` with a typed `code`.
12
+ - **Hue** (`@khalido/home/hue`): `pairWhenPressed` waits for the bridge's button and returns `{ key }`; Signify's root certificates are in the package, so `hue({ host, bridgeId, key })` needs no file. Writes by name: `client.light(name, change)`, `client.room(name, change)`, `client.scene(name, room?)`, `client.allOff()`, with `change` `{ on?, brightness?, color?, kelvin?, transitionMs? }`, each resolving `{ state, confirmed }`. `{ dryRun: true }` as the last argument checks and returns `wouldSend` without sending.
13
+ - **Daikin** (`@khalido/home/daikin`): `client.power(on)` and `client.mode(mode, targetC?)` resolve `{ state, confirmed }`, and take `{ dryRun: true }` last; cool defaults to 25 °C and heat to 27.
14
+ - **Cast** (`@khalido/home/cast`): `castStatus(state)` and `castFree(state, targets, { forms? })`, on the Cast part's state, say what is playing and where an announcement would disturb nothing; `castFreeSpeakers(state)` is the one call for every free speaker and display; `announce`, `announceAll`, `setVolume`, `setMuted`, `stop`.
15
+ - **Spotify Soloist** (`@khalido/home/spotify`): `play`, `pause`, `setVolume` and the rest resolve `{ state, confirmed }`; `duck` and `interrupt` wrap an announcement. `speakerState(part)` is `elsewhere` when the music is on another Spotify device.
16
+ - **Discovery** (`@khalido/home/finders`, Node-side): `FINDERS`, one finder per connector, and `fileCache`. **Node** needs the fetches in `@khalido/home/node`.
17
+ - **The command:** `home`, `home devices`, `home <connector>`, and writes such as `home daikin cool`, `home room <name> on`, `home cast announce`.
6
18
 
7
19
  ## Start here
8
20
 
9
21
  - [README](https://raw.githubusercontent.com/khalido/home.ts/main/README.md): what it is, install, the connectors
10
- - [Using it from an app](https://raw.githubusercontent.com/khalido/home.ts/main/docs/using.md): createHome, loadHome, health, change events, writes, energy
22
+ - [Using it from an app](https://raw.githubusercontent.com/khalido/home.ts/main/docs/using.md): the whole wiring on Bun, every device in one list, each write and what it returns, errors, health, energy
11
23
  - [The home config](https://raw.githubusercontent.com/khalido/home.ts/main/docs/config.md): devices by identity, rooms, discovery
12
- - [The home command](https://raw.githubusercontent.com/khalido/home.ts/main/docs/cli.md): status, probe, discover, per-connector verbs
24
+ - [The home command](https://raw.githubusercontent.com/khalido/home.ts/main/docs/cli.md): status, devices, probe, discover, per-connector verbs
13
25
  - [Agent skill: use-home](https://raw.githubusercontent.com/khalido/home.ts/main/skills/use-home/SKILL.md): reading safely, connecting, the rules for writes
14
26
 
15
27
  ## Connectors
16
28
 
17
- - [hue](https://raw.githubusercontent.com/khalido/home.ts/main/src/connectors/hue/README.md): lights, rooms, scenes over the bridge's event stream
18
- - [daikin](https://raw.githubusercontent.com/khalido/home.ts/main/src/connectors/daikin/README.md): the AC over its local API
29
+ - [hue](https://raw.githubusercontent.com/khalido/home.ts/main/src/connectors/hue/README.md): lights, rooms, scenes over the bridge's event stream; pairing
30
+ - [hue: Signify's guidance](https://raw.githubusercontent.com/khalido/home.ts/main/docs/hue.md): certificates, discovery, write rates, rooms, colour, pairing, and what this library does about each
31
+ - [daikin](https://raw.githubusercontent.com/khalido/home.ts/main/src/connectors/daikin/README.md): the AC over its local API: power and mode writes that report what the unit then said
19
32
  - [enphase](https://raw.githubusercontent.com/khalido/home.ts/main/src/connectors/enphase/README.md): solar from the IQ Gateway
20
- - [cast](https://raw.githubusercontent.com/khalido/home.ts/main/src/connectors/cast/README.md): Cast devices and groups, volume, announcements
33
+ - [cast](https://raw.githubusercontent.com/khalido/home.ts/main/src/connectors/cast/README.md): Cast devices and groups, what each is playing, volume, announcements
21
34
  - [printers](https://raw.githubusercontent.com/khalido/home.ts/main/src/connectors/printers/README.md): AirPrint printers over IPP
22
35
  - [spotify](https://raw.githubusercontent.com/khalido/home.ts/main/src/connectors/spotify/README.md): a Spotify Soloist speaker: state, confirmed writes, duck and interrupt, change events, stall watch
23
36
  - [amber](https://raw.githubusercontent.com/khalido/home.ts/main/src/connectors/amber/README.md): live electricity prices and forecast
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@khalido/home",
3
- "version": "0.1.3",
3
+ "version": "0.2.1",
4
4
  "description": "home.ts: small, typed home automation for TypeScript: Hue, Daikin, Enphase, Google Cast, AirPrint printers and a Spotify Soloist speaker on the local network; Amber prices, Up Bank spending and the weather from the cloud.",
5
5
  "license": "MIT",
6
6
  "author": "Khalid Omar",
@@ -1,5 +1,7 @@
1
1
  # draw-home: references
2
2
 
3
+ Where the skill is going, from a floor plan PDF to a live view: [khalido/home.ts#3](https://github.com/khalido/home.ts/issues/3).
4
+
3
5
  Good drawing references to read before drawing a home, newest idea first. All are skills or kits for coding agents; read for craft, keep this skill's contract (`SKILL.md` section 3) where they disagree.
4
6
 
5
7
  | Reference | What it is | Use it for | Licence |
@@ -67,7 +67,7 @@ Make a **room table** first, in the SVG's header comment: each room, its floor,
67
67
  }
68
68
  ```
69
69
 
70
- Room ids start with `room-`; names are what the person calls them (an unlabelled room keeps `""` until they say). No `room` on whole-house devices (solar, prices). The solar array is drawn on the roof with its own marker; set the Enphase device's `marker` to its id, and `capacityW` only when the person gives the rated size. Lights are placed through the room's `hueRoom`, not as devices. `marker` in the config always means an SVG marker id.
70
+ Room ids start with `room-`; names are what the person calls them (an unlabelled room keeps `""` until they say). No `room` on whole-house devices (solar, prices). The solar array is drawn on the roof with its own marker; set the Enphase device's `marker` to its id, and `capacityW` only when the person gives the rated size. Lights are placed through the room's `hueRoom`, not as devices. A room's `photos` lists the pictures it was drawn from, as paths relative to the config, when the person wants them kept. `marker` in the config always means an SVG marker id.
71
71
 
72
72
  ## 5. Check before hand-over
73
73
 
@@ -9,8 +9,8 @@ description: Use the `home` command and the @khalido/home library to see and run
9
9
 
10
10
  ## Rules
11
11
 
12
- - **Reads are free.** `home`, `home <connector>`, `home discover`, `home probe`, `home up spend` and every `--json` read touch nothing.
13
- - **Writes only on request.** Lights, AC, speakers, Up categories and tags, webhooks: run one only when the person asked for that change, then read back and report the state it left. Never write to "test" something.
12
+ - **Reads are free.** `home`, `home <connector>` with no verb, `home devices`, `home discover`, `home probe`, `home up spend` and every `--json` read touch nothing.
13
+ - **Writes only on request.** Lights, AC, speakers, printing, Up categories and tags, webhooks: run one only when the person asked for that change, then read back and report the state it left. Never write to "test" something: `--dry-run` on a Hue or Daikin write (`home room …`, `home scene …`, `home lights off`, `home daikin …`, `home printers print …`) checks it and prints what it would send, sending nothing (a print's dry run also asks the printer to validate the job, which prints nothing); other commands refuse the flag.
14
14
  - **Never print a key, token or password**, not even part of one. Say "set" or "unset". Env names are fine; values are not.
15
15
  - **Nothing private into a public repo**: no IPs, MACs, serials, device or room names, Up transactions or balances. A real `home.jsonc`, `home.svg` and floor plan live in a private folder.
16
16
  - **Show values as read.** A missing field is unknown, not zero. An `anomaly` is something odd the device said: report it, never smooth it.
@@ -43,7 +43,7 @@ Weather: `blocks` holds morning, afternoon and evening for today and tomorrow in
43
43
 
44
44
  **Keys** come from environment variables by name (`.env.example` lists them; Bun loads `.env` from the working directory). A real environment variable beats `.env`. Some keys come from a command, not a website:
45
45
 
46
- - Hue: press the bridge's button, then `home pair` within 30 s; it prints the env lines. Set `HUE_CA_FILE` to Signify's root CA (`src/connectors/hue/README.md`).
46
+ - Hue: `home pair`, then press the bridge's button within the 60 s it counts down; it prints the env lines. Signify's root certificates are in the package.
47
47
  - Enphase: `home enphase login` once a year writes the owner token to `~/.config/home/enphase-token.json` (0600).
48
48
  - Amber, Up, Google Weather: a token from each service's website.
49
49
  - Spotify (Soloist): no key in `home`; Soloist's own API key is the daemon's. Set `SOLOIST_WS` to the address its `--ws` opens. Signed out is normal until someone picks the speaker in the Spotify app.
package/src/cli/cast.ts CHANGED
@@ -7,9 +7,12 @@ import { tmpdir } from "node:os";
7
7
  import { extname, join } from "node:path";
8
8
  import {
9
9
  cast,
10
+ type CastDevice,
10
11
  type CastSnapshot,
12
+ castText,
11
13
  resolvedFrom,
12
14
  } from "../connectors/cast/index.ts";
15
+ import { findCast } from "../connectors/cast/state.ts";
13
16
  import { serveClip } from "../connectors/cast/serve.ts";
14
17
  import { tlsConnect } from "../connectors/cast/tls.ts";
15
18
  import { addressBook } from "../discover.ts";
@@ -52,7 +55,10 @@ export function castNow() {
52
55
 
53
56
  /** Open every Cast socket, wait until each has answered (at most 10 s), run one write over them, then close them all. */
54
57
  async function withCast<T>(
55
- act: (c: ReturnType<typeof castNow>) => Promise<T>,
58
+ act: (
59
+ c: ReturnType<typeof castNow>,
60
+ state: () => CastSnapshot | undefined,
61
+ ) => Promise<T>,
56
62
  ): Promise<T> {
57
63
  const c = castNow();
58
64
  const stop = new AbortController();
@@ -64,7 +70,7 @@ async function withCast<T>(
64
70
  Date.now() < until && (last?.status ?? "connecting") === "connecting";
65
71
  )
66
72
  await Bun.sleep(50);
67
- return await act(c);
73
+ return await act(c, () => last?.state);
68
74
  } finally {
69
75
  stop.abort();
70
76
  await running;
@@ -105,19 +111,59 @@ export const CAST_OPTIONS: [string, string, { type: unknown[] }?][] = [
105
111
  ["--volume <0-100>", "announce: the volume for the clip"],
106
112
  ];
107
113
 
114
+ /** How long a write waits for the device to report the change before saying it was only sent. */
115
+ const SHOWN_MS = 2000;
116
+
117
+ /** One write, then the device's line once it reports what was asked, or that it has not yet. */
118
+ const wrote = (
119
+ name: string,
120
+ write: (c: ReturnType<typeof castNow>) => Promise<void>,
121
+ shown: (d: CastDevice) => boolean,
122
+ ) =>
123
+ withCast(async (c, state) => {
124
+ await write(c);
125
+ const now = () => {
126
+ const s = state();
127
+ const d = s && findCast(s.devices, name);
128
+ return s && d ? { line: castText(d, s), ok: shown(d) } : undefined;
129
+ };
130
+ for (const until = Date.now() + SHOWN_MS; Date.now() < until;) {
131
+ if (now()?.ok) break;
132
+ await Bun.sleep(50);
133
+ }
134
+ const after = now();
135
+ console.log(
136
+ after?.ok
137
+ ? after.line
138
+ : `sent; the device has not reported it yet${after ? ` (${after.line})` : ""}`,
139
+ );
140
+ });
141
+
108
142
  export const castVerbs: Verbs = {
109
143
  async volume([name, value]) {
110
144
  if (!name) usage(CAST_USAGE);
111
145
  const level = percentLevel(value);
112
- await withCast((c) => c.setVolume(name!, level));
146
+ await wrote(
147
+ name!,
148
+ (c) => c.setVolume(name!, level),
149
+ (d) => d.volume !== undefined && Math.abs(d.volume - level) < 0.006,
150
+ );
113
151
  },
114
152
  async mute([name, value]) {
115
153
  if (!name || (value !== "on" && value !== "off")) usage(CAST_USAGE);
116
- await withCast((c) => c.setMuted(name!, value === "on"));
154
+ await wrote(
155
+ name!,
156
+ (c) => c.setMuted(name!, value === "on"),
157
+ (d) => d.muted === (value === "on"),
158
+ );
117
159
  },
118
160
  async stop([name]) {
119
161
  if (!name) usage(CAST_USAGE);
120
- await withCast((c) => c.stop(name!));
162
+ await wrote(
163
+ name!,
164
+ (c) => c.stop(name!),
165
+ (d) => d.idle === true,
166
+ );
121
167
  },
122
168
  async announce(_, o) {
123
169
  const [say, file] = [text(o.say), text(o.file)];
@@ -38,6 +38,8 @@ export const usage = (message: string): never => fail(message, "usage");
38
38
  /** The options cac parses: the global ones, and each command's own. */
39
39
  export type Options = {
40
40
  json?: boolean;
41
+ /** `--dry-run`: Hue and Daikin writes check and say what they would send, and send nothing. */
42
+ dryRun?: boolean;
41
43
  config?: string;
42
44
  write?: string | true;
43
45
  passes?: number | string;
@@ -0,0 +1,71 @@
1
+ // `home daikin on|off` and `home daikin cool|heat|auto|dry|fan [°C]`: one write to the AC, then what it reports.
2
+ // Live on KO's unit (2026-11-11): on, and dry to cool at 25. Off, heat, auto, fan and a fan speed are untried.
3
+ import {
4
+ acText,
5
+ type DaikinApplied,
6
+ daikinClient,
7
+ DEFAULT_TARGET_C,
8
+ type Mode,
9
+ setTarget,
10
+ } from "../connectors/daikin/index.ts";
11
+ import { findDaikins } from "../finders.ts";
12
+ import { env, fail, type Options, usage, type Verbs } from "./context.ts";
13
+
14
+ export const DAIKIN_USAGE = `usage: home daikin on | off | cool [°C] | heat [°C] | auto [°C] | dry | fan (cool ${DEFAULT_TARGET_C.cool} and heat ${DEFAULT_TARGET_C.heat} when no °C is given)`;
15
+
16
+ let found: Promise<string | undefined> | undefined;
17
+ /** The unit's address: DAIKIN_HOST, else the first to answer the UDP broadcast (asked once a run). Undefined when none does. */
18
+ export const daikinHost = (): Promise<string | undefined> =>
19
+ (found ??= env.DAIKIN_HOST
20
+ ? Promise.resolve(env.DAIKIN_HOST)
21
+ : findDaikins().then((units) => units[0]?.ip));
22
+
23
+ async function unit() {
24
+ const host =
25
+ (await daikinHost()) ??
26
+ fail("no Daikin answered the UDP broadcast; set DAIKIN_HOST", "network");
27
+ return daikinClient({ host, fetch });
28
+ }
29
+
30
+ /** What a write left, in a line: the unit as it then reported, that it has not reported the change yet, or what a dry run would send. */
31
+ const said = ({ state, confirmed, wouldSend }: DaikinApplied) =>
32
+ console.log(
33
+ wouldSend
34
+ ? `dry run, nothing sent: would set ${JSON.stringify(wouldSend.sets)} (now ${acText(state)})`
35
+ : confirmed
36
+ ? acText(state)
37
+ : `sent; the unit has not reported it yet (${acText(state)})`,
38
+ );
39
+
40
+ const power =
41
+ (on: boolean) =>
42
+ async ([extra]: string[], o: Options) =>
43
+ extra
44
+ ? usage(DAIKIN_USAGE)
45
+ : said(await (await unit()).power(on, { dryRun: !!o.dryRun }));
46
+
47
+ const mode =
48
+ (m: Mode) =>
49
+ async ([value, extra]: string[], o: Options) => {
50
+ if (extra || (value !== undefined && !/^\d+(\.\d+)?$/.test(value)))
51
+ usage(DAIKIN_USAGE);
52
+ const c = value === undefined ? undefined : Number(value);
53
+ // A setpoint the mode can't take is a usage error, said before looking for the unit.
54
+ if (c !== undefined)
55
+ try {
56
+ setTarget(c, m);
57
+ } catch (e) {
58
+ usage(`${(e as Error).message}; ${DAIKIN_USAGE}`);
59
+ }
60
+ said(await (await unit()).mode(m, c, { dryRun: !!o.dryRun }));
61
+ };
62
+
63
+ export const daikinVerbs: Verbs = {
64
+ on: power(true),
65
+ off: power(false),
66
+ cool: mode("cool"),
67
+ heat: mode("heat"),
68
+ auto: mode("auto"),
69
+ dry: mode("dry"),
70
+ fan: mode("fan"),
71
+ };
@@ -7,8 +7,16 @@ import {
7
7
  tokenExpiry,
8
8
  } from "../connectors/enphase/index.ts";
9
9
  import { daysLeft } from "../connectors/enphase/client.ts";
10
- import { writePrivate } from "../finders.ts";
11
- import { env, fail, homeTimeZone, TOKEN_FILE, type Verbs } from "./context.ts";
10
+ import { addressBook } from "../discover.ts";
11
+ import { FINDERS, writePrivate } from "../finders.ts";
12
+ import {
13
+ addresses,
14
+ env,
15
+ fail,
16
+ homeTimeZone,
17
+ TOKEN_FILE,
18
+ type Verbs,
19
+ } from "./context.ts";
12
20
 
13
21
  /** The cached Enphase token, or undefined if `home enphase login` has not run. A corrupt file throws, naming it. */
14
22
  export function enphaseToken(): { token: string; expiresAt: Date } | undefined {
@@ -31,12 +39,30 @@ export function enphaseToken(): { token: string; expiresAt: Date } | undefined {
31
39
  }
32
40
  }
33
41
 
34
- /** Enphase as an integration from IQ_GATEWAY_HOST and the token file, for `home status`. */
35
- export function enphaseFromEnv() {
42
+ /**
43
+ * Enphase as an integration for `home status`: IQ_GATEWAY_HOST, else the address cache, else the first gateway mDNS finds.
44
+ * With the gateway's serial from the cache, a read that fails is followed by discovery at once, so an IQ_GATEWAY_HOST left
45
+ * behind by a DHCP move still reads, and its detail says where the gateway went.
46
+ */
47
+ export async function enphaseFromEnv() {
36
48
  const t =
37
49
  enphaseToken() ?? fail("no Enphase token", "auth", "home enphase login");
50
+ const book = addressBook(addresses.cache);
51
+ const serial = Object.keys(book.entries())
52
+ .find((k) => k.startsWith("enphase:"))
53
+ ?.slice("enphase:".length);
54
+ const cached = serial ? book.fresh("enphase", serial) : undefined;
55
+ const host =
56
+ env.IQ_GATEWAY_HOST ??
57
+ cached?.host ??
58
+ (await FINDERS.enphase())[0]?.host ??
59
+ fail("no gateway found by mDNS; set IQ_GATEWAY_HOST", "network");
38
60
  return enphase({
39
- host: env.IQ_GATEWAY_HOST!,
61
+ host,
62
+ serial,
63
+ find: FINDERS.enphase,
64
+ seen: (h) => book.seen("enphase", serial, { host: h }),
65
+ hostCached: true, // an env address is as likely stale as a cached one: look again after one failure
40
66
  token: t.token,
41
67
  fetch: bunGatewayFetch(),
42
68
  timeZone: homeTimeZone(),