@khalido/home 0.0.0-stage → 0.1.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 (217) hide show
  1. package/.env.example +25 -0
  2. package/CHANGELOG.md +25 -0
  3. package/LICENSE +21 -0
  4. package/README.md +77 -2
  5. package/dist/clock.d.ts +17 -0
  6. package/dist/clock.js +43 -0
  7. package/dist/config.d.ts +273 -0
  8. package/dist/config.js +392 -0
  9. package/dist/connector.d.ts +34 -0
  10. package/dist/connector.js +47 -0
  11. package/dist/connectors/amber/client.d.ts +61 -0
  12. package/dist/connectors/amber/client.js +106 -0
  13. package/dist/connectors/amber/index.d.ts +30 -0
  14. package/dist/connectors/amber/index.js +94 -0
  15. package/dist/connectors/cast/codec.d.ts +38 -0
  16. package/dist/connectors/cast/codec.js +102 -0
  17. package/dist/connectors/cast/index.d.ts +60 -0
  18. package/dist/connectors/cast/index.js +426 -0
  19. package/dist/connectors/cast/schemas.d.ts +167 -0
  20. package/dist/connectors/cast/schemas.js +87 -0
  21. package/dist/connectors/cast/serve.d.ts +11 -0
  22. package/dist/connectors/cast/serve.js +57 -0
  23. package/dist/connectors/cast/state.d.ts +62 -0
  24. package/dist/connectors/cast/state.js +133 -0
  25. package/dist/connectors/cast/tls.d.ts +3 -0
  26. package/dist/connectors/cast/tls.js +67 -0
  27. package/dist/connectors/cast/writes.d.ts +80 -0
  28. package/dist/connectors/cast/writes.js +390 -0
  29. package/dist/connectors/daikin/client.d.ts +65 -0
  30. package/dist/connectors/daikin/client.js +221 -0
  31. package/dist/connectors/daikin/index.d.ts +41 -0
  32. package/dist/connectors/daikin/index.js +108 -0
  33. package/dist/connectors/enphase/client.d.ts +65 -0
  34. package/dist/connectors/enphase/client.js +173 -0
  35. package/dist/connectors/enphase/index.d.ts +43 -0
  36. package/dist/connectors/enphase/index.js +174 -0
  37. package/dist/connectors/enphase/schemas.d.ts +51 -0
  38. package/dist/connectors/enphase/schemas.js +34 -0
  39. package/dist/connectors/hue/client.d.ts +48 -0
  40. package/dist/connectors/hue/client.js +95 -0
  41. package/dist/connectors/hue/events.d.ts +41 -0
  42. package/dist/connectors/hue/events.js +156 -0
  43. package/dist/connectors/hue/index.d.ts +42 -0
  44. package/dist/connectors/hue/index.js +205 -0
  45. package/dist/connectors/hue/schemas.d.ts +65 -0
  46. package/dist/connectors/hue/schemas.js +48 -0
  47. package/dist/connectors/hue/state.d.ts +43 -0
  48. package/dist/connectors/hue/state.js +84 -0
  49. package/dist/connectors/printers/client.d.ts +28 -0
  50. package/dist/connectors/printers/client.js +74 -0
  51. package/dist/connectors/printers/codec.d.ts +76 -0
  52. package/dist/connectors/printers/codec.js +246 -0
  53. package/dist/connectors/printers/found.d.ts +15 -0
  54. package/dist/connectors/printers/found.js +35 -0
  55. package/dist/connectors/printers/index.d.ts +52 -0
  56. package/dist/connectors/printers/index.js +282 -0
  57. package/dist/connectors/printers/state.d.ts +60 -0
  58. package/dist/connectors/printers/state.js +99 -0
  59. package/dist/connectors/spotify/changes.d.ts +32 -0
  60. package/dist/connectors/spotify/changes.js +84 -0
  61. package/dist/connectors/spotify/client.d.ts +199 -0
  62. package/dist/connectors/spotify/client.js +270 -0
  63. package/dist/connectors/spotify/index.d.ts +92 -0
  64. package/dist/connectors/spotify/index.js +745 -0
  65. package/dist/connectors/up/client.d.ts +183 -0
  66. package/dist/connectors/up/client.js +308 -0
  67. package/dist/connectors/up/index.d.ts +91 -0
  68. package/dist/connectors/up/index.js +213 -0
  69. package/dist/connectors/up/spend.d.ts +78 -0
  70. package/dist/connectors/up/spend.js +213 -0
  71. package/dist/connectors/up/webhook.d.ts +13 -0
  72. package/dist/connectors/up/webhook.js +32 -0
  73. package/dist/connectors/weather/blocks.d.ts +51 -0
  74. package/dist/connectors/weather/blocks.js +106 -0
  75. package/dist/connectors/weather/bom.d.ts +37 -0
  76. package/dist/connectors/weather/bom.js +237 -0
  77. package/dist/connectors/weather/ftp.d.ts +5 -0
  78. package/dist/connectors/weather/ftp.js +46 -0
  79. package/dist/connectors/weather/google.d.ts +31 -0
  80. package/dist/connectors/weather/google.js +364 -0
  81. package/dist/connectors/weather/index.d.ts +53 -0
  82. package/dist/connectors/weather/index.js +267 -0
  83. package/dist/connectors/weather/open-meteo.d.ts +15 -0
  84. package/dist/connectors/weather/open-meteo.js +285 -0
  85. package/dist/connectors/weather/rain.d.ts +19 -0
  86. package/dist/connectors/weather/rain.js +51 -0
  87. package/dist/connectors/weather/source.d.ts +149 -0
  88. package/dist/connectors/weather/source.js +77 -0
  89. package/dist/connectors/weather/sun.d.ts +48 -0
  90. package/dist/connectors/weather/sun.js +65 -0
  91. package/dist/discover.d.ts +195 -0
  92. package/dist/discover.js +446 -0
  93. package/dist/energy.d.ts +61 -0
  94. package/dist/energy.js +162 -0
  95. package/dist/finders-entry.d.ts +1 -0
  96. package/dist/finders-entry.js +2 -0
  97. package/dist/finders.d.ts +45 -0
  98. package/dist/finders.js +173 -0
  99. package/dist/health.d.ts +28 -0
  100. package/dist/health.js +35 -0
  101. package/dist/home.d.ts +81 -0
  102. package/dist/home.js +114 -0
  103. package/dist/index.d.ts +11 -0
  104. package/dist/index.js +11 -0
  105. package/dist/lane.d.ts +17 -0
  106. package/dist/lane.js +74 -0
  107. package/dist/locate.d.ts +27 -0
  108. package/dist/locate.js +59 -0
  109. package/dist/manifest.d.ts +31 -0
  110. package/dist/manifest.js +4 -0
  111. package/dist/manifests.d.ts +5 -0
  112. package/dist/manifests.js +23 -0
  113. package/dist/node-fetch.d.ts +9 -0
  114. package/dist/node-fetch.js +25 -0
  115. package/dist/poll.d.ts +10 -0
  116. package/dist/poll.js +33 -0
  117. package/docs/cast.md +133 -0
  118. package/docs/cli.md +39 -0
  119. package/docs/config.md +36 -0
  120. package/docs/decisions.md +150 -0
  121. package/docs/devices.md +128 -0
  122. package/docs/enphase-notes.md +24 -0
  123. package/docs/google.md +92 -0
  124. package/docs/ideas.md +69 -0
  125. package/docs/matter.md +55 -0
  126. package/docs/performance.md +12 -0
  127. package/docs/philosophy.md +23 -0
  128. package/docs/up.md +84 -0
  129. package/docs/using.md +65 -0
  130. package/docs/weather-probe.md +63 -0
  131. package/examples/home.jsonc +74 -0
  132. package/examples/home.svg +76 -0
  133. package/home.schema.json +474 -0
  134. package/llms.txt +32 -0
  135. package/package.json +143 -4
  136. package/skills/draw-home/README.md +26 -0
  137. package/skills/draw-home/SKILL.md +82 -0
  138. package/skills/use-home/SKILL.md +86 -0
  139. package/src/cli/cache.ts +29 -0
  140. package/src/cli/cast.ts +172 -0
  141. package/src/cli/config.ts +52 -0
  142. package/src/cli/context.ts +118 -0
  143. package/src/cli/discover.ts +175 -0
  144. package/src/cli/enphase.ts +62 -0
  145. package/src/cli/hue.ts +124 -0
  146. package/src/cli/locate.ts +47 -0
  147. package/src/cli/main.ts +182 -0
  148. package/src/cli/printers.ts +21 -0
  149. package/src/cli/probe.ts +274 -0
  150. package/src/cli/spotify.ts +76 -0
  151. package/src/cli/status.ts +228 -0
  152. package/src/cli/up.ts +350 -0
  153. package/src/clock.ts +62 -0
  154. package/src/config.ts +578 -0
  155. package/src/connector.ts +77 -0
  156. package/src/connectors/amber/README.md +40 -0
  157. package/src/connectors/amber/client.ts +157 -0
  158. package/src/connectors/amber/index.ts +118 -0
  159. package/src/connectors/cast/README.md +43 -0
  160. package/src/connectors/cast/codec.ts +126 -0
  161. package/src/connectors/cast/index.ts +551 -0
  162. package/src/connectors/cast/schemas.ts +101 -0
  163. package/src/connectors/cast/serve.ts +72 -0
  164. package/src/connectors/cast/state.ts +203 -0
  165. package/src/connectors/cast/tls.ts +100 -0
  166. package/src/connectors/cast/writes.ts +572 -0
  167. package/src/connectors/daikin/README.md +78 -0
  168. package/src/connectors/daikin/client.ts +295 -0
  169. package/src/connectors/daikin/index.ts +145 -0
  170. package/src/connectors/enphase/README.md +64 -0
  171. package/src/connectors/enphase/client.ts +244 -0
  172. package/src/connectors/enphase/index.ts +241 -0
  173. package/src/connectors/enphase/schemas.ts +45 -0
  174. package/src/connectors/hue/README.md +50 -0
  175. package/src/connectors/hue/client.ts +157 -0
  176. package/src/connectors/hue/events.ts +182 -0
  177. package/src/connectors/hue/index.ts +261 -0
  178. package/src/connectors/hue/schemas.ts +60 -0
  179. package/src/connectors/hue/state.ts +124 -0
  180. package/src/connectors/printers/README.md +46 -0
  181. package/src/connectors/printers/client.ts +114 -0
  182. package/src/connectors/printers/codec.ts +292 -0
  183. package/src/connectors/printers/found.ts +46 -0
  184. package/src/connectors/printers/index.ts +385 -0
  185. package/src/connectors/printers/state.ts +182 -0
  186. package/src/connectors/spotify/README.md +80 -0
  187. package/src/connectors/spotify/changes.ts +127 -0
  188. package/src/connectors/spotify/client.ts +387 -0
  189. package/src/connectors/spotify/index.ts +929 -0
  190. package/src/connectors/up/README.md +35 -0
  191. package/src/connectors/up/client.ts +518 -0
  192. package/src/connectors/up/index.ts +370 -0
  193. package/src/connectors/up/spend.ts +280 -0
  194. package/src/connectors/up/webhook.ts +62 -0
  195. package/src/connectors/weather/README.md +84 -0
  196. package/src/connectors/weather/blocks.ts +173 -0
  197. package/src/connectors/weather/bom.ts +344 -0
  198. package/src/connectors/weather/ftp.ts +48 -0
  199. package/src/connectors/weather/google.ts +425 -0
  200. package/src/connectors/weather/index.ts +380 -0
  201. package/src/connectors/weather/open-meteo.ts +309 -0
  202. package/src/connectors/weather/rain.ts +73 -0
  203. package/src/connectors/weather/source.ts +236 -0
  204. package/src/connectors/weather/sun.ts +114 -0
  205. package/src/discover.ts +634 -0
  206. package/src/energy.ts +221 -0
  207. package/src/finders-entry.ts +2 -0
  208. package/src/finders.ts +247 -0
  209. package/src/health.ts +74 -0
  210. package/src/home.ts +205 -0
  211. package/src/index.ts +62 -0
  212. package/src/lane.ts +110 -0
  213. package/src/locate.ts +85 -0
  214. package/src/manifest.ts +43 -0
  215. package/src/manifests.ts +27 -0
  216. package/src/node-fetch.ts +41 -0
  217. package/src/poll.ts +33 -0
