@khalido/home 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/.env.example +2 -2
  2. package/CHANGELOG.md +41 -0
  3. package/README.md +7 -2
  4. package/dist/config.d.ts +5 -3
  5. package/dist/config.js +5 -1
  6. package/dist/connectors/cast/index.d.ts +1 -1
  7. package/dist/connectors/cast/index.js +1 -1
  8. package/dist/connectors/cast/state.d.ts +17 -3
  9. package/dist/connectors/cast/state.js +26 -7
  10. package/dist/connectors/daikin/client.d.ts +45 -2
  11. package/dist/connectors/daikin/client.js +67 -2
  12. package/dist/connectors/daikin/index.d.ts +2 -2
  13. package/dist/connectors/daikin/index.js +4 -3
  14. package/dist/connectors/hue/ca.d.ts +2 -0
  15. package/dist/connectors/hue/ca.js +38 -0
  16. package/dist/connectors/hue/client.d.ts +81 -6
  17. package/dist/connectors/hue/client.js +217 -6
  18. package/dist/connectors/hue/color.d.ts +30 -0
  19. package/dist/connectors/hue/color.js +92 -0
  20. package/dist/connectors/hue/index.d.ts +15 -4
  21. package/dist/connectors/hue/index.js +25 -8
  22. package/dist/connectors/hue/schemas.d.ts +27 -0
  23. package/dist/connectors/hue/schemas.js +26 -0
  24. package/dist/connectors/hue/state.d.ts +10 -1
  25. package/dist/connectors/hue/state.js +20 -1
  26. package/dist/connectors/spotify/changes.d.ts +19 -5
  27. package/dist/connectors/spotify/changes.js +29 -7
  28. package/dist/connectors/spotify/index.d.ts +1 -1
  29. package/dist/connectors/spotify/index.js +8 -2
  30. package/dist/devices.d.ts +24 -0
  31. package/dist/devices.js +56 -2
  32. package/dist/discover.js +1 -1
  33. package/dist/finders-entry.d.ts +1 -1
  34. package/dist/finders-entry.js +1 -1
  35. package/dist/finders.d.ts +2 -1
  36. package/dist/finders.js +16 -5
  37. package/dist/home.d.ts +11 -2
  38. package/dist/home.js +98 -34
  39. package/dist/index.d.ts +1 -1
  40. package/dist/index.js +1 -1
  41. package/dist/node-fetch.d.ts +2 -2
  42. package/dist/node-fetch.js +3 -2
  43. package/docs/cli.md +13 -7
  44. package/docs/config.md +11 -3
  45. package/docs/decisions.md +12 -0
  46. package/docs/hue.md +161 -0
  47. package/docs/using.md +98 -3
  48. package/examples/home.jsonc +2 -1
  49. package/home.schema.json +10 -3
  50. package/llms.txt +19 -6
  51. package/package.json +1 -1
  52. package/skills/draw-home/SKILL.md +1 -1
  53. package/skills/use-home/SKILL.md +3 -3
  54. package/src/cli/cast.ts +51 -5
  55. package/src/cli/context.ts +2 -0
  56. package/src/cli/daikin.ts +71 -0
  57. package/src/cli/hue.ts +147 -59
  58. package/src/cli/main.ts +35 -10
  59. package/src/cli/probe.ts +21 -17
  60. package/src/cli/status.ts +61 -10
  61. package/src/config.ts +8 -2
  62. package/src/connectors/cast/README.md +6 -3
  63. package/src/connectors/cast/index.ts +7 -1
  64. package/src/connectors/cast/state.ts +32 -5
  65. package/src/connectors/daikin/README.md +16 -4
  66. package/src/connectors/daikin/client.ts +104 -3
  67. package/src/connectors/daikin/index.ts +8 -2
  68. package/src/connectors/hue/README.md +62 -17
  69. package/src/connectors/hue/ca.ts +39 -0
  70. package/src/connectors/hue/client.ts +362 -15
  71. package/src/connectors/hue/color.ts +123 -0
  72. package/src/connectors/hue/index.ts +44 -11
  73. package/src/connectors/hue/schemas.ts +26 -0
  74. package/src/connectors/hue/state.ts +27 -1
  75. package/src/connectors/spotify/README.md +8 -2
  76. package/src/connectors/spotify/changes.ts +38 -9
  77. package/src/connectors/spotify/index.ts +9 -1
  78. package/src/devices.ts +83 -3
  79. package/src/discover.ts +1 -2
  80. package/src/finders-entry.ts +1 -1
  81. package/src/finders.ts +18 -6
  82. package/src/home.ts +99 -39
  83. package/src/index.ts +7 -1
  84. package/src/node-fetch.ts +6 -2
package/.env.example CHANGED
@@ -4,8 +4,8 @@
4
4
  HUE_BRIDGE_HOST=hue-bridge.local # optional: found by mDNS (dns-sd) when unset
5
5
  HUE_BRIDGE_ID=0017880000000000 # optional: the certificate's CN must match it; mDNS finds it when unset
6
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
7
+ HUE_CA_FILE=/path/to/hue-root-ca.pem # optional: a root CA to trust instead of Signify's two, which the package carries
8
+ DAIKIN_HOST=192.0.2.20 # optional: the unit's address; found by UDP broadcast when unset (1.5 s a run)
9
9
  IQ_GATEWAY_HOST=envoy.local # the IQ Gateway on the LAN (a scheme is tolerated)
10
10
  IQ_GATEWAY_SERIAL_NUMBER=000000000000 # optional: for `home enphase login`; from the Enphase app: System > Devices > Gateway
11
11
  ENPHASE_USERNAME=you@example.com # optional: for `home enphase login`, once a year: the Enphase account
