@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.
- package/.env.example +2 -2
- package/CHANGELOG.md +74 -2
- package/README.md +7 -2
- package/dist/clock.js +2 -0
- package/dist/config.d.ts +5 -3
- package/dist/config.js +5 -1
- package/dist/connector.d.ts +9 -2
- package/dist/connectors/amber/index.js +1 -0
- package/dist/connectors/cast/index.d.ts +8 -6
- package/dist/connectors/cast/index.js +30 -4
- package/dist/connectors/cast/state.d.ts +66 -0
- package/dist/connectors/cast/state.js +141 -0
- package/dist/connectors/cast/writes.d.ts +0 -2
- package/dist/connectors/cast/writes.js +25 -7
- package/dist/connectors/daikin/client.d.ts +45 -2
- package/dist/connectors/daikin/client.js +67 -2
- package/dist/connectors/daikin/index.d.ts +8 -4
- package/dist/connectors/daikin/index.js +25 -4
- package/dist/connectors/enphase/index.d.ts +5 -1
- package/dist/connectors/enphase/index.js +34 -2
- package/dist/connectors/hue/ca.d.ts +2 -0
- package/dist/connectors/hue/ca.js +38 -0
- package/dist/connectors/hue/client.d.ts +81 -6
- package/dist/connectors/hue/client.js +217 -6
- package/dist/connectors/hue/color.d.ts +30 -0
- package/dist/connectors/hue/color.js +92 -0
- package/dist/connectors/hue/index.d.ts +21 -6
- package/dist/connectors/hue/index.js +59 -9
- package/dist/connectors/hue/schemas.d.ts +27 -0
- package/dist/connectors/hue/schemas.js +26 -0
- package/dist/connectors/hue/state.d.ts +10 -1
- package/dist/connectors/hue/state.js +20 -1
- package/dist/connectors/printers/index.d.ts +6 -2
- package/dist/connectors/printers/index.js +23 -0
- package/dist/connectors/spotify/changes.d.ts +19 -5
- package/dist/connectors/spotify/changes.js +29 -7
- package/dist/connectors/spotify/index.d.ts +7 -2
- package/dist/connectors/spotify/index.js +34 -3
- package/dist/connectors/up/index.js +1 -0
- package/dist/connectors/weather/bom.js +1 -0
- package/dist/connectors/weather/google.js +52 -9
- package/dist/connectors/weather/index.d.ts +2 -1
- package/dist/connectors/weather/index.js +7 -0
- package/dist/connectors/weather/source.d.ts +2 -0
- package/dist/devices.d.ts +51 -0
- package/dist/devices.js +115 -0
- package/dist/discover.js +1 -1
- package/dist/finders-entry.d.ts +1 -1
- package/dist/finders-entry.js +1 -1
- package/dist/finders.d.ts +2 -1
- package/dist/finders.js +16 -5
- package/dist/health.d.ts +2 -1
- package/dist/health.js +2 -1
- package/dist/home.d.ts +52 -5
- package/dist/home.js +114 -33
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/node-fetch.d.ts +2 -2
- package/dist/node-fetch.js +3 -2
- package/docs/cli.md +13 -7
- package/docs/config.md +18 -5
- package/docs/decisions.md +16 -0
- package/docs/hue.md +161 -0
- package/docs/using.md +115 -3
- package/examples/home.jsonc +2 -1
- package/home.schema.json +10 -3
- package/llms.txt +19 -6
- package/package.json +1 -1
- package/skills/draw-home/SKILL.md +1 -1
- package/skills/use-home/SKILL.md +10 -10
- package/src/cli/cast.ts +51 -5
- package/src/cli/context.ts +2 -0
- package/src/cli/daikin.ts +71 -0
- package/src/cli/enphase.ts +5 -1
- package/src/cli/hue.ts +147 -59
- package/src/cli/main.ts +35 -10
- package/src/cli/probe.ts +21 -17
- package/src/cli/status.ts +87 -13
- package/src/clock.ts +1 -0
- package/src/config.ts +8 -2
- package/src/connector.ts +18 -2
- package/src/connectors/amber/index.ts +1 -0
- package/src/connectors/cast/README.md +10 -3
- package/src/connectors/cast/index.ts +65 -7
- package/src/connectors/cast/state.ts +204 -0
- package/src/connectors/cast/writes.ts +29 -8
- package/src/connectors/daikin/README.md +20 -4
- package/src/connectors/daikin/client.ts +104 -3
- package/src/connectors/daikin/index.ts +49 -4
- package/src/connectors/enphase/index.ts +49 -2
- package/src/connectors/hue/README.md +62 -17
- package/src/connectors/hue/ca.ts +39 -0
- package/src/connectors/hue/client.ts +362 -15
- package/src/connectors/hue/color.ts +123 -0
- package/src/connectors/hue/index.ts +94 -12
- package/src/connectors/hue/schemas.ts +26 -0
- package/src/connectors/hue/state.ts +27 -1
- package/src/connectors/printers/README.md +4 -2
- package/src/connectors/printers/index.ts +43 -1
- package/src/connectors/spotify/README.md +21 -3
- package/src/connectors/spotify/changes.ts +38 -9
- package/src/connectors/spotify/index.ts +55 -3
- package/src/connectors/up/index.ts +1 -0
- package/src/connectors/weather/README.md +1 -1
- package/src/connectors/weather/bom.ts +1 -0
- package/src/connectors/weather/google.ts +66 -10
- package/src/connectors/weather/index.ts +16 -2
- package/src/connectors/weather/source.ts +2 -0
- package/src/devices.ts +182 -0
- package/src/discover.ts +1 -2
- package/src/finders-entry.ts +1 -1
- package/src/finders.ts +18 -6
- package/src/health.ts +4 -2
- package/src/home.ts +166 -39
- package/src/index.ts +11 -1
- package/src/node-fetch.ts +6 -2
package/docs/config.md
CHANGED
|
@@ -4,30 +4,43 @@ One JSONC file lays out the home, so the `home` command, jaldi and chota-bot rea
|
|
|
4
4
|
|
|
5
5
|
```ts
|
|
6
6
|
import { readFileSync } from "node:fs";
|
|
7
|
+
import { homedir } from "node:os";
|
|
7
8
|
import { dirname, resolve } from "node:path";
|
|
8
9
|
import { createHome, loadHome, parseJsonc } from "@khalido/home";
|
|
10
|
+
import { serveClip } from "@khalido/home/cast/serve";
|
|
11
|
+
import { tlsConnect } from "@khalido/home/cast/tls";
|
|
12
|
+
import { FINDERS, fileCache } from "@khalido/home/finders";
|
|
9
13
|
import { ftpFetch } from "@khalido/home/weather/ftp";
|
|
10
14
|
|
|
11
15
|
const path = "/path/to/home.jsonc";
|
|
12
|
-
const
|
|
16
|
+
const cachePath = "/path/to/addresses.json";
|
|
17
|
+
const readFile = (p: string) =>
|
|
18
|
+
readFileSync(
|
|
19
|
+
resolve(dirname(path), p.replace(/^~(?=\/)/, homedir())),
|
|
20
|
+
"utf8",
|
|
21
|
+
); // relative to the config, or from ~/
|
|
13
22
|
const home = createHome(
|
|
14
23
|
loadHome(parseJsonc(readFileSync(path, "utf8")), {
|
|
15
24
|
env: process.env,
|
|
16
25
|
readFile,
|
|
17
|
-
ftpFetch: ftpFetch(),
|
|
26
|
+
ftpFetch: ftpFetch(), // BOM's weather
|
|
27
|
+
finders: FINDERS, // finds Cast devices, and any device that moves
|
|
28
|
+
castConnect: tlsConnect, // Cast's socket; a config with Cast is refused without it
|
|
29
|
+
castServe: serveClip, // only to announce a clip from bytes, not a URL
|
|
30
|
+
cache: fileCache(cachePath), // addresses kept between runs
|
|
18
31
|
}),
|
|
19
32
|
);
|
|
20
33
|
```
|
|
21
34
|
|
|
22
|
-
`loadHome` runs on Bun and Node. Hue and Enphase need a fetch that handles their certificates: Bun's `tls` fetch option by default, and under Node `tls: NODE_TLS` from `@khalido/home/node`. Under Node, a config with Hue or Enphase and no `tls` is a problem `loadHome` names, not a silent failure at the first read.
|
|
35
|
+
Only `env` is always needed: `readFile` for a config that names a file, `ftpFetch` for BOM, `finders` and `castConnect` for Cast. `loadHome` runs on Bun and Node. Hue and Enphase need a fetch that handles their certificates: Bun's `tls` fetch option by default, and under Node `tls: NODE_TLS` from `@khalido/home/node`. Under Node, a config with Hue or Enphase and no `tls` is a problem `loadHome` names, not a silent failure at the first read.
|
|
23
36
|
|
|
24
37
|
- **A device entry is only how to reach it and where it is**: its identity (the Hue `bridgeId`, a Daikin's `mac`, the Enphase `serial`, a Cast `castId`), its last known `host`, a key's env name, a file's path, its `room`. What a device reports about itself (model, firmware) comes live; a note about it is a comment.
|
|
25
|
-
- **Secrets are env variable names** (`"key": "AMBER_KEY"`), resolved against the `env` the caller passes; a pasted key fails the schema.
|
|
38
|
+
- **Secrets are env variable names** (`"key": "AMBER_KEY"`), resolved against the `env` the caller passes; a pasted key fails the schema. `env` is any record, not only `process.env`: a key the app keeps in its own store (a Hue key paired from a chat) goes in as `env: { ...process.env, HUE_APPLICATION_KEY: stored }`. Files (Enphase's `token`; a Hue `ca` only to replace Signify's roots, which the package carries) are paths the caller reads, relative to the config or from `~/`, which the caller expands (`home discover --write` writes Enphase's token as `~/.config/home/enphase-token.json`); the library reads nothing.
|
|
26
39
|
- **Problems name the field**: an unknown connector, a missing env name or a device in a missing room is an error, never a skip. `checkHome` returns them all; `loadHome` throws them all. Unknown keys pass through for a consumer's own settings.
|
|
27
40
|
- **One device per connector** for now (each integration is named by its connector), except printers (one entry each, by `uuid`) and Cast: one entry per device and speaker group (`name`, the Google Home name, and `castId`, the mDNS id), all read by one `cast` integration that finds every address by discovery, since a group's moves with its leader. A `spotify` entry is only its Soloist `url` (and `maxVolume`): a socket on the same machine, with nothing to discover.
|
|
28
41
|
- **Identity, not address.** A device is recognised by the id its own discovery reports; `host` is a cache. `home discover` lists what answers (mDNS, Daikin's UDP broadcast), in two passes 5 s apart since both drop answers now and then: the first pass prints at once, the second only what it adds (`--passes 1` for a quick look; `--json` waits for both); with a config (`--config` or `HOME_CONFIG`) it then matches those against it by identity and prints new devices, moved addresses and conflicts (a different device at an entry's host). `home discover --write <file>` adds the new ones (each with an id no entry has), updates moved hosts and refreshes the `name` of a Cast entry renamed in the Google Home app through `jsonc-parser`'s edits, every comment and other line left as it was, and writes only a result that passes `checkPlan`. Nothing else writes the file; the library never does. Two devices with one id are a config problem.
|
|
29
42
|
- **A device that moves is found again, without periodic scans**, when the caller passes `finders` to `loadHome` (`FINDERS` from `@khalido/home/finders`, Node-side like `weather/ftp`; the CLI does, and jaldi and chota-bot can). After three failed reads in a row (Hue: three failed reconnects of its event stream), Hue, Daikin and Enphase run their discovery (two passes, the second skipped when the first finds the device), switch to the address their identity now answers at and say "moved from a to b" in `detail` (persist it with `home discover --write`); otherwise they look again at 1, 5 and 15 minutes, then hourly for as long as reads fail, so a device back from the wall socket reads again without a restart. The detail says what discovery found ("not found by discovery", or "discovery finds it at its address but reads fail", or "discovery failed: …" when it could not run), and "no answer for a day" once a day has passed; the part stays `disconnected`, since `unavailable` is for a rejected key. `refresh()` resets the wait. Amber and the weather have no discovery and do not take part. Cast always needs `finders.cast`: discovery is how it finds every address.
|
|
30
|
-
- **Discovery is rare: addresses are cached between runs.** `loadHome(config, { cache, configAt })` takes an `AddressCache` (`read()` and `write(entries)`, keyed by connector and identity, each entry `{ host, port?, name?, seenAt }`). A cached address newer than the config (`configAt`, the file's modified time) is tried first, a failure there scans at once rather than after three, and each address that answers is written back when it changed or predates the config, so a config edit is re-trusted at the first good read. Nothing evicts an entry but `home cache clear`: a device gone for good keeps its entry, and `home status` without a config, which reads every Cast entry from the cache, shows it as not found until then. The consumer persists it: the CLI keeps `$XDG_CACHE_HOME/home/addresses.json` (default `~/.cache/home/addresses.json`, 0600) through `fileCache` from `@khalido/home/finders`, written at the end of `status`, `probe` and `discover`;
|
|
43
|
+
- **Discovery is rare: addresses are cached between runs.** `loadHome(config, { cache, configAt })` takes an `AddressCache` (`read()` and `write(entries)`, keyed by connector and identity, each entry `{ host, port?, name?, seenAt }`). A cached address newer than the config (`configAt`, the file's modified time) is tried first, a failure there scans at once rather than after three, and each address that answers is written back when it changed or predates the config, so a config edit is re-trusted at the first good read. Nothing evicts an entry but `home cache clear`: a device gone for good keeps its entry, and `home status` without a config, which reads every Cast entry from the cache, shows it as not found until then. The consumer persists it: the CLI keeps `$XDG_CACHE_HOME/home/addresses.json` (default `~/.cache/home/addresses.json`, 0600) through `fileCache` from `@khalido/home/finders`, written at the end of `status`, `probe` and `discover`; another consumer passes `FINDERS` and its own cache the same way. The config's `host` stays the human-set fallback and `home discover --write` the way to change the config. `home status` from a Cast config: 0.33–0.44 s cold, 0.13 s with a warm cache, 1.4 s once when a cached address has gone stale (Bun 1.4.2, measured 2026-10-13).
|
|
31
44
|
- **Parsing** is through `jsonc-parser` (comments and trailing commas), on Bun and Node alike; a syntax error throws. `bun run schema` rewrites `home.schema.json` from the zod schema, and a test fails if they drift.
|
|
32
45
|
- `createHome` stays for code-first use.
|
|
33
46
|
|
package/docs/decisions.md
CHANGED
|
@@ -149,3 +149,19 @@ KO's decisions on this package, newest block last. They override every other doc
|
|
|
149
149
|
- **Stalls (Soloist issue #5) are noticed by default and repaired only when asked** (`repairStalls: true`): the repair is the library's first write nobody asked for, so a consumer opts in. Two signals, since the issue doesn't say whether the reported position freezes: a frozen position across fresh reports, and still playing well past the track's end, the symptom actually reported. One repair per track, never a loop. The nudges (seek, then pause and play; a skip past the end) are guesses until a stall is seen live.
|
|
150
150
|
- **The project is home.ts; the npm package stays `@khalido/home`** (KO, 2026-11-08), so imports stay short (`@khalido/home/spotify`). It is home automation for TypeScript, starting with the devices in KO's house, and is meant to grow into more of the basics.
|
|
151
151
|
- **Spotify: inactive is refused, and activating is the caller's choice** (KO, 2026-11-09). Soloist accepts and drops `set_volume` while it isn't the active device. `setVolume` refuses with code `inactive`, and `activate()` is separate, because activating makes a new stream and can jump the volume to the level last saved. `duck` runs `fn` anyway, with `music: "untouched"`, because the announcement matters more. `play` goes active itself, since someone asking for music wants it to start; `resume` doesn't, since it worked inactive. The `maxVolume` cap says "lowered" only once Soloist reports it.
|
|
152
|
+
|
|
153
|
+
- **`Part.reason`, only on `unavailable`** (KO, 2026-11-09). An admin page had to match `detail` text like "pair again" to know a person must re-key. `reason` is `auth` (a key or token rejected or unusable), `config` (a setting or file is wrong) or `crashed` (a `run` threw), for code to switch on; `detail` stays the text for people. `disconnected` gets none: it already says unreachable. Additive, so a patch. `config` came a day later (KO, 2026-11-10), so "re-key", "fix a setting" and "report a bug" are three answers; `crashed` stays though nothing reads it yet. Weather has one too: each source says why it stopped (`stopReason`), the part is `auth` if any stopped source is, else `config`, and `state.sources.<name>.reason` shows a dead key while another source keeps the part `connected`.
|
|
154
|
+
- **Google's key goes in the `X-Goog-Api-Key` header, not the URL** (KO, 2026-11-10), so nothing that prints a URL can print it. Its rejection is read from the error body: a bad key and a bad latitude are both HTTP 400, and only the key's carries `API_KEY_INVALID` (live, 2026-11-10).
|
|
155
|
+
- **Every device answers the same few questions** (KO, 2026-11-10: "nice and consistent so we can easily and consistently add devices"). `DeviceRecord` is identity, availability, one word of state and one line of text, the same for a light, a speaker and a printer; `homeDevices(snapshot, config?)` lists them all. Everything particular to a kind stays in the connector's own typed row, carried as `detail`, so the common record never grows a field only one kind has. Three levels, each inside the last: the house list, the connector's typed row, the raw snapshot. Each connector exports its own typed function, listed by hand in `src/devices.ts`, and not a manifest field: `MANIFESTS` is untyped, and `detail` must narrow by `connector`. The list is read-only: a write means something different for a light, a speaker and an air conditioner, so there is no generic `turnOn`. Not done, for a 0.2.0: Hue's `reachable` becoming `available`, and its groups joining its lights in one call.
|
|
156
|
+
|
|
157
|
+
# 2026-11-11
|
|
158
|
+
|
|
159
|
+
- **Signify's two root certificates ship in the package** (KO, 2026-11-11). Signify's own page tells apps to bundle both and validate against them, and openHAB, Homebridge's tools, openhue-go and Raycast do. `ca` stays as an override. No trust-on-first-use and no way to turn validation off: self-signed bridges were updated in May 2025, and Home Assistant's `ssl=False` is what this avoids. `docs/hue.md` has the detail.
|
|
160
|
+
- **`pairWhenPressed` polls for 30 s; `pair` stays one request.** A consumer pairing from a chat wants "press the button" and then a key, not its own retry loop. 2 s between asks is this library's choice (openHAB uses 0.5 s, openhue-cli 1 s, Homebridge 15 s).
|
|
161
|
+
- **Hue discovery stays mDNS only.** Signify's cloud endpoint (`discovery.meethue.com`) would also find a bridge, but nothing here calls a cloud to reach a local device.
|
|
162
|
+
- **The Daikin's default setpoints live in the library: cool 25, heat 27** (KO, 2026-11-11: "I like having opinionated defaults"). `mode("cool")` always sets one, since the unit keeps a setpoint per mode and would otherwise give whatever the remote last left; `power(true)` is the way to get it "as it was". A caller that wants another number passes it.
|
|
163
|
+
- **Daikin's `power` and `mode` resolve with `{ state, confirmed }`**, as spotify's writes do: the unit is read until it shows what was asked, so a bot can tell "done" from "sent".
|
|
164
|
+
- **Hue's writes are by name, on the client, and read before they write** (KO, 2026-11-11, after chota asked for what the CLI had). `light`, `room` and `scene` resolve a name against a fresh read, check the change against that light's own gamut and kelvin range, send only what was asked, and resolve `{ state, confirmed }` as the Daikin's do. A read costs about 0.3 s (289 ms for 177 resources, Bun 1.4.4, 2026-11-11); it buys a miss that lists what exists and no table of bulb models. A colour off the gamut moves to the nearest the light can show, as Signify's page says to; a kelvin off the range throws, since there is no "nearest" a person would expect.
|
|
165
|
+
- **Spotify playing on another device reads as `elsewhere`, not `playing`** (KO, 2026-11-11; chota raised it three times). Soloist reports the account's playback, not the box's, so `playing` while inactive is a silent speaker. `speakerState`, `spotifyText` and `spotifyChanges` all go through one test, `playingElsewhere`. Paused while inactive is left as Soloist says: nothing live shows whose pause it is. Soloist never names the other device, so where is a join with Cast by track name (`spotifyElsewhere`), passed to `spotifyText(state, on)` by `homeDevices`, which sees both parts; the speaker's own part can't know it, so `home status` says "another Spotify device".
|
|
166
|
+
- **`home.add` replaces by name and starts the part over** (KO, 2026-11-11; chota pairs Hue from a chat). The old run ends before the new one starts, so a device never has two streams; the new part is a bare `connecting`, since the old state belonged to another key or address. No type widening: a `loadHome` home is already typed for every connector, so `add` takes the home's own integration type. `remove` fires no `onChange`: a change needs an `after`.
|
|
167
|
+
- **Dry run is an option on each write, not a separate client** (KO, 2026-11-12; chota asked, since KO's rule is no device write without his OK at the time). It runs the read and every check a real write runs, so a wrong name or an unreachable light fails the same way, and returns the request instead of sending it, in the same `{ state, confirmed }` shape with `wouldSend` added. Hue and Daikin first, chota's lights and aircon tools; Cast and Spotify when a tool needs them.
|
package/docs/hue.md
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# Hue: certificates, discovery, write rates, rooms, colour and pairing
|
|
2
|
+
|
|
3
|
+
What Signify asks of an app on the bridge's local API, what other projects do, and what this library does. Signify's developer pages need a free Hue developer login; KO read them on 2026-11-11 and the points below are from those, in our words. The connector's own file is [src/connectors/hue/README.md](../src/connectors/hue/README.md).
|
|
4
|
+
|
|
5
|
+
## Links
|
|
6
|
+
|
|
7
|
+
| Page | For | Login |
|
|
8
|
+
| --------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | ----- |
|
|
9
|
+
| [Bridge release notes](https://www.philips-hue.com/en-au/support/release-notes/bridge) | what each bridge software version changed; check after an update | no |
|
|
10
|
+
| [Using HTTPS](https://developers.meethue.com/develop/application-design-guidance/using-https/) | the two root certificates, CN check, self-signed bridges | yes |
|
|
11
|
+
| [Hue Bridge Discovery](https://developers.meethue.com/develop/application-design-guidance/hue-bridge-discovery/) | mDNS, the cloud endpoint, validating a bridge | yes |
|
|
12
|
+
| [Hue System Performance](https://developers.meethue.com/develop/application-design-guidance/hue-system-performance/) | latency, how many commands a second | yes |
|
|
13
|
+
| [Color Conversion Formulas](https://developers.meethue.com/develop/application-design-guidance/color-conversion-formulas-rgb-to-xy-and-back/) | RGB to `xy` and back, the gamuts | yes |
|
|
14
|
+
| [API v2: getting started](https://developers.meethue.com/develop/hue-api-v2/getting-started/) | pairing, the key header, resource URLs | yes |
|
|
15
|
+
| [API v2: core concepts](https://developers.meethue.com/develop/hue-api-v2/core-concepts/) | the resource model, the event stream | yes |
|
|
16
|
+
| [openhue-api](https://github.com/openhue/openhue-api) | an OpenAPI spec of CLIP v2 (Apache-2.0), no login | no |
|
|
17
|
+
| [IANA: `hue` service](https://www.iana.org/assignments/service-names-port-numbers/service-names-port-numbers.xhtml?&search=hue) | the registered mDNS service and its TXT keys | no |
|
|
18
|
+
|
|
19
|
+
## Certificates
|
|
20
|
+
|
|
21
|
+
Signify's rules ([Using HTTPS](https://developers.meethue.com/develop/application-design-guidance/using-https/)):
|
|
22
|
+
|
|
23
|
+
- The local API is HTTPS only, port 443. No fallback to HTTP: a fake bridge could ask for one and collect the key.
|
|
24
|
+
- Signify has two private root certificates for bridges: one in use, and a second it may switch to. An app bundles both and validates the bridge's certificate against them.
|
|
25
|
+
- The certificate's Common Name is the bridge id. Check it, since the certificate carries no address.
|
|
26
|
+
- Today the root signs a bridge's certificate directly. Under the second root there will likely be an intermediate, which the bridge sends in the handshake, so the two roots are still all an app carries.
|
|
27
|
+
- Self-signed certificates are gone: the few early bridges that had them were updated (the release notes put this at software 1971060010, 22 May 2025). A bridge that fails validation needs its firmware updated. Trust-on-first-use code can be removed.
|
|
28
|
+
- Turning validation off is for development only.
|
|
29
|
+
- Keep the connection alive between requests.
|
|
30
|
+
|
|
31
|
+
| Root | Valid to | SHA-256 of the DER |
|
|
32
|
+
| ---------------------------------- | ---------- | ------------------------------------------------------------------------------------------------- |
|
|
33
|
+
| `CN=root-bridge, O=Philips Hue` | 2038-01-19 | `F0:BD:8E:65:09:E8:2F:77:4D:63:BC:00:9D:53:88:C9:69:FE:3D:CF:7D:6D:54:1D:63:51:B7:2B:89:8D:8A:CF` |
|
|
34
|
+
| `CN=Hue Root CA 01, O=Signify Hue` | 2050-12-31 | `D8:B8:94:48:B2:AF:8E:16:76:18:5A:C0:72:19:EE:9D:CB:C8:F0:1C:12:2A:02:6A:2A:4B:7B:5C:FE:03:28:B8` |
|
|
35
|
+
|
|
36
|
+
**What this library does.** Both roots are in the package as `HUE_BRIDGE_ROOT_CA` (`src/connectors/hue/ca.ts`), byte for byte the bundle on Signify's page (compared 2026-11-11), and a test holds the two fingerprints. `bunPinnedFetch(bridgeId)` and `nodePinnedFetch(bridgeId)` trust those two and nothing else, and accept only a certificate whose CN is the bridge id. A caller can still pass its own `ca`. There is no trust-on-first-use and no way to turn validation off.
|
|
37
|
+
|
|
38
|
+
**Live, 2026-11-11, KO's BSB002 on software 1978545030 (Bun 1.4.4), reads of `/api/0/config` only:**
|
|
39
|
+
|
|
40
|
+
- Its certificate is `CN=<bridge id>`, issued 9 April 2025 by `root-bridge`, with no intermediate.
|
|
41
|
+
- The bundle: accepted. The two roots in the other order: accepted, so Bun reads both from one PEM string. The second root alone: refused ("unable to verify the first certificate"). Another bridge id: refused by the CN check.
|
|
42
|
+
|
|
43
|
+
**Untried:** a Bridge Pro (BSB003). Others report it chains to the second root through an intermediate (hb-hue-tools, openHAB #20400); nothing here has met one. Node's fetch against a real bridge.
|
|
44
|
+
|
|
45
|
+
**What others do** (their sources read 2026-11-11):
|
|
46
|
+
|
|
47
|
+
| Project | Approach |
|
|
48
|
+
| ------------------------- | ----------------------------------------------------------------------------------------------- |
|
|
49
|
+
| Home Assistant (aiohue) | validation off (`ssl=False`) |
|
|
50
|
+
| node-hue-api, openhue-cli | validation off; node-hue-api compares the CN to the bridge id it read over that same connection |
|
|
51
|
+
| openHAB hue binding | both roots bundled; an option trusts the bridge's own certificate instead |
|
|
52
|
+
| hb-hue-tools (Homebridge) | both roots bundled, CN and serial checked against the bridge id |
|
|
53
|
+
| openhue-go | both roots bundled, chain checked, no CN check |
|
|
54
|
+
| Raycast's Hue extension | both roots bundled, CN checked; self-signed support dropped June 2025 |
|
|
55
|
+
| Hue-Sync | the first root as a file; the user points `NODE_EXTRA_CA_CERTS` at it |
|
|
56
|
+
|
|
57
|
+
Everyone who bundles carries the same bytes and cites Signify's page. Signify's page tells apps to bundle them; no licence text for the certificates was found.
|
|
58
|
+
|
|
59
|
+
## Discovery
|
|
60
|
+
|
|
61
|
+
Signify's rules ([Hue Bridge Discovery](https://developers.meethue.com/develop/application-design-guidance/hue-bridge-discovery/)):
|
|
62
|
+
|
|
63
|
+
- Discover only at first install, or when the stored address stops answering. Store the address; check it at each start.
|
|
64
|
+
- **mDNS** first: service `_hue._tcp`, with `bridgeid` and `modelid` in the TXT record. SSDP is gone.
|
|
65
|
+
- **The cloud endpoint**, `https://discovery.meethue.com`: the bridges seen from this network's public address, each with its id and LAN address. At most one request per 15 minutes per client. Empty if the bridge has never been online.
|
|
66
|
+
- **A typed address** as the fallback.
|
|
67
|
+
- Validate what was found with `GET https://<address>/api/0/config`, which needs no key: name, `bridgeid`, `modelid`, software version.
|
|
68
|
+
- Several bridges: list them by id for the person to pick, or start pairing on all and let the button press pick.
|
|
69
|
+
|
|
70
|
+
**What this library does.** mDNS only (`FINDERS.hue`), matched by bridge id. The address lives in the config or the caller's address cache; `hue()` searches again only after three failed reconnects, and follows the bridge to a new address. `home probe` is the `/api/0/config` check. A typed address is `host` in the config or `HUE_BRIDGE_HOST`.
|
|
71
|
+
|
|
72
|
+
**Not done:**
|
|
73
|
+
|
|
74
|
+
- The cloud endpoint. It would find a bridge where mDNS is blocked, at the cost of one request to Signify; nothing here calls a cloud to reach a local device. Worth adding to `home discover` only if mDNS fails someone.
|
|
75
|
+
- More than one bridge: the CLI takes the first mDNS answer. A config can list several by `bridgeId`.
|
|
76
|
+
|
|
77
|
+
## Write rates
|
|
78
|
+
|
|
79
|
+
Signify's rules ([Hue System Performance](https://developers.meethue.com/develop/application-design-guidance/hue-system-performance/)):
|
|
80
|
+
|
|
81
|
+
- **About 10 commands a second to lights, 100 ms apart; at most 1 a second to groups.** Send fewer where you can: the Zigbee network is shared with switches and the bridge's own housekeeping.
|
|
82
|
+
- The bridge turns each command into one Zigbee message per thing changed, and the network carries about 25 a second. Brightness alone is one message, brightness and colour two, those plus on/off three. So send only what changes: no `on` to a light already on.
|
|
83
|
+
- Latency to one light, wired, one hop: about 55 ms for one message, 95 ms for two, 125 ms for three.
|
|
84
|
+
- A group command is a Zigbee broadcast, limited to about one a second, and broadcasts slow single-light commands too.
|
|
85
|
+
- Past the limit the bridge buffers without saying so and latency grows to seconds; when the buffer fills it drops the command and answers an error (type 901, "Internal error, 503").
|
|
86
|
+
- Other apps share the same budget. A light several hops away is slower.
|
|
87
|
+
- Fast updates to many lights for more than a few seconds belong on the Entertainment streaming API, not this one.
|
|
88
|
+
|
|
89
|
+
**What this library does.** Two write lanes per client (`WRITE_GAP_MS` in `client.ts`): `light` writes 100 ms apart, `grouped_light` and `scene` writes 1 s apart, both Signify's figures. A write to a resource whose last write is still waiting merges into it, so a burst cannot build a backlog. Prefer one `grouped_light` or scene write over many light writes.
|
|
90
|
+
|
|
91
|
+
**Not done:** a 503 (error 901) is reported as a failed write, not retried or slowed for. Whether a merged write carries a field the light already has (an `on` to a light that is on) is the caller's to avoid. No Entertainment API.
|
|
92
|
+
|
|
93
|
+
**Transition time.** A change fades over 400 ms unless the write says otherwise, and the setting does not stick: each write that wants another time must carry it (v2: `dynamics.duration`, in ms). Right for everyday use; an app that wants a light to respond at once sends 0. Latency above is measured with 0. `transitionMs` on a light or room change sets it; without one a write takes the bridge's 400 ms.
|
|
94
|
+
|
|
95
|
+
## Rooms, zones and scenes
|
|
96
|
+
|
|
97
|
+
People group their lights into rooms in the Hue app, so a home app should use those rooms and not make its own. Signify's [groups and scenes guide](https://developers.meethue.com/develop/application-design-guidance/hue-groups-rooms-and-scene-control/) is written for API v1; the points that carry over to v2:
|
|
98
|
+
|
|
99
|
+
- **A write to a group changes its lights together**, with no popcorn effect, and costs one broadcast (the one-a-second limit above).
|
|
100
|
+
- **A light is in at most one room.** Zones (v2) are the free groupings; a light can be in several. The whole-home group is made by the bridge.
|
|
101
|
+
- **Each light does what it can:** a dimmable light ignores a colour sent to its group.
|
|
102
|
+
- **A light that is off takes no brightness or colour change.** Turn it on in the same write.
|
|
103
|
+
- **Colour has one mode at a time.** If a write carries more than one, `xy` wins over colour temperature.
|
|
104
|
+
- **A scene belongs to a room or zone and stores each light's state.** Recalling it changes only the lights that are in both.
|
|
105
|
+
|
|
106
|
+
In v2 a room or zone is a `room` or `zone` resource whose `grouped_light` service takes the writes, and the whole home is `bridge_home`.
|
|
107
|
+
|
|
108
|
+
**What this library does.** Reads rooms, zones (`groups(state)`, each with its lights and its grouped light's on/off and brightness), the whole-home group (`homeGroupedLightId`) and scenes (`scenes(state)`). The config's `hueRoom` ties a Hue room to a room on the plan, and `homeDevices` gives each light its room. Writes: `home lights off`, `home room <name> [on|off] [<1-100>] [<kelvin>k] [<hex colour>]`, `home scene <name>`, each one group write; from code, the typed writes below, or `client.put("grouped_light", id, body)` and `client.recallScene(id)` for anything else.
|
|
109
|
+
|
|
110
|
+
Typed writes by name: `client.light(name, change)`, `client.room(name, change)`, `client.scene(name, room?)`, each resolving `{ state, confirmed }` (the connector README has the rules).
|
|
111
|
+
|
|
112
|
+
**Not done:** creating or editing rooms and scenes, which stays in the Hue app. The typed writes ran on KO's bridge on 2026-11-11: every one confirmed in about a second, a fade included (the connector README has the run).
|
|
113
|
+
|
|
114
|
+
## Colour
|
|
115
|
+
|
|
116
|
+
Signify's rules ([Color Conversion Formulas](https://developers.meethue.com/develop/application-design-guidance/color-conversion-formulas-rgb-to-xy-and-back/)):
|
|
117
|
+
|
|
118
|
+
- A light's colour is an `xy` point in the CIE colour space, so one `xy` is the same colour on every light that can show it. Brightness is separate.
|
|
119
|
+
- Each light can show the colours inside a triangle, its gamut. Three are named: C for newer lights (red 0.6915, 0.3038; green 0.17, 0.7; blue 0.1532, 0.0475), B for the older bulb, A for the legacy LivingColors lamps and strips. **API v2 gives each light's gamut on its `light` resource**, so nothing needs a table of models.
|
|
120
|
+
- RGB to `xy`: scale to 0–1, undo the sRGB gamma, convert to XYZ with the D65 matrix, then `x = X / (X + Y + Z)` and `y = Y / (X + Y + Z)`; `Y` is the brightness. If the point is outside the light's gamut, move it to the nearest point on the triangle.
|
|
121
|
+
- `xy` to RGB, for showing a light's colour on a screen, is the same in reverse, with the brightness as `Y`.
|
|
122
|
+
- A colour given as hue, saturation and value goes to RGB first.
|
|
123
|
+
- White is a colour temperature in mirek (`color_temperature.mirek`, 153 cool to 500 warm on most lights), not an `xy`.
|
|
124
|
+
|
|
125
|
+
**What this library does.** `light(name, { color })` and `room(name, { color })` take a hex string or `{ r, g, b }` and send its `xy`, moved inside the light's own gamut; `kelvin` is a white temperature checked against the light's own range, never rounded into it. The state has each light's `kelvin`, `kelvinRange`, `xy`, `gamut` and a `hex` for a screen. Brightness stays separate: a dark red and a bright red are one colour. KO's bridge does report it (2026-11-11): each light's `color.xy`, its `gamut` and `gamut_type`, and `color_temperature.mirek` with the light's own `mirek_minimum` and `mirek_maximum`. The conversions are public maths; Signify's page also carries an iOS SDK extract "as is", which is read for the method and not copied.
|
|
126
|
+
|
|
127
|
+
## Pairing
|
|
128
|
+
|
|
129
|
+
`POST https://<address>/api` with `{ devicetype, generateclientkey: true }`. Until the link button is pressed the bridge answers error 101; for about 30 s after, it answers with the application key. The key keeps working until it is deleted on the bridge. `devicetype` is `app#instance`, at most 40 characters.
|
|
130
|
+
|
|
131
|
+
**What this library does.**
|
|
132
|
+
|
|
133
|
+
- `pair({ host, fetch, deviceType })`: one request. Throws `HueError` with `code: "link_button"` until the button is pressed.
|
|
134
|
+
- `pairWhenPressed({ …, timeoutMs?, everyMs?, onWaiting?, signal? })`: asks every 2 s for 30 s and returns `{ key }`. `onWaiting()` takes nothing and is called once, when the bridge first says the button isn't pressed; if it was already pressed, it is never called. After the time it throws the `link_button` error: nothing was changed on the bridge, so tell the person and ask again.
|
|
135
|
+
- `home pair` uses it with 60 s, names the bridge it found, counts down on a terminal, and prints the env lines. With no press it says so and exits 1.
|
|
136
|
+
- The library never stores the key.
|
|
137
|
+
|
|
138
|
+
A consumer pairing from a chat:
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
import { FINDERS } from "@khalido/home/finders";
|
|
142
|
+
import { bunPinnedFetch, pairWhenPressed } from "@khalido/home/hue";
|
|
143
|
+
|
|
144
|
+
const [bridge] = await FINDERS.hue(); // mDNS: needs avahi-utils on Linux, else throws MissingTool
|
|
145
|
+
if (!bridge) throw new Error("no Hue bridge answered");
|
|
146
|
+
const { key } = await pairWhenPressed({
|
|
147
|
+
host: bridge.host,
|
|
148
|
+
fetch: bunPinnedFetch(bridge.id),
|
|
149
|
+
deviceType: "chota#pi",
|
|
150
|
+
onWaiting: () => say("Press the button on the Hue bridge."),
|
|
151
|
+
});
|
|
152
|
+
// store `key` as a secret; then hue({ host: bridge.host, bridgeId: bridge.id, key })
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`FINDERS.hue()` returns every bridge that answers; this takes the first. A home with two should list them by id and let the person choose. To use the key from a home config, pass it in `env` under the name the config gives (`env: { ...process.env, HUE_APPLICATION_KEY: key }`), and `home.add` the Hue integration that builds ([using.md](using.md) has the three lines).
|
|
156
|
+
|
|
157
|
+
That is Bun. Under Node, `nodePinnedFetch(bridge.id)` from `@khalido/home/node` takes the place of `bunPinnedFetch`.
|
|
158
|
+
|
|
159
|
+
**What others do:** Home Assistant asks the person to press, then makes one request per click of Submit. openHAB asks every 0.5 s for 5 minutes, Homebridge every 15 s with no limit, openhue-cli every second with no limit. Home Assistant and openHAB leave out `generateclientkey`.
|
|
160
|
+
|
|
161
|
+
**Live, 2026-11-11, KO's BSB002 (Bun 1.4.4):** `home pair` gave the `link_button` error after its wait with no press, and a key on the next run once the button was pressed. The key then read the bridge: 177 resources in 286 ms, all parsed (the connector README has the rest).
|
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,
|
|
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
|
|
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,9 +37,121 @@ 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
|
+
|
|
42
|
+
## Every device, the same way
|
|
43
|
+
|
|
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.
|
|
45
|
+
|
|
46
|
+
To drill down, `detail` is the connector's own typed row, and `connector` (with `kind` for Hue) tells TypeScript which:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
for (const d of homeDevices(home.snapshot(), config)) {
|
|
50
|
+
console.log(d.text); // "Kitchen: on · 60%"
|
|
51
|
+
if (d.connector === "cast") d.detail.volume; // CastStatus
|
|
52
|
+
if (d.connector === "hue" && d.kind === "light") d.detail.brightness; // LightView
|
|
53
|
+
if (d.connector === "printers") d.detail.markers; // Printer
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
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.
|
|
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
|
+
|
|
40
61
|
## Writes
|
|
41
62
|
|
|
42
|
-
Writes hang off the integration
|
|
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.
|
|
43
155
|
|
|
44
156
|
## Text for people and agents
|
|
45
157
|
|
package/examples/home.jsonc
CHANGED
|
@@ -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):
|
|
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
|
-
- [
|
|
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.
|
|
3
|
+
"version": "0.2.0",
|
|
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",
|
|
@@ -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
|
|
package/skills/use-home/SKILL.md
CHANGED
|
@@ -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
|
|
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, 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 …`) checks it and prints what it would send, sending 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.
|
|
@@ -29,13 +29,13 @@ The contract (in `docs/cli.md`): stdout is data, stderr everything else. `--json
|
|
|
29
29
|
|
|
30
30
|
What a status means:
|
|
31
31
|
|
|
32
|
-
| Status | Meaning
|
|
33
|
-
| -------------- |
|
|
34
|
-
| `connected` | read and current
|
|
35
|
-
| `connecting` | still starting (a 15 s cap in the CLI)
|
|
36
|
-
| `disconnected` | the device or service did not answer; the last good state is kept, with its `asOf`
|
|
37
|
-
| `unavailable` | stopped for good: a key rejected, a setting wrong
|
|
38
|
-
| `not set up` | env names or a file missing (`needs` lists them)
|
|
32
|
+
| Status | Meaning | What to do |
|
|
33
|
+
| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
|
|
34
|
+
| `connected` | read and current | use it |
|
|
35
|
+
| `connecting` | still starting (a 15 s cap in the CLI) | wait, or run again |
|
|
36
|
+
| `disconnected` | the device or service did not answer; the last good state is kept, with its `asOf` | say so; check the device is on and at its address |
|
|
37
|
+
| `unavailable` | stopped for good: a key rejected, a setting wrong; `reason`, when present, is `auth` (a person must re-key), `config` (a setting or file is wrong) or `crashed` | the `detail` names it; fix the key or setting |
|
|
38
|
+
| `not set up` | env names or a file missing (`needs` lists them) | set them, or say what is needed |
|
|
39
39
|
|
|
40
40
|
Weather: `blocks` holds morning, afternoon and evening for today and tomorrow in the home's time zone; `rainSoon` says when rain starts in the next two hours (needs the Google key). Energy: `now` in `home --json` is the one owner of solar, use, grid flow and cost per hour; never recompute it.
|
|
41
41
|
|
|
@@ -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
|
|
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.
|