package/.env.example ADDED
@@ -0,0 +1,25 @@
1
+ # Names the `home` CLI, the live tests and the bench read. Dummy values; copy to .env (gitignored)
2
+ # or export them in your shell. The library never reads these: callers pass everything in. Written by `bun run gen`
3
+ # from each connector's manifest (src/connectors/*/index.ts); edit those, not this file.
4
+ HUE_BRIDGE_HOST=hue-bridge.local # optional: found by mDNS (dns-sd) when unset
5
+ HUE_BRIDGE_ID=0017880000000000 # optional: the certificate's CN must match it; mDNS finds it when unset
6
+ HUE_APPLICATION_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx # from `home pair`
7
+ HUE_CA_FILE=/path/to/hue-root-ca.pem # until HUE_BRIDGE_ROOT_CA ships in src/connectors/hue/
8
+ DAIKIN_HOST=192.0.2.20 # the unit's address; `home probe` finds it by UDP broadcast
9
+ IQ_GATEWAY_HOST=envoy.local # the IQ Gateway on the LAN (a scheme is tolerated)
10
+ IQ_GATEWAY_SERIAL_NUMBER=000000000000 # optional: for `home enphase login`; from the Enphase app: System > Devices > Gateway
11
+ ENPHASE_USERNAME=you@example.com # optional: for `home enphase login`, once a year: the Enphase account
12
+ ENPHASE_PASSWORD=xxxxxxxx # optional: for `home enphase login`; never stored, only sent to Enphase's login service
13
+ AMBER_KEY=psk_xxxxxxxx # Amber Electric API key (app.amber.com.au/developers)
14
+ AMBER_SITE_ID= # optional: the first active site is used when unset
15
+ UP_TOKEN=up:yeah:xxxxxxxx # Up personal access token (api.up.com.au/getting_started)
16
+ UP_ACCOUNT=JOINT TRANSACTIONAL # optional: which accounts count, by ownership and type; every account when unset
17
+ UP_HOUSEHOLD=home tag:home # optional: what counts as the household's: Up category ids and tag:labels; everything when unset
18
+ HOME_LATITUDE=-33.8688 # the home's position for weather and the sun (this one is Sydney CBD)
19
+ HOME_LONGITUDE=151.2093
20
+ HOME_TIMEZONE=Australia/Sydney # optional: the home's IANA time zone for weather times and day blocks; this machine's when unset
21
+ HOME_BOM_FORECAST=Sydney # optional: BOM précis location by name or aac; unset, the forecast comes from Open-Meteo
22
+ GOOGLE_WEATHER_KEY= # optional: Maps Platform key with the Weather API enabled
23
+ SOLOIST_WS=ws://127.0.0.1:9091 # Soloist's --ws address as a URL; `home status` skips the speaker without it
24
+ SOLOIST_MAX_VOLUME=60 # optional: the loudest `home spotify volume` sets, and where a first play above it is lowered to
25
+ HOME_CONFIG= # optional: a home config (JSONC), e.g. ~/home/home.jsonc; else the names above
package/CHANGELOG.md ADDED
@@ -0,0 +1,25 @@
1
+ # Changelog
2
+
3
+ All notable changes to home.ts (`@khalido/home` on npm), in [Keep a Changelog](https://keepachangelog.com/) form. Versions follow [SemVer](https://semver.org) at 0.x: a minor release can break what a consumer calls, a patch can't.
4
+
5
+ ## [Unreleased]
6
+
7
+ ## [0.1.0] - 2026-11-08
8
+
9
+ First release. Nine connectors: 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. Each connector's README says what has run against a real device.
10
+
11
+ ### Added
12
+
13
+ - **One snapshot, typed by name.** `createHome` runs the integrations and keeps their parts; `home.part("weather")?.state` is a `WeatherState`. `loadHome` builds them from a home config.
14
+ - **Health.** Each part carries `lastGoodAt` and `downSince`; `health(snapshot, now)` says ok, stale or down per integration, with its thresholds. `onChange` gets what changed: `{ name, before, after }`.
15
+ - **Writes** hang off the integration (`home.integration("daikin")?.client.write(…)`) and go through a write lane: spaced, a burst merged so the last value lands, and they follow a device that moves.
16
+ - **Weather** by the hour and by part of the day. The parts of the day are yours to name (`blocks`), with UV per part.
17
+ - **Energy.** `energyNow` combines solar, use and price; `bestWindow` finds the cheapest or greenest hours in Amber's forecast.
18
+ - **Spotify.** `spotify()` reads a [Spotify Soloist](https://developer.spotify.com/documentation/soloist) speaker over its local WebSocket and plays, pauses, skips and sets the volume, held to a `maxVolume`. Each write resolves with the state once Soloist reports the change (`{ state, confirmed }`). `duck(fn, { to })` turns the music down around an announcement and `interrupt(fn)` pauses it; both put it back unless someone changed it from a phone meanwhile. `state.upNext` is what plays next, `setRepeat` sets repeat, `speakerState(part)` gives one word to switch on (offline, connecting, waiting, idle, playing, paused, buffering), and `spotifyChanges(before, after)` turns two reports into events (started, resumed, paused, stopped, track, offline, online, waiting, picked), marking the playback ones that come from the library's own `duck` or `interrupt`. It notices playback that stalls while Soloist says playing (Soloist issue #5), says so in `anomaly` and as `stalled` and `recovered` events, and with `repairStalls: true` tries once per track to restart it. The connector has only run against a fake socket; no real Soloist has answered it yet.
19
+ - **A home config** (`home.jsonc`, checked by `home.schema.json`): each device by the identity its own discovery reports, its last address, key env names (never keys) and its room. A device that moves is found again by discovery after failed reads, and `loadHome` keeps an address book.
20
+ - **The `home` command** (Bun): `home` for every connector's status, `home <connector>` for one, `discover`, a read-only `probe`, `config check`, `locate`, `cache`, `pair` for Hue, `enphase login`, and writes for Hue (`lights`, `room`, `scene`), Cast, Up and the Spotify speaker. `--json` on the reads.
21
+ - **Agent skills** in `skills/`: `use-home` (reading safely, connecting, the rules for writes) and `draw-home` (an SVG plan of a home and its rooms). `llms.txt` points agents at the docs.
22
+ - **Node.** Hue and Enphase take the fetches in `@khalido/home/node`. The library runs on Bun 1.4+ and Node 24+; the `home` command needs Bun.
23
+
24
+ [Unreleased]: https://github.com/khalido/home.ts/compare/v0.1.0...HEAD
25
+ [0.1.0]: https://github.com/khalido/home.ts/releases/tag/v0.1.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Khalid Omar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,78 @@
1
- # Temporary Holding Version
1
+ # home.ts
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Small, typed home automation for TypeScript, starting with the devices in my house: Philips Hue, a Daikin AC, Enphase solar, Google Cast speakers and screens, AirPrint printers and a Spotify Soloist speaker on the local network, plus Amber Electric's live prices, Up Bank spending and the weather. One small library, published as `@khalido/home`, and a `home` command. No server, no cloud of its own.
4
+
5
+ - Read every device into one typed snapshot, kept current by event stream or on a timer.
6
+ - Turn lights on, set the AC, change speaker volume. Writes are spaced and merged, so a burst never floods a device.
7
+ - Find what answers on your network, and check it read-only from the terminal.
8
+ - Combine solar, grid and price into one energy figure.
9
+
10
+ ## Install
11
+
12
+ ```sh
13
+ bun add @khalido/home # or: npm install @khalido/home
14
+ ```
15
+
16
+ The library runs on Bun 1.4+ and Node 24+. The `home` command needs Bun.
17
+
18
+ ## Try it
19
+
20
+ From a clone:
21
+
22
+ ```sh
23
+ bun install
24
+ bun test # runs on fixtures, no device needed
25
+ bun link # puts `home` on your PATH
26
+ home discover # what answers on this network, and which of it this package supports
27
+ home probe # read-only checks, no keys
28
+ home # each connector: connected, or what it still needs
29
+ ```
30
+
31
+ Copy [.env.example](.env.example) to `.env` and fill in the connectors you have, or describe the home in a [config file](docs/config.md) ([example](examples/home.jsonc)).
32
+
33
+ ## Use it from code
34
+
35
+ Weather needs no key or device, so this runs anywhere:
36
+
37
+ ```ts
38
+ import { createHome } from "@khalido/home";
39
+ import { weather } from "@khalido/home/weather";
40
+
41
+ const home = createHome(
42
+ [weather({ latitude: -33.87, longitude: 151.21 })],
43
+ (snapshot) => console.log(snapshot),
44
+ );
45
+ home.start(new AbortController().signal); // connects, reads, keeps reading, reconnects
46
+ home.part("weather")?.state; // typed by name, read from memory, never from the device
47
+ ```
48
+
49
+ The library never reads your environment or a file: you pass addresses, keys and a `fetch`. Under Node, Hue and Enphase take the fetches in `@khalido/home/node`. From a clone, run `bun run build` first: the package's entry points resolve to `dist/`. Health, change events, writes and the energy helpers are in [using it from an app](docs/using.md).
50
+
51
+ ## Connectors
52
+
53
+ | Connector | Reads | Writes |
54
+ | --------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
55
+ | [hue](src/connectors/hue/README.md) | lights, rooms, scenes, over the bridge's event stream | lights, rooms, scenes |
56
+ | [daikin](src/connectors/daikin/README.md) | the AC, over its local API | power, mode, setpoint, fan |
57
+ | [enphase](src/connectors/enphase/README.md) | solar production and consumption from the IQ Gateway | none |
58
+ | [cast](src/connectors/cast/README.md) | every Cast device and speaker group | volume, mute, stop, announcements |
59
+ | [printers](src/connectors/printers/README.md) | AirPrint printers over IPP: state, toner, paper | none |
60
+ | [spotify](src/connectors/spotify/README.md) | a Spotify Soloist speaker: what is playing, volume, shuffle, over its local WebSocket | play a link, pause, resume, skip, seek, volume (capped), queue, shuffle, repeat, each confirmed by what the speaker reports; turn down or pause around an announcement; a stalled stream noticed and, if asked, restarted |
61
+ | [amber](src/connectors/amber/README.md) | live import and feed-in prices | none |
62
+ | [up](src/connectors/up/README.md) | household spending by category, what still needs one | a category or tag on a transaction |
63
+ | [weather](src/connectors/weather/README.md) | now, forecast by the hour and by part of the day, rain soon, the sun, from BOM, Open-Meteo and Google | none |
64
+
65
+ Each connector's README says what has run against a real device and what has not. This covers only the devices I own; [Home Assistant](https://www.home-assistant.io/) is the inspiration and the reference for every protocol.
66
+
67
+ ## More
68
+
69
+ - [The `home` command](docs/cli.md) and [the home config](docs/config.md)
70
+ - [Why it is shaped this way](docs/philosophy.md) and [decisions](docs/decisions.md)
71
+ - To add a connector, follow [AGENTS.md](AGENTS.md)
72
+ - For agents: [llms.txt](llms.txt)
73
+
74
+ I use it from jaldi, a Mac launcher, and chota-bot, a family bot on a Raspberry Pi, both on Bun; the docs name both.
75
+
76
+ ## Licence
77
+
78
+ MIT. Each connector's README lists its sources and their licences. Two Daikin sources are GPL-3.0 and are read for facts only, never copied.
@@ -0,0 +1,17 @@
1
+ /** Time as a parameter, so queues and backoff run in tests without waiting. */
2
+ export type Clock = {
3
+ now(): number;
4
+ sleep(ms: number, signal?: AbortSignal): Promise<void>;
5
+ };
6
+ /** The wall clock. `sleep` resolves early when `signal` aborts. */
7
+ export declare const realClock: Clock;
8
+ /** The calendar day at `ms` in an IANA time zone (the runtime's when left out), as YYYY-MM-DD: where "today" starts. */
9
+ export declare const localDay: (ms: number, timeZone?: string) => string;
10
+ /** The hour of the day (0–23) at `ms` in an IANA time zone, the runtime's when left out. */
11
+ export declare const localHour: (ms: number, timeZone?: string) => number;
12
+ /** "20:15" at `ms` in an IANA time zone, the runtime's when left out. */
13
+ export declare const localTime: (ms: number, timeZone?: string) => string;
14
+ /** Whether a name is an IANA time zone this runtime knows. */
15
+ export declare function isTimeZone(name: string): boolean;
16
+ /** The clock's now as an ISO string: the `asOf` every snapshot carries. */
17
+ export declare const asOf: (clock: Pick<Clock, "now">) => string;
package/dist/clock.js ADDED
@@ -0,0 +1,43 @@
1
+ /** The wall clock. `sleep` resolves early when `signal` aborts. */
2
+ export const realClock = {
3
+ now: () => Date.now(),
4
+ sleep: (ms, signal) => new Promise((resolve) => {
5
+ const done = () => (clearTimeout(timer),
6
+ signal?.removeEventListener("abort", done),
7
+ resolve());
8
+ const timer = setTimeout(done, ms);
9
+ signal?.addEventListener("abort", done, { once: true });
10
+ }),
11
+ };
12
+ /** The calendar day at `ms` in an IANA time zone (the runtime's when left out), as YYYY-MM-DD: where "today" starts. */
13
+ export const localDay = (ms, timeZone) => new Intl.DateTimeFormat("en-CA", {
14
+ timeZone,
15
+ year: "numeric",
16
+ month: "2-digit",
17
+ day: "2-digit",
18
+ }).format(ms);
19
+ /** The hour of the day (0–23) at `ms` in an IANA time zone, the runtime's when left out. */
20
+ export const localHour = (ms, timeZone) => Number(new Intl.DateTimeFormat("en-GB", {
21
+ timeZone,
22
+ hour: "2-digit",
23
+ hourCycle: "h23",
24
+ }).format(ms));
25
+ /** "20:15" at `ms` in an IANA time zone, the runtime's when left out. */
26
+ export const localTime = (ms, timeZone) => new Intl.DateTimeFormat("en-GB", {
27
+ timeZone,
28
+ hour: "2-digit",
29
+ minute: "2-digit",
30
+ hourCycle: "h23",
31
+ }).format(ms);
32
+ /** Whether a name is an IANA time zone this runtime knows. */
33
+ export function isTimeZone(name) {
34
+ try {
35
+ new Intl.DateTimeFormat("en", { timeZone: name });
36
+ return true;
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ }
42
+ /** The clock's now as an ISO string: the `asOf` every snapshot carries. */
43
+ export const asOf = (clock) => new Date(clock.now()).toISOString();
@@ -0,0 +1,273 @@
1
+ import { z } from "zod";
2
+ import { type Clock } from "./clock.ts";
3
+ import { amber } from "./connectors/amber/index.ts";
4
+ import { daikin } from "./connectors/daikin/index.ts";
5
+ import { cast, type CastConnect, type ServeClip } from "./connectors/cast/index.ts";
6
+ import { enphase } from "./connectors/enphase/index.ts";
7
+ import { type AddressCache, type Connector, type Finder } from "./discover.ts";
8
+ import { hue } from "./connectors/hue/index.ts";
9
+ import { printers } from "./connectors/printers/index.ts";
10
+ import { spotify } from "./connectors/spotify/index.ts";
11
+ import { up } from "./connectors/up/index.ts";
12
+ import { weather } from "./connectors/weather/index.ts";
13
+ /** Where a device or marker sits: `id` matches `<g class="room" id="…">` in the plan's SVG. */
14
+ export declare const Room: z.ZodObject<{
15
+ id: z.ZodString;
16
+ name: z.ZodString;
17
+ floor: z.ZodOptional<z.ZodString>;
18
+ hueRoom: z.ZodOptional<z.ZodString>;
19
+ }, z.core.$loose>;
20
+ /** JSONC (comments and trailing commas) through jsonc-parser, which runs on Node too; a syntax error throws rather than parsing to part of the file. */
21
+ export declare function parseJsonc(text: string): unknown;
22
+ /** A device is one connector's settings; a new connector adds its `configSchema` here, in one line. */
23
+ export declare const Device: z.ZodDiscriminatedUnion<[z.ZodObject<{
24
+ connector: z.ZodLiteral<"hue">;
25
+ host: z.ZodString;
26
+ bridgeId: z.ZodString;
27
+ key: z.ZodString;
28
+ ca: z.ZodString;
29
+ id: z.ZodString;
30
+ room: z.ZodOptional<z.ZodString>;
31
+ name: z.ZodOptional<z.ZodString>;
32
+ }, z.core.$loose>, z.ZodObject<{
33
+ connector: z.ZodLiteral<"daikin">;
34
+ host: z.ZodString;
35
+ mac: z.ZodOptional<z.ZodString>;
36
+ id: z.ZodString;
37
+ room: z.ZodOptional<z.ZodString>;
38
+ name: z.ZodOptional<z.ZodString>;
39
+ }, z.core.$loose>, z.ZodObject<{
40
+ connector: z.ZodLiteral<"enphase">;
41
+ host: z.ZodString;
42
+ serial: z.ZodOptional<z.ZodString>;
43
+ token: z.ZodString;
44
+ marker: z.ZodOptional<z.ZodString>;
45
+ capacityW: z.ZodOptional<z.ZodNumber>;
46
+ id: z.ZodString;
47
+ room: z.ZodOptional<z.ZodString>;
48
+ name: z.ZodOptional<z.ZodString>;
49
+ }, z.core.$loose>, z.ZodObject<{
50
+ connector: z.ZodLiteral<"amber">;
51
+ key: z.ZodString;
52
+ siteId: z.ZodOptional<z.ZodString>;
53
+ id: z.ZodString;
54
+ room: z.ZodOptional<z.ZodString>;
55
+ name: z.ZodOptional<z.ZodString>;
56
+ }, z.core.$loose>, z.ZodObject<{
57
+ connector: z.ZodLiteral<"up">;
58
+ key: z.ZodString;
59
+ account: z.ZodOptional<z.ZodObject<{
60
+ accountType: z.ZodOptional<z.ZodEnum<{
61
+ HOME_LOAN: "HOME_LOAN";
62
+ SAVER: "SAVER";
63
+ TRANSACTIONAL: "TRANSACTIONAL";
64
+ }>>;
65
+ ownershipType: z.ZodOptional<z.ZodEnum<{
66
+ INDIVIDUAL: "INDIVIDUAL";
67
+ JOINT: "JOINT";
68
+ }>>;
69
+ }, z.core.$strip>>;
70
+ household: z.ZodOptional<z.ZodObject<{
71
+ categories: z.ZodOptional<z.ZodArray<z.ZodString>>;
72
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
73
+ }, z.core.$strip>>;
74
+ id: z.ZodString;
75
+ room: z.ZodOptional<z.ZodString>;
76
+ name: z.ZodOptional<z.ZodString>;
77
+ }, z.core.$loose>, z.ZodObject<{
78
+ connector: z.ZodLiteral<"cast">;
79
+ name: z.ZodString;
80
+ castId: z.ZodString;
81
+ id: z.ZodString;
82
+ room: z.ZodOptional<z.ZodString>;
83
+ }, z.core.$loose>, z.ZodObject<{
84
+ connector: z.ZodLiteral<"printers">;
85
+ name: z.ZodString;
86
+ uuid: z.ZodString;
87
+ host: z.ZodOptional<z.ZodString>;
88
+ port: z.ZodOptional<z.ZodNumber>;
89
+ offlineDays: z.ZodOptional<z.ZodNumber>;
90
+ id: z.ZodString;
91
+ room: z.ZodOptional<z.ZodString>;
92
+ }, z.core.$loose>, z.ZodObject<{
93
+ connector: z.ZodLiteral<"spotify">;
94
+ url: z.ZodOptional<z.ZodURL>;
95
+ maxVolume: z.ZodOptional<z.ZodNumber>;
96
+ repairStalls: z.ZodOptional<z.ZodBoolean>;
97
+ id: z.ZodString;
98
+ room: z.ZodOptional<z.ZodString>;
99
+ name: z.ZodOptional<z.ZodString>;
100
+ }, z.core.$loose>], "connector">;
101
+ export type Device = z.infer<typeof Device>;
102
+ export declare const WEATHER_SOURCES: readonly ["bom", "open-meteo", "google"];
103
+ export declare const HomeConfig: z.ZodObject<{
104
+ $schema: z.ZodOptional<z.ZodString>;
105
+ name: z.ZodOptional<z.ZodString>;
106
+ location: z.ZodOptional<z.ZodObject<{
107
+ latitude: z.ZodNumber;
108
+ longitude: z.ZodNumber;
109
+ bomForecast: z.ZodOptional<z.ZodString>;
110
+ timezone: z.ZodOptional<z.ZodString>;
111
+ }, z.core.$loose>>;
112
+ weather: z.ZodOptional<z.ZodObject<{
113
+ sources: z.ZodOptional<z.ZodArray<z.ZodEnum<{
114
+ bom: "bom";
115
+ google: "google";
116
+ "open-meteo": "open-meteo";
117
+ }>>>;
118
+ pollMinutes: z.ZodOptional<z.ZodNumber>;
119
+ googleKey: z.ZodOptional<z.ZodString>;
120
+ }, z.core.$loose>>;
121
+ rooms: z.ZodOptional<z.ZodArray<z.ZodObject<{
122
+ id: z.ZodString;
123
+ name: z.ZodString;
124
+ floor: z.ZodOptional<z.ZodString>;
125
+ hueRoom: z.ZodOptional<z.ZodString>;
126
+ }, z.core.$loose>>>;
127
+ devices: z.ZodOptional<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
128
+ connector: z.ZodLiteral<"hue">;
129
+ host: z.ZodString;
130
+ bridgeId: z.ZodString;
131
+ key: z.ZodString;
132
+ ca: z.ZodString;
133
+ id: z.ZodString;
134
+ room: z.ZodOptional<z.ZodString>;
135
+ name: z.ZodOptional<z.ZodString>;
136
+ }, z.core.$loose>, z.ZodObject<{
137
+ connector: z.ZodLiteral<"daikin">;
138
+ host: z.ZodString;
139
+ mac: z.ZodOptional<z.ZodString>;
140
+ id: z.ZodString;
141
+ room: z.ZodOptional<z.ZodString>;
142
+ name: z.ZodOptional<z.ZodString>;
143
+ }, z.core.$loose>, z.ZodObject<{
144
+ connector: z.ZodLiteral<"enphase">;
145
+ host: z.ZodString;
146
+ serial: z.ZodOptional<z.ZodString>;
147
+ token: z.ZodString;
148
+ marker: z.ZodOptional<z.ZodString>;
149
+ capacityW: z.ZodOptional<z.ZodNumber>;
150
+ id: z.ZodString;
151
+ room: z.ZodOptional<z.ZodString>;
152
+ name: z.ZodOptional<z.ZodString>;
153
+ }, z.core.$loose>, z.ZodObject<{
154
+ connector: z.ZodLiteral<"amber">;
155
+ key: z.ZodString;
156
+ siteId: z.ZodOptional<z.ZodString>;
157
+ id: z.ZodString;
158
+ room: z.ZodOptional<z.ZodString>;
159
+ name: z.ZodOptional<z.ZodString>;
160
+ }, z.core.$loose>, z.ZodObject<{
161
+ connector: z.ZodLiteral<"up">;
162
+ key: z.ZodString;
163
+ account: z.ZodOptional<z.ZodObject<{
164
+ accountType: z.ZodOptional<z.ZodEnum<{
165
+ HOME_LOAN: "HOME_LOAN";
166
+ SAVER: "SAVER";
167
+ TRANSACTIONAL: "TRANSACTIONAL";
168
+ }>>;
169
+ ownershipType: z.ZodOptional<z.ZodEnum<{
170
+ INDIVIDUAL: "INDIVIDUAL";
171
+ JOINT: "JOINT";
172
+ }>>;
173
+ }, z.core.$strip>>;
174
+ household: z.ZodOptional<z.ZodObject<{
175
+ categories: z.ZodOptional<z.ZodArray<z.ZodString>>;
176
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
177
+ }, z.core.$strip>>;
178
+ id: z.ZodString;
179
+ room: z.ZodOptional<z.ZodString>;
180
+ name: z.ZodOptional<z.ZodString>;
181
+ }, z.core.$loose>, z.ZodObject<{
182
+ connector: z.ZodLiteral<"cast">;
183
+ name: z.ZodString;
184
+ castId: z.ZodString;
185
+ id: z.ZodString;
186
+ room: z.ZodOptional<z.ZodString>;
187
+ }, z.core.$loose>, z.ZodObject<{
188
+ connector: z.ZodLiteral<"printers">;
189
+ name: z.ZodString;
190
+ uuid: z.ZodString;
191
+ host: z.ZodOptional<z.ZodString>;
192
+ port: z.ZodOptional<z.ZodNumber>;
193
+ offlineDays: z.ZodOptional<z.ZodNumber>;
194
+ id: z.ZodString;
195
+ room: z.ZodOptional<z.ZodString>;
196
+ }, z.core.$loose>, z.ZodObject<{
197
+ connector: z.ZodLiteral<"spotify">;
198
+ url: z.ZodOptional<z.ZodURL>;
199
+ maxVolume: z.ZodOptional<z.ZodNumber>;
200
+ repairStalls: z.ZodOptional<z.ZodBoolean>;
201
+ id: z.ZodString;
202
+ room: z.ZodOptional<z.ZodString>;
203
+ name: z.ZodOptional<z.ZodString>;
204
+ }, z.core.$loose>], "connector">>>;
205
+ plan: z.ZodOptional<z.ZodObject<{
206
+ svg: z.ZodOptional<z.ZodString>;
207
+ json: z.ZodOptional<z.ZodString>;
208
+ }, z.core.$loose>>;
209
+ }, z.core.$loose>;
210
+ export type HomeConfig = z.infer<typeof HomeConfig>;
211
+ /** What a valid config would run: each integration, the device it comes from, its settings, and each secret's env name and whether it is set. */
212
+ export type Setup = {
213
+ name: string;
214
+ from: string;
215
+ settings: Record<string, string | number>;
216
+ secrets: Record<string, "set" | "unset">;
217
+ };
218
+ /** A config that cannot run. `problems` names each field. */
219
+ export declare class HomeConfigError extends Error {
220
+ readonly problems: string[];
221
+ constructor(problems: string[]);
222
+ }
223
+ /** The drawing side of a config: the schema, unique room and device ids, and every device's room present. Problems, not a throw. */
224
+ export declare function checkPlan(input: unknown): {
225
+ config?: HomeConfig;
226
+ problems: string[];
227
+ };
228
+ /**
229
+ * Validate a parsed config against `env`: `checkPlan`, one device per connector (Cast and printers: any number), and every env name set.
230
+ * Returns the problems rather than throwing, and what would be set up, secrets shown only as set or unset.
231
+ */
232
+ export declare function checkHome(input: unknown, env: Record<string, string | undefined>): {
233
+ config?: HomeConfig;
234
+ setup: Setup[];
235
+ problems: string[];
236
+ };
237
+ /**
238
+ * How `loadHome` reaches devices. `fetch` reaches Daikin, Amber, Up, printers and the weather; Hue and Enphase use `tls`,
239
+ * Bun's `tls` fetch option by default, `NODE_TLS` from `@khalido/home/node` under Node.
240
+ */
241
+ export type LoadOptions = {
242
+ env: Record<string, string | undefined>;
243
+ /** Reads a path from the config (a CA, a token file); the caller resolves it against the config file's folder. */
244
+ readFile?: (path: string) => string;
245
+ /** For Daikin, Amber, Up, printers and the weather; the global `fetch` when left out. */
246
+ fetch?: typeof fetch;
247
+ /** Hue's pinned fetch and Enphase's unverified one: Bun's by default, `NODE_TLS` from `@khalido/home/node` under Node. */
248
+ tls?: {
249
+ hue(bridgeId: string, ca: string): typeof fetch;
250
+ enphase(): typeof fetch;
251
+ };
252
+ /** BOM's transport: `ftpFetch()` from `@khalido/home/weather/ftp`. Without it a listed `bom` source stops and says why. */
253
+ ftpFetch?: typeof fetch;
254
+ /** Each connector's discovery (the CLI's mDNS and UDP), used only after reads keep failing, to find a device that moved. Cast needs its finder always: it is how Cast finds every address. */
255
+ finders?: Partial<Record<Connector, Finder>>;
256
+ /** Cast's transport: `tlsConnect` from `@khalido/home/cast/tls`. Needed when the config lists a Cast device. */
257
+ castConnect?: CastConnect;
258
+ /** For Cast's `announce` with a clip of bytes: `serveClip` from `@khalido/home/cast/serve`. A URL clip needs none. */
259
+ castServe?: ServeClip;
260
+ /** Time for every integration; tests pass a fake. */
261
+ clock?: Clock;
262
+ /**
263
+ * Addresses kept between runs (`fileCache` from `@khalido/home/finders`, or the caller's own). A cached address newer
264
+ * than the config is tried first; each address that answers is written back when it changed or predates the config. Discovery stays for a miss.
265
+ */
266
+ cache?: AddressCache;
267
+ /** When the config was last edited (ms): a cached address older than this yields to the config's `host`. */
268
+ configAt?: number;
269
+ };
270
+ /** Any integration `loadHome` builds, typed by its name and state (and Hue's client, Cast's actions), so `createHome(loadHome(…))` is typed. */
271
+ export type HomeIntegration = ReturnType<typeof hue> | ReturnType<typeof daikin> | ReturnType<typeof enphase> | ReturnType<typeof amber> | ReturnType<typeof up> | ReturnType<typeof cast> | ReturnType<typeof printers> | ReturnType<typeof spotify> | ReturnType<typeof weather>;
272
+ /** Build the integrations a config describes, in its device order, weather last. Throws `HomeConfigError` naming every problem. */
273
+ export declare function loadHome(input: unknown, options: LoadOptions): HomeIntegration[];