package/CHANGELOG.md CHANGED
@@ -4,6 +4,47 @@ All notable changes to home.ts (`@khalido/home` on npm), in [Keep a Changelog](h
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.2.0] - 2026-11-12
8
+
9
+ ### Breaking
10
+
11
+ - **spotify: music on another Spotify device no longer reads as this speaker playing.** When Soloist says `playing` (or `buffering`) while it isn't the account's active device, `speakerState` now gives `elsewhere` (it gave `playing`), `spotifyText` says `silent · "Song" by Band is playing on another Spotify device`, and `spotifyChanges` treats it as silence: music moving off this speaker is `stopped`, music moving onto it is `started` then `track`, and nothing fires for its tracks or pauses meanwhile (it fired `resumed` with the box silent). `home devices --json` carries the new `state`. What to do: add `elsewhere` wherever you switch on `speakerState`; `state.status` is still what Soloist said.
12
+ - **cli: `home scene <name>` no longer takes the first match** when several rooms have a scene by that name: it lists them and exits 2, and `home scene <name> in <room>` picks one. A wrong or shared room or scene name is a usage error (exit 2), where it was exit 1. What to do: a script that recalls a shared scene name adds `in <room>`.
13
+
14
+ ### Fixed
15
+
16
+ - **spotify:** the stall watch no longer looks at, or repairs, music playing on another device.
17
+
18
+ ### Changed
19
+
20
+ - **cli:** `home room <name> <n>` and `home scene` print the room's line after the write, where they printed nothing.
21
+ - **hue:** a light's line shows its white temperature or colour when on (`Desk: on · 40% · 2700 K`), in `home hue`, `home devices` and `homeDevices`.
22
+ - **docs, for a consumer:** `docs/using.md` has each write and what it resolves with in one table, an announce-where-free example, and what a part's `status` and `reason` and an error's `code` mean. `docs/config.md`'s `loadHome` call now passes what Cast needs (`finders`, `castConnect`). `llms.txt` says what to call.
23
+ - **docs:** `castStatus` and `castFree` take the Cast part's state (`home.part("cast")?.state`), which the docs called "the snapshot".
24
+ - **cli:** `home` and `home daikin` find the Daikin by UDP broadcast when `DAIKIN_HOST` is unset, as the write commands do, so `DAIKIN_HOST` is optional. With no unit on the network it reads "not set up".
25
+ - **cli:** `home cast volume | mute | stop` print the device's line once it reports the change, where they printed nothing; after 2 s without it, "sent; the device has not reported it yet".
26
+ - **cli:** "no route to host" on macOS now adds that the terminal may need the Local Network permission.
27
+
28
+ ### Added
29
+
30
+ - **Dry run on Hue's and the Daikin's writes**: `{ dryRun: true }` as the last argument of `light`, `room`, `scene`, `allOff`, `power` and `mode`. The read and every check made before sending run, and no write is sent; the result's `wouldSend` is the request it would make (`HueRequest`, `DaikinRequest` with `sets`), `state` how things stand, `confirmed: false`. `--dry-run` on the same commands (`home light | room | scene | lights off`, `home hue …`, `home daikin …`); every other command refuses the flag.
31
+ - **hue: `client.allOff()`**: every light in the home off with one write, resolving `{ state, confirmed }` with every light as the bridge then reported it. `home lights off` uses it and now prints the result.
32
+ - **`byRoom(snapshot, config)`** from `@khalido/home`, for a map of the home: each room of the config with its devices, `noData` for a connector the config places there that has nothing to list, and `unplaced` for the rest.
33
+ - **config: `photos` on a room**, a list of paths. The library carries them and reads nothing.
34
+ - **cast: `castFreeSpeakers(state)`**: every free speaker and display in one call, a whole group where all of it is free, never a TV; `free` goes straight to `announceAll`.
35
+ - **cast: `castFree(state, targets, { forms })`**: with `forms: ["speaker", "display"]` a TV is left out (`why: "form"`, only when `forms` is given), and a group holding one becomes its other free members.
36
+ - **`home.add(integration)` and `home.remove(name)`** on the home `createHome` returns: add one while the rest keep running (a bridge paired just now), replace one by name (the old run has ended before the new one starts; how an `unavailable` part comes back after a re-key), or stop one and drop its part. `start` now also waits for those added.
37
+ - **spotify: `playingElsewhere(state)`**, and **`spotifyElsewhere(snapshot)`** from `@khalido/home`: the names of the Cast devices and groups playing that track in Spotify, matched by the track's name since Soloist doesn't say where the music is. Empty for a phone or a laptop. `homeDevices` and `home devices` put it in the speaker's line (`silent · "Song" is playing on Front room speaker`), and `spotifyText(state, on)` takes it. Tested on built snapshots only.
38
+ - **hue: lights, rooms and scenes by name.** `client.light(name, change)`, `client.room(name, change)` and `client.scene(name, room?)`, each resolving `{ state, confirmed }` (`HueApplied`). `change` (`LightChange`) is `{ on?, brightness?, color?, kelvin?, transitionMs? }`: a colour as hex or `{ r, g, b }`, sent as `xy` inside the light's own gamut; a white temperature in kelvin, checked against the light's range. An unknown name or a value the light can't take throws `HueError` with `code: "invalid"` before anything is sent, and a light the bridge has lost throws with `code: "offline"`.
39
+ - **hue: colour in the state.** Each light's `kelvin` and `kelvinRange`, and on a colour light its `xy`, `hex` and `gamut`. `colorToXy`, `xyToHex`, `kelvinToMirek`, `mirekToKelvin` and `hueText` are exported.
40
+ - **`home light <name> …`**, and `home room` and `home scene` now take a brightness, a temperature (`2700k`) and a colour (`ff8800`) together, name a scene's room (`in <room>`), and print what the bridge then reported.
41
+ - **hue: Signify's root certificates are in the package**, as `HUE_BRIDGE_ROOT_CA` from `@khalido/home/hue`: both roots, the bundle from Signify's "Using HTTPS" page. `ca` is now optional everywhere it was required: `hue({ host, bridgeId, key })`, `bunPinnedFetch(bridgeId)`, `nodePinnedFetch(bridgeId)`, the config's `ca` and the CLI's `HUE_CA_FILE`. One given still replaces the bundle. `home probe` reads the bridge without setup, and `home discover --write` no longer writes a `ca` line.
42
+ - **hue: `pairWhenPressed`**: `pair`, asked every 2 s for 30 s until the bridge's button is pressed, with `onWaiting` for the moment to prompt. `pair` takes a `signal`. `home pair` now waits 60 s for the button, counting down, and says when no press was detected.
43
+ - **daikin: `client.power(on)` and `client.mode(mode, targetC?)`**, resolving with `{ state, confirmed }` (`DaikinApplied`) once the unit reports what was asked, or after 5 s (`CONFIRM_MS`) without it. `mode` sends the mode, power on and the setpoint as one request: the one given, else the library's defaults, `DEFAULT_TARGET_C` (cool 25, heat 27).
44
+ - **`home daikin on | off`** (power only, as it was last left) and **`home daikin cool | heat | auto [°C] | dry | fan`**, each printing what the unit then reports.
45
+ - **`MissingTool`** from `@khalido/home/finders`: thrown when the discovery tool (`dns-sd` on macOS, `avahi-browse` on Linux) isn't installed.
46
+ - **`home devices`:** every device the connectors read, one line each from `homeDevices`: its state, its room and name from the config when there is one, and why when it isn't available. `--json` prints the records. A connector that isn't connected is named on stderr.
47
+
7
48
  ## [0.1.3] - 2026-11-10
8
49
 
9
50
  ### Added
package/README.md CHANGED
@@ -13,7 +13,9 @@ Small, typed home automation for TypeScript, starting with the devices in my hou
13
13
  bun add @khalido/home # or: npm install @khalido/home
14
14
  ```
15
15
 
16
- The library runs on Bun 1.4+ and Node 24+, and is ESM only. The `home` command needs Bun.
16
+ The library runs on Bun 1.4+ and Node 24+, and is ESM only. The `home` command needs Bun on your `PATH`; in a project that added the package, run it as `bunx home`.
17
+
18
+ Pointing a coding agent at it? Give it [llms.txt](llms.txt): what to call and where each doc is. [Using it from an app](docs/using.md) is the same for people.
17
19
 
18
20
  ## Try it
19
21
 
@@ -26,6 +28,7 @@ bun link # puts `home` on your PATH
26
28
  home discover # what answers on this network, and which of it this package supports
27
29
  home probe # read-only checks, no keys
28
30
  home # each connector: connected, or what it still needs
31
+ home devices # every device, one line each
29
32
  ```
30
33
 
31
34
  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)).
@@ -66,9 +69,11 @@ Each connector's README says what has run against a real device and what has not
66
69
 
67
70
  ## More
68
71
 
72
+ - [Using it from an app](docs/using.md): the wiring, each write and what it returns
69
73
  - [The `home` command](docs/cli.md) and [the home config](docs/config.md)
74
+ - [Hue](docs/hue.md): Signify's guidance on certificates, pairing and write rates
70
75
  - [Why it is shaped this way](docs/philosophy.md) and [decisions](docs/decisions.md)
71
- - To add a connector, follow [AGENTS.md](AGENTS.md)
76
+ - To add a connector, follow [AGENTS.md](https://github.com/khalido/home.ts/blob/main/AGENTS.md)
72
77
  - For agents: [llms.txt](llms.txt)
73
78
 
74
79
  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.
package/dist/config.d.ts CHANGED
@@ -16,6 +16,7 @@ export declare const Room: z.ZodObject<{
16
16
  name: z.ZodString;
17
17
  floor: z.ZodOptional<z.ZodString>;
18
18
  hueRoom: z.ZodOptional<z.ZodString>;
19
+ photos: z.ZodOptional<z.ZodArray<z.ZodString>>;
19
20
  }, z.core.$loose>;
20
21
  /** 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
22
  export declare function parseJsonc(text: string): unknown;
@@ -25,7 +26,7 @@ export declare const Device: z.ZodDiscriminatedUnion<[z.ZodObject<{
25
26
  host: z.ZodString;
26
27
  bridgeId: z.ZodString;
27
28
  key: z.ZodString;
28
- ca: z.ZodString;
29
+ ca: z.ZodOptional<z.ZodString>;
29
30
  id: z.ZodString;
30
31
  room: z.ZodOptional<z.ZodString>;
31
32
  name: z.ZodOptional<z.ZodString>;
@@ -123,13 +124,14 @@ export declare const HomeConfig: z.ZodObject<{
123
124
  name: z.ZodString;
124
125
  floor: z.ZodOptional<z.ZodString>;
125
126
  hueRoom: z.ZodOptional<z.ZodString>;
127
+ photos: z.ZodOptional<z.ZodArray<z.ZodString>>;
126
128
  }, z.core.$loose>>>;
127
129
  devices: z.ZodOptional<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
128
130
  connector: z.ZodLiteral<"hue">;
129
131
  host: z.ZodString;
130
132
  bridgeId: z.ZodString;
131
133
  key: z.ZodString;
132
- ca: z.ZodString;
134
+ ca: z.ZodOptional<z.ZodString>;
133
135
  id: z.ZodString;
134
136
  room: z.ZodOptional<z.ZodString>;
135
137
  name: z.ZodOptional<z.ZodString>;
@@ -246,7 +248,7 @@ export type LoadOptions = {
246
248
  fetch?: typeof fetch;
247
249
  /** Hue's pinned fetch and Enphase's unverified one: Bun's by default, `NODE_TLS` from `@khalido/home/node` under Node. */
248
250
  tls?: {
249
- hue(bridgeId: string, ca: string): typeof fetch;
251
+ hue(bridgeId: string, ca?: string): typeof fetch;
250
252
  enphase(): typeof fetch;
251
253
  };
252
254
  /** BOM's transport: `ftpFetch()` from `@khalido/home/weather/ftp`. Without it a listed `bom` source stops and says why. */
package/dist/config.js CHANGED
@@ -25,6 +25,10 @@ export const Room = z.looseObject({
25
25
  .string()
26
26
  .optional()
27
27
  .describe("the Hue room, zone or grouped_light id whose lights this room's marker shows"),
28
+ photos: z
29
+ .array(filePath)
30
+ .optional()
31
+ .describe("photos of the room, as paths relative to the config or from ~/; the library never reads them, a drawing agent or an app does"),
28
32
  });
29
33
  /** 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. */
30
34
  export function parseJsonc(text) {
@@ -266,7 +270,7 @@ export function loadHome(input, options) {
266
270
  function one(d, at) {
267
271
  switch (d.connector) {
268
272
  case "hue": {
269
- const ca = file(d.ca);
273
+ const ca = d.ca === undefined ? undefined : file(d.ca);
270
274
  return hue({
271
275
  ...start("hue", d.bridgeId, d.host),
272
276
  bridgeId: d.bridgeId,
@@ -7,7 +7,7 @@ import { type CastConnect } from "./codec.ts";
7
7
  import { type CastDevice, type CastSnapshot, type Resolved, type CastForm, type CastStatus } from "./state.ts";
8
8
  import { type CastActions, type ServeClip } from "./writes.ts";
9
9
  export type { CastConnect } from "./codec.ts";
10
- export { castDevices, castForm, castFree, castStatus } from "./state.ts";
10
+ export { castDevices, castForm, castFree, castFreeSpeakers, castStatus, } from "./state.ts";
11
11
  export type { CastDevice, CastForm, CastSkipped, CastSnapshot, CastStatus, Resolved, } from "./state.ts";
12
12
  export type { AnnounceResult, CastActions, CastClip, ServeClip, } from "./writes.ts";
13
13
  /** A Cast device or speaker group in a home config. No host: a group's moves with its leader, so every address comes from discovery. */
@@ -8,7 +8,7 @@ import { NS } from "./codec.js";
8
8
  import { CastPayload, KNOWN_TYPES } from "./schemas.js";
9
9
  import { castKey, connection, found, mediaApp, reduce, castDevices, castStatus, } from "./state.js";
10
10
  import { castActions, } from "./writes.js";
11
- export { castDevices, castForm, castFree, castStatus } from "./state.js";
11
+ export { castDevices, castForm, castFree, castFreeSpeakers, castStatus, } from "./state.js";
12
12
  /** A Cast device or speaker group in a home config. No host: a group's moves with its leader, so every address comes from discovery. */
13
13
  export const configSchema = z.looseObject({
14
14
  connector: z.literal("cast"),
@@ -97,10 +97,10 @@ export type CastStatus = {
97
97
  };
98
98
  /** Every device and group in the snapshot as a `CastStatus`, devices first, each by name. */
99
99
  export declare function castStatus(s: CastSnapshot): CastStatus[];
100
- /** A target `castFree` left out: `standby` is a TV that is off, which a clip may wake. */
100
+ /** A target `castFree` left out: `standby` is a TV that is off, which a clip may wake; `form` is one whose form wasn't asked for. */
101
101
  export type CastSkipped = {
102
102
  name: string;
103
- why: "busy" | "offline" | "standby" | "unknown";
103
+ why: "busy" | "offline" | "standby" | "unknown" | "form";
104
104
  app?: string;
105
105
  };
106
106
  /**
@@ -111,8 +111,22 @@ export type CastSkipped = {
111
111
  * becomes its free members, named one by one (they then play out of step with each other).
112
112
  * - A device is never named beside a free group that holds it, nor two groups that share a member: the second becomes its
113
113
  * members not yet covered.
114
+ * - With `forms` (`["speaker", "display"]` keeps an announcement off TVs), a device of another form, or of a model this
115
+ * library doesn't know, is skipped with `why: "form"`, and a group holding one (or a member the snapshot doesn't have)
116
+ * becomes its other free members. An empty list leaves every device out.
114
117
  */
115
- export declare function castFree(s: CastSnapshot, targets: string[]): {
118
+ export declare function castFree(s: CastSnapshot, targets: string[], options?: {
119
+ forms?: readonly CastForm[];
120
+ }): {
121
+ free: string[];
122
+ skipped: CastSkipped[];
123
+ };
124
+ /**
125
+ * Where an announcement can play right now, in one call: every speaker and display that is free, a whole group where all
126
+ * of it is, and never a TV. `castFree` over everything Cast has found, with `forms: ["speaker", "display"]`; `free` goes
127
+ * straight to `announceAll`.
128
+ */
129
+ export declare const castFreeSpeakers: (s: CastSnapshot) => {
116
130
  free: string[];
117
131
  skipped: CastSkipped[];
118
132
  };
@@ -216,8 +216,18 @@ export function castStatus(s) {
216
216
  * becomes its free members, named one by one (they then play out of step with each other).
217
217
  * - A device is never named beside a free group that holds it, nor two groups that share a member: the second becomes its
218
218
  * members not yet covered.
219
+ * - With `forms` (`["speaker", "display"]` keeps an announcement off TVs), a device of another form, or of a model this
220
+ * library doesn't know, is skipped with `why: "form"`, and a group holding one (or a member the snapshot doesn't have)
221
+ * becomes its other free members. An empty list leaves every device out.
219
222
  */
220
- export function castFree(s, targets) {
223
+ export function castFree(s, targets, options = {}) {
224
+ /** A device of a form asked for. A group has no form of its own: its members decide. */
225
+ const wanted = (id) => {
226
+ const d = s.devices[id];
227
+ if (!options.forms || d?.kind === "group")
228
+ return true;
229
+ return d?.form !== undefined && options.forms.includes(d.form);
230
+ };
221
231
  const status = new Map(castStatus(s).map((c) => [c.id, c]));
222
232
  const free = new Map();
223
233
  const covered = new Set(); // members of the groups in `free`
@@ -225,14 +235,16 @@ export function castFree(s, targets) {
225
235
  const ok = (c) => (c.state === "idle" || c.state === "restorable") && !c.standby;
226
236
  const skip = (c) => skipped.set(c.id, {
227
237
  name: c.name,
228
- why: c.state === "offline"
229
- ? "offline"
230
- : c.standby && c.state !== "busy"
231
- ? "standby"
232
- : "busy",
238
+ why: !wanted(c.id)
239
+ ? "form"
240
+ : c.state === "offline"
241
+ ? "offline"
242
+ : c.standby && c.state !== "busy"
243
+ ? "standby"
244
+ : "busy",
233
245
  ...(c.app ? { app: c.app } : {}),
234
246
  });
235
- const device = (c) => (ok(c) ? free.set(c.id, c) : skip(c));
247
+ const device = (c) => ok(c) && wanted(c.id) ? free.set(c.id, c) : skip(c);
236
248
  for (const target of targets) {
237
249
  const d = findCast(s.devices, target);
238
250
  const c = d && status.get(d.id);
@@ -248,6 +260,7 @@ export function castFree(s, targets) {
248
260
  skip(c);
249
261
  else if (own !== "offline" &&
250
262
  up.every(ok) &&
263
+ (d.members ?? []).every(wanted) &&
251
264
  !members.some((m) => covered.has(m.id))) {
252
265
  free.set(c.id, c);
253
266
  for (const m of members)
@@ -265,3 +278,9 @@ export function castFree(s, targets) {
265
278
  skipped: [...skipped.values()],
266
279
  };
267
280
  }
281
+ /**
282
+ * Where an announcement can play right now, in one call: every speaker and display that is free, a whole group where all
283
+ * of it is, and never a TV. `castFree` over everything Cast has found, with `forms: ["speaker", "display"]`; `free` goes
284
+ * straight to `announceAll`.
285
+ */
286
+ export const castFreeSpeakers = (s) => castFree(s, castDevices(s).map((d) => d.id), { forms: ["speaker", "display"] });
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import type { Clock } from "../../clock.ts";
2
+ import { type Clock } from "../../clock.ts";
3
3
  import { ConnectorError } from "../../connector.ts";
4
4
  /** A failed Daikin call. `status` is the HTTP status, or the dsiot `rsc` when HTTP succeeded. */
5
5
  export declare class DaikinError extends ConnectorError {
@@ -23,6 +23,41 @@ type Node = {
23
23
  pch?: Node[];
24
24
  };
25
25
  declare const Node: z.ZodType<Node>;
26
+ /**
27
+ * The setpoint a mode gets when the caller names none: this library's opinion (KO's: not too cold, properly warm).
28
+ * Without one, "cool" would mean whatever the remote last left. Auto keeps the unit's own.
29
+ */
30
+ export declare const DEFAULT_TARGET_C: {
31
+ readonly cool: 25;
32
+ readonly heat: 27;
33
+ };
34
+ /**
35
+ * What a write left: the state once the unit reported the change (`confirmed`), or as it stood when `CONFIRM_MS` ran out
36
+ * without it. With `{ dryRun: true }` nothing is sent: `wouldSend` is the request and what it sets, `state` the unit as it
37
+ * reads now, and `confirmed` is false. Check `wouldSend` first.
38
+ */
39
+ export type DaikinApplied = {
40
+ state: DaikinState;
41
+ confirmed: boolean;
42
+ wouldSend?: DaikinRequest;
43
+ };
44
+ /** One write: `POST /dsiot/multireq` with `body`; `sets` is what it changes, in this library's words. */
45
+ export type DaikinRequest = {
46
+ method: "POST";
47
+ path: "/dsiot/multireq";
48
+ body: WriteRequest;
49
+ sets: {
50
+ on?: boolean;
51
+ mode?: Mode;
52
+ targetC?: number;
53
+ };
54
+ };
55
+ /** `dryRun`: every check a write makes (the mode, the setpoint's range), one read for the state, and nothing sent. */
56
+ export type DaikinWriteOptions = {
57
+ dryRun?: boolean;
58
+ };
59
+ /** How long `power` and `mode` wait for the unit to report what was asked, reading once a second. */
60
+ export declare const CONFIRM_MS = 5000;
26
61
  /** Little-endian hex to an integer, two's complement when `signed`: "2700" → 39, "EEFF" → -18. */
27
62
  export declare function hexLE(hex: string, signed?: boolean): number | undefined;
28
63
  /** Map the indoor and outdoor `dgc_status` trees to a state. Unknown codes go to `anomaly`, never to a guess. */
@@ -48,7 +83,7 @@ export declare const setMode: (mode: Mode) => WriteRequest;
48
83
  export declare function setTarget(c: number, mode: Mode): WriteRequest;
49
84
  /** Set the fan speed of `mode` (usually the current one). */
50
85
  export declare function setFan(fan: Fan, mode: Mode): WriteRequest;
51
- /** A client for one unit. Reads are op 2; `write` sends a body built by setPower, setMode, setTarget or setFan. */
86
+ /** A client for one unit. Reads are op 2. `power` and `mode` are the writes most callers want; `write` sends any body built by setPower, setMode, setTarget or setFan. */
52
87
  export declare function daikinClient(options: {
53
88
  host: string;
54
89
  fetch: typeof fetch;
@@ -61,5 +96,13 @@ export declare function daikinClient(options: {
61
96
  * unconfirmed; re-read to be sure). Writes waiting their turn merge (`mergeWrites`) and resolve with the send that carried them.
62
97
  */
63
98
  write(request: WriteRequest): Promise<void>;
99
+ /** Turn the unit on or off, as it was last left. Only when a person asks. Resolves with what the unit then reported. */
100
+ power: (on: boolean, options?: DaikinWriteOptions) => Promise<DaikinApplied>;
101
+ /**
102
+ * Select a mode, turn the unit on and set its setpoint, in one request: `targetC`, else `DEFAULT_TARGET_C` (cool 25,
103
+ * heat 27), else (auto) the unit's own. Dry and fan take none, and a `targetC` for them throws before anything is
104
+ * sent, as one off the unit's range does. Only when a person asks. Resolves with what the unit then reported.
105
+ */
106
+ mode(mode: Mode, targetC?: number, options?: DaikinWriteOptions): Promise<DaikinApplied>;
64
107
  };
65
108
  export {};
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { realClock } from "../../clock.js";
2
3
  import { ConnectorError, getJson } from "../../connector.js";
3
4
  import { writeLane } from "../../lane.js";
4
5
  // Daikin's JSON dsiot API (the BRP084 family): POST /dsiot/multireq, plain HTTP, no key. Field paths and value
@@ -56,6 +57,16 @@ const TARGET_RANGE = {
56
57
  heat: [10, 30],
57
58
  auto: [18, 30],
58
59
  };
60
+ /**
61
+ * The setpoint a mode gets when the caller names none: this library's opinion (KO's: not too cold, properly warm).
62
+ * Without one, "cool" would mean whatever the remote last left. Auto keeps the unit's own.
63
+ */
64
+ export const DEFAULT_TARGET_C = {
65
+ cool: 25,
66
+ heat: 27,
67
+ };
68
+ /** How long `power` and `mode` wait for the unit to report what was asked, reading once a second. */
69
+ export const CONFIRM_MS = 5000;
59
70
  /** Little-endian hex to an integer, two's complement when `signed`: "2700" → 39, "EEFF" → -18. */
60
71
  export function hexLE(hex, signed = false) {
61
72
  if (!/^(?:[0-9a-f]{2})+$/i.test(hex))
@@ -173,7 +184,7 @@ export function setFan(fan, mode) {
173
184
  throw new DaikinError(`${mode} mode has no fan speed`);
174
185
  return write([["e_3001", FAN[mode], key(FANS, fan)]]);
175
186
  }
176
- /** A client for one unit. Reads are op 2; `write` sends a body built by setPower, setMode, setTarget or setFan. */
187
+ /** A client for one unit. Reads are op 2. `power` and `mode` are the writes most callers want; `write` sends any body built by setPower, setMode, setTarget or setFan. */
177
188
  export function daikinClient(options) {
178
189
  const lane = writeLane({
179
190
  gapMs: WRITE_GAP_MS,
@@ -190,7 +201,8 @@ export function daikinClient(options) {
190
201
  schema: Multi,
191
202
  error: DaikinError,
192
203
  });
193
- return {
204
+ const clock = options.clock ?? realClock;
205
+ const client = {
194
206
  /** The indoor and outdoor status in one request. A failed outdoor read leaves `outsideC` empty. */
195
207
  async read(signal) {
196
208
  const m = await multireq({
@@ -218,4 +230,57 @@ export function daikinClient(options) {
218
230
  }, mergeWrites);
219
231
  },
220
232
  };
233
+ /** Send `request`, then read once a second until the unit reports `asked` or `CONFIRM_MS` passes. Rejects if the write is refused, or if no read after it succeeds. */
234
+ async function apply(request, sets, asked, options) {
235
+ if (options?.dryRun)
236
+ return {
237
+ state: await client.read(),
238
+ confirmed: false,
239
+ wouldSend: {
240
+ method: "POST",
241
+ path: "/dsiot/multireq",
242
+ body: request,
243
+ sets,
244
+ },
245
+ };
246
+ await client.write(request);
247
+ const until = clock.now() + CONFIRM_MS;
248
+ let state;
249
+ let failed;
250
+ // The write was accepted, so one dropped read must not turn it into a failure: keep reading until the time is up.
251
+ for (;;) {
252
+ try {
253
+ state = await client.read();
254
+ if (asked(state))
255
+ break;
256
+ }
257
+ catch (error) {
258
+ failed = error;
259
+ }
260
+ if (clock.now() >= until)
261
+ break;
262
+ await clock.sleep(1000);
263
+ }
264
+ if (!state)
265
+ throw failed; // sent, but the unit answered no read since: nothing to report
266
+ return { state, confirmed: asked(state) };
267
+ }
268
+ return {
269
+ ...client,
270
+ /** Turn the unit on or off, as it was last left. Only when a person asks. Resolves with what the unit then reported. */
271
+ power: (on, options) => apply(setPower(on), { on }, (s) => s.on === on, options),
272
+ /**
273
+ * Select a mode, turn the unit on and set its setpoint, in one request: `targetC`, else `DEFAULT_TARGET_C` (cool 25,
274
+ * heat 27), else (auto) the unit's own. Dry and fan take none, and a `targetC` for them throws before anything is
275
+ * sent, as one off the unit's range does. Only when a person asks. Resolves with what the unit then reported.
276
+ */
277
+ async mode(mode, targetC, options) {
278
+ const c = targetC ?? DEFAULT_TARGET_C[mode];
279
+ return apply(c === undefined
280
+ ? setMode(mode)
281
+ : mergeWrites(setMode(mode), setTarget(c, mode)), { on: true, mode, ...(c === undefined ? {} : { targetC: c }) }, (s) => s.on === true &&
282
+ s.mode === mode &&
283
+ (c === undefined || s.targetC === c), options);
284
+ },
285
+ };
221
286
  }
@@ -4,7 +4,7 @@ import { type DeviceRecord, type Integration, type Part } from "../../home.ts";
4
4
  import { type Finder } from "../../discover.ts";
5
5
  import type { Manifest } from "../../manifest.ts";
6
6
  import { daikinClient, type DaikinState, type Mode } from "./client.ts";
7
- export { DaikinError, type DaikinState, daikinClient, type Fan, type Mode, setFan, setMode, setPower, setTarget, type WriteRequest, } from "./client.ts";
7
+ export { CONFIRM_MS, type DaikinApplied, type DaikinRequest, type DaikinWriteOptions, DaikinError, type DaikinState, DEFAULT_TARGET_C, daikinClient, type Fan, type Mode, setFan, setMode, setPower, setTarget, type WriteRequest, } from "./client.ts";
8
8
  /** Poll every 120 s, as Home Assistant's daikin integration does (its coordinator's update_interval). */
9
9
  export declare const POLL_MS = 120000;
10
10
  /** A Daikin AC in a home config: its last known address, and the MAC its UDP discovery reports, which is how it is recognised. */
@@ -30,7 +30,7 @@ export type DaikinSnapshot = DaikinState & {
30
30
  /** What `daikin().client` is: the reads and writes of the unit, at wherever it is now. */
31
31
  export type DaikinClient = ReturnType<typeof daikinClient>;
32
32
  /**
33
- * One Daikin AC as a home integration: one read now, then every 120 s. Its writes are `client.write(setPower(true))`: one
33
+ * One Daikin AC as a home integration: one read now, then every 120 s. Its writes are `client.power(on)` and `client.mode(mode, targetC?)`: one
34
34
  * client for the integration's life, so the writes share its lane and follow the unit when it moves.
35
35
  */
36
36
  export declare function daikin(config: DaikinConfig): Integration<DaikinSnapshot, "daikin"> & {
@@ -4,7 +4,7 @@ import { deviceRecords, } from "../../home.js";
4
4
  import { lostDetail, relocator } from "../../discover.js";
5
5
  import { poller } from "../../poll.js";
6
6
  import { daikinClient, DaikinError, } from "./client.js";
7
- export { DaikinError, daikinClient, setFan, setMode, setPower, setTarget, } from "./client.js";
7
+ export { CONFIRM_MS, DaikinError, DEFAULT_TARGET_C, daikinClient, setFan, setMode, setPower, setTarget, } from "./client.js";
8
8
  /** Poll every 120 s, as Home Assistant's daikin integration does (its coordinator's update_interval). */
9
9
  export const POLL_MS = 120_000;
10
10
  /** A Daikin AC in a home config: its last known address, and the MAC its UDP discovery reports, which is how it is recognised. */
@@ -17,7 +17,7 @@ export const configSchema = z.looseObject({
17
17
  .describe("the unit's MAC as its UDP discovery reports it: its identity"),
18
18
  });
19
19
  /**
20
- * One Daikin AC as a home integration: one read now, then every 120 s. Its writes are `client.write(setPower(true))`: one
20
+ * One Daikin AC as a home integration: one read now, then every 120 s. Its writes are `client.power(on)` and `client.mode(mode, targetC?)`: one
21
21
  * client for the integration's life, so the writes share its lane and follow the unit when it moves.
22
22
  */
23
23
  export function daikin(config) {
@@ -102,7 +102,8 @@ export const manifest = {
102
102
  {
103
103
  name: "DAIKIN_HOST",
104
104
  example: "192.0.2.20",
105
- what: "the unit's address; `home probe` finds it by UDP broadcast",
105
+ optional: true,
106
+ what: "the unit's address; found by UDP broadcast when unset (1.5 s a run)",
106
107
  },
107
108
  ],
108
109
  lines: ({ state }) => (state ? [acText(state)] : []),
@@ -0,0 +1,2 @@
1
+ /** Signify's root certificates for Hue bridges (PEM, two certificates): the default `ca` everywhere one is taken. */
2
+ export declare const HUE_BRIDGE_ROOT_CA = "-----BEGIN CERTIFICATE-----\nMIICMjCCAdigAwIBAgIUO7FSLbaxikuXAljzVaurLXWmFw4wCgYIKoZIzj0EAwIw\nOTELMAkGA1UEBhMCTkwxFDASBgNVBAoMC1BoaWxpcHMgSHVlMRQwEgYDVQQDDAty\nb290LWJyaWRnZTAiGA8yMDE3MDEwMTAwMDAwMFoYDzIwMzgwMTE5MDMxNDA3WjA5\nMQswCQYDVQQGEwJOTDEUMBIGA1UECgwLUGhpbGlwcyBIdWUxFDASBgNVBAMMC3Jv\nb3QtYnJpZGdlMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEjNw2tx2AplOf9x86\naTdvEcL1FU65QDxziKvBpW9XXSIcibAeQiKxegpq8Exbr9v6LBnYbna2VcaK0G22\njOKkTqOBuTCBtjAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAdBgNV\nHQ4EFgQUZ2ONTFrDT6o8ItRnKfqWKnHFGmQwdAYDVR0jBG0wa4AUZ2ONTFrDT6o8\nItRnKfqWKnHFGmShPaQ7MDkxCzAJBgNVBAYTAk5MMRQwEgYDVQQKDAtQaGlsaXBz\nIEh1ZTEUMBIGA1UEAwwLcm9vdC1icmlkZ2WCFDuxUi22sYpLlwJY81Wrqy11phcO\nMAoGCCqGSM49BAMCA0gAMEUCIEBYYEOsa07TH7E5MJnGw557lVkORgit2Rm1h3B2\nsFgDAiEA1Fj/C3AN5psFMjo0//mrQebo0eKd3aWRx+pQY08mk48=\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIIBzDCCAXOgAwIBAgICEAAwCgYIKoZIzj0EAwIwPDELMAkGA1UEBhMCTkwxFDAS\nBgNVBAoMC1NpZ25pZnkgSHVlMRcwFQYDVQQDDA5IdWUgUm9vdCBDQSAwMTAgFw0y\nNTAyMjUwMDAwMDBaGA8yMDUwMTIzMTIzNTk1OVowPDELMAkGA1UEBhMCTkwxFDAS\nBgNVBAoMC1NpZ25pZnkgSHVlMRcwFQYDVQQDDA5IdWUgUm9vdCBDQSAwMTBZMBMG\nByqGSM49AgEGCCqGSM49AwEHA0IABFfOO0jfSAUXGQ9kjEDzyBrcMQ3ItyA5krE+\ncyvb1Y3xFti7KlAad8UOnAx0FBLn7HZrlmIwm1QnX0fK3LPM13mjYzBhMB0GA1Ud\nDgQWBBTF1pSpsCASX/z0VHLigxU2CAaqoTAfBgNVHSMEGDAWgBTF1pSpsCASX/z0\nVHLigxU2CAaqoTAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAKBggq\nhkjOPQQDAgNHADBEAiAk7duT+IHbOGO4UUuGLAEpyYejGZK9Z7V9oSfnvuQ5BQIg\nIYSgwwxHXm73/JgcU9lAM6c8Bmu3UE3kBIUwBs1qXFw=\n-----END CERTIFICATE-----\n";
@@ -0,0 +1,38 @@
1
+ // Signify's two root certificates for Hue bridges, so pinning needs no file: `bunPinnedFetch(bridgeId)` and
2
+ // `nodePinnedFetch(bridgeId)` trust these and nothing else. Public certificates, as Signify's "Using HTTPS" page gives
3
+ // them (login-gated); these bytes are the ones openHAB, hb-hue-tools, openhue-go and Raycast's Hue extension ship.
4
+ //
5
+ // CN=root-bridge, O=Philips Hue to 2038-01-19 the one in use: KO's square bridge (BSB002) chains to it
6
+ // SHA-256 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
7
+ // CN=Hue Root CA 01, O=Signify Hue to 2050-12-31 Signify's spare, likely behind an intermediate: untried here
8
+ // SHA-256 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
9
+ //
10
+ // test/hue.test.ts checks both fingerprints, so an edit here fails.
11
+ /** Signify's root certificates for Hue bridges (PEM, two certificates): the default `ca` everywhere one is taken. */
12
+ export const HUE_BRIDGE_ROOT_CA = `-----BEGIN CERTIFICATE-----
13
+ MIICMjCCAdigAwIBAgIUO7FSLbaxikuXAljzVaurLXWmFw4wCgYIKoZIzj0EAwIw
14
+ OTELMAkGA1UEBhMCTkwxFDASBgNVBAoMC1BoaWxpcHMgSHVlMRQwEgYDVQQDDAty
15
+ b290LWJyaWRnZTAiGA8yMDE3MDEwMTAwMDAwMFoYDzIwMzgwMTE5MDMxNDA3WjA5
16
+ MQswCQYDVQQGEwJOTDEUMBIGA1UECgwLUGhpbGlwcyBIdWUxFDASBgNVBAMMC3Jv
17
+ b3QtYnJpZGdlMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEjNw2tx2AplOf9x86
18
+ aTdvEcL1FU65QDxziKvBpW9XXSIcibAeQiKxegpq8Exbr9v6LBnYbna2VcaK0G22
19
+ jOKkTqOBuTCBtjAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAdBgNV
20
+ HQ4EFgQUZ2ONTFrDT6o8ItRnKfqWKnHFGmQwdAYDVR0jBG0wa4AUZ2ONTFrDT6o8
21
+ ItRnKfqWKnHFGmShPaQ7MDkxCzAJBgNVBAYTAk5MMRQwEgYDVQQKDAtQaGlsaXBz
22
+ IEh1ZTEUMBIGA1UEAwwLcm9vdC1icmlkZ2WCFDuxUi22sYpLlwJY81Wrqy11phcO
23
+ MAoGCCqGSM49BAMCA0gAMEUCIEBYYEOsa07TH7E5MJnGw557lVkORgit2Rm1h3B2
24
+ sFgDAiEA1Fj/C3AN5psFMjo0//mrQebo0eKd3aWRx+pQY08mk48=
25
+ -----END CERTIFICATE-----
26
+ -----BEGIN CERTIFICATE-----
27
+ MIIBzDCCAXOgAwIBAgICEAAwCgYIKoZIzj0EAwIwPDELMAkGA1UEBhMCTkwxFDAS
28
+ BgNVBAoMC1NpZ25pZnkgSHVlMRcwFQYDVQQDDA5IdWUgUm9vdCBDQSAwMTAgFw0y
29
+ NTAyMjUwMDAwMDBaGA8yMDUwMTIzMTIzNTk1OVowPDELMAkGA1UEBhMCTkwxFDAS
30
+ BgNVBAoMC1NpZ25pZnkgSHVlMRcwFQYDVQQDDA5IdWUgUm9vdCBDQSAwMTBZMBMG
31
+ ByqGSM49AgEGCCqGSM49AwEHA0IABFfOO0jfSAUXGQ9kjEDzyBrcMQ3ItyA5krE+
32
+ cyvb1Y3xFti7KlAad8UOnAx0FBLn7HZrlmIwm1QnX0fK3LPM13mjYzBhMB0GA1Ud
33
+ DgQWBBTF1pSpsCASX/z0VHLigxU2CAaqoTAfBgNVHSMEGDAWgBTF1pSpsCASX/z0
34
+ VHLigxU2CAaqoTAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAKBggq
35
+ hkjOPQQDAgNHADBEAiAk7duT+IHbOGO4UUuGLAEpyYejGZK9Z7V9oSfnvuQ5BQIg
36
+ IYSgwwxHXm73/JgcU9lAM6c8Bmu3UE3kBIUwBs1qXFw=
37
+ -----END CERTIFICATE-----
38
+ `;