@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/.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 #
|
|
8
|
-
DAIKIN_HOST=192.0.2.20 # the unit's address;
|
|
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
|
@@ -1,9 +1,80 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
All notable changes to home.ts (`@khalido/home` on npm), in [Keep a Changelog](https://keepachangelog.com/) form. Versions follow [SemVer](https://semver.org) at 0.x: a minor release can break what a consumer calls, a patch can't.
|
|
3
|
+
All notable changes to home.ts (`@khalido/home` on npm), in [Keep a Changelog](https://keepachangelog.com/) form. Versions follow [SemVer](https://semver.org) at 0.x: a minor release can break what a consumer calls, a patch can't. A release that breaks something opens with a **Breaking** heading, so a consumer on `latest` sees it first.
|
|
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
|
+
|
|
48
|
+
## [0.1.3] - 2026-11-10
|
|
49
|
+
|
|
50
|
+
### Added
|
|
51
|
+
|
|
52
|
+
- **`Part.reason`:** why a part is `unavailable`, for code to switch on: `auth` (a key or token was rejected or unusable, so a person must re-key), `config` (a setting or file is wrong) or `crashed` (its `run` threw). `health()` and `home --json` carry it. It can be absent. Hue, Enphase, Amber and Up set `auth` on a rejected key, and so does an Enphase token that does not parse. In `home status`, a connector that fails to build from the environment says `auth` or `config`.
|
|
53
|
+
- **`homeDevices(snapshot, config?)`:** every device in one list (Amber, Up and the weather read services and list nothing), each answering the same questions (`connector`, `id`, `name`, `kind`, `available`, `reason`, `asOf`, `state`, `text`, `room`, `members`), with the connector's own typed row in `detail` and `connector` narrowing its type. Each device connector also exports its own: `hueHomeDevices`, `daikinHomeDevices`, `enphaseHomeDevices`, `castHomeDevices`, `printersHomeDevices`, `spotifyHomeDevices`. `DeviceRecord` is the common shape and `deviceRecords(part, records)` what a connector returns them through: while its part isn't connected, none is `available`.
|
|
54
|
+
- **`ConnectorCode`:** the type of `ConnectorError.code`, every code the library sets, so a consumer's table of messages fails to compile when one is added.
|
|
55
|
+
- **cast:** `castStatus(snapshot)` says what each device and group is doing (`idle`, `restorable`, `busy` or `offline`, with the app, title and volume), and `castFree(snapshot, targets)` gives the targets an announcement would not disturb, with the ones it `skipped` and why. For announcing only where nothing is playing.
|
|
56
|
+
- **weather:** `reason` on an `unavailable` weather part (`auth` if a stopped source's key was rejected, else `config`), and `state.sources.<name>.reason` beside `stopped`, so code can see a dead Google key while another source keeps the part `connected`. A custom `WeatherSource` can return `stopReason` with `stop`.
|
|
57
|
+
|
|
58
|
+
### Changed
|
|
59
|
+
|
|
60
|
+
- **`ConnectorError.code`** is typed `ConnectorCode`, no longer `string`. Reading it is unchanged. Only code that builds a `ConnectorError` with a code outside that set stops compiling; chota-bot and jaldi don't.
|
|
61
|
+
- **hue:** a room or zone the bridge gives no on or off for prints its name alone in `home hue`, no longer "off".
|
|
62
|
+
- **weather:** Google's key is sent in the `X-Goog-Api-Key` header, no longer in the URL.
|
|
63
|
+
- **weather:** Google's stop text says which it was: `google: the key was rejected (HTTP 400 API_KEY_INVALID)` or `google: the request was refused (HTTP 400); check the latitude and longitude`. Before, both read `google: key rejected or request refused (HTTP 400)`.
|
|
64
|
+
|
|
65
|
+
### Fixed
|
|
66
|
+
|
|
67
|
+
- **cast:** `announce` to a group with a `volume` left the group's own level at the announcement's, though every member's was put back. The group's is now restored too, before its members', so theirs land last.
|
|
68
|
+
- **spotify:** `interrupt` paused the music when the account was playing on another speaker (the box reports `playing` while not the active device). It now leaves it alone and returns `music: "untouched"`, as `duck` does.
|
|
69
|
+
- **spotify:** `maxVolume` gave up when a lowering was refused or unanswered after a second start signal (buffering, then playing) had opened a new window, leaving the volume above the cap. The new window now lowers again.
|
|
70
|
+
- **`createHome`:** an `onChange` that throws or rejects no longer marks the integration `unavailable`; the error goes to `console.error` and the integration keeps running.
|
|
71
|
+
- **`createHome`:** `refresh()` before `start()` resolves at once instead of never.
|
|
72
|
+
- **`createHome`:** an integration whose `run` fails after a good report keeps its last `state`.
|
|
73
|
+
- **`createHome`:** a `run` that throws synchronously marks that integration `unavailable`; the others still start.
|
|
74
|
+
- **`home <connector> --config`** needs only that connector's keys, and runs only that connector.
|
|
75
|
+
- **`realClock.sleep`** resolves at once on an already-aborted signal.
|
|
76
|
+
- **Docs:** the `readFile` in `docs/config.md` expands `~/`, which `home discover --write` puts in Enphase's `token`. The README says the package is ESM only.
|
|
77
|
+
|
|
7
78
|
## [0.1.2] - 2026-11-09
|
|
8
79
|
|
|
9
80
|
From chota-bot's live writes on a Raspberry Pi. `setVolume` on an inactive speaker now rejects, and `track` events can come later than before.
|
|
@@ -68,7 +139,8 @@ First release. Nine connectors: Hue, Daikin, Enphase, Google Cast, AirPrint prin
|
|
|
68
139
|
- **Agent skills** in `skills/`: `use-home` (reading safely, connecting, the rules for writes) and `draw-home` (an SVG plan of a home and its rooms). `llms.txt` points agents at the docs.
|
|
69
140
|
- **Node.** Hue and Enphase take the fetches in `@khalido/home/node`. The library runs on Bun 1.4+ and Node 24+; the `home` command needs Bun.
|
|
70
141
|
|
|
71
|
-
[Unreleased]: https://github.com/khalido/home.ts/compare/v0.1.
|
|
142
|
+
[Unreleased]: https://github.com/khalido/home.ts/compare/v0.1.3...HEAD
|
|
143
|
+
[0.1.3]: https://github.com/khalido/home.ts/compare/v0.1.2...v0.1.3
|
|
72
144
|
[0.1.2]: https://github.com/khalido/home.ts/compare/v0.1.1...v0.1.2
|
|
73
145
|
[0.1.1]: https://github.com/khalido/home.ts/compare/v0.1.0...v0.1.1
|
|
74
146
|
[0.1.0]: https://github.com/khalido/home.ts/releases/tag/v0.1.0
|
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
|
|
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/clock.js
CHANGED
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
|
|
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,
|
package/dist/connector.d.ts
CHANGED
|
@@ -6,13 +6,20 @@ export declare const TIMEOUT_MS = 10000;
|
|
|
6
6
|
* Node's fetch puts the reason there: "fetch failed: connect ECONNREFUSED 192.0.2.10:443".
|
|
7
7
|
*/
|
|
8
8
|
export declare function errorText(error: unknown): string;
|
|
9
|
+
/**
|
|
10
|
+
* Every `code` a `ConnectorError` from this library carries, so a consumer's table of messages fails to compile when one is added.
|
|
11
|
+
* The spotify connector's writes: `offline` (not connected), `waiting` (nobody signed in), `inactive` (not the active device), `refused`
|
|
12
|
+
* (the device said no), `timeout` (no answer), `invalid` (bad input, nothing sent). `link_button`: Hue wants its button
|
|
13
|
+
* pressed. `ipp`: a printer answered with an IPP error, its code in `status`.
|
|
14
|
+
*/
|
|
15
|
+
export type ConnectorCode = "offline" | "waiting" | "inactive" | "refused" | "timeout" | "invalid" | "link_button" | "ipp";
|
|
9
16
|
/** A failed device or service call. `status` is the HTTP status (or a device's own code); `code` a connector's own reason. */
|
|
10
17
|
export declare class ConnectorError extends Error {
|
|
11
18
|
readonly status?: number;
|
|
12
|
-
readonly code?:
|
|
19
|
+
readonly code?: ConnectorCode;
|
|
13
20
|
constructor(message: string, options?: {
|
|
14
21
|
status?: number;
|
|
15
|
-
code?:
|
|
22
|
+
code?: ConnectorCode;
|
|
16
23
|
cause?: unknown;
|
|
17
24
|
});
|
|
18
25
|
}
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { type Clock } from "../../clock.ts";
|
|
3
3
|
import { type Found } from "../../discover.ts";
|
|
4
|
-
import type
|
|
4
|
+
import { type DeviceRecord, type Integration, type Part } from "../../home.ts";
|
|
5
5
|
import type { Manifest } from "../../manifest.ts";
|
|
6
6
|
import { type CastConnect } from "./codec.ts";
|
|
7
|
-
import { type CastDevice, type CastSnapshot, type Resolved } from "./state.ts";
|
|
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 { castForm } from "./state.ts";
|
|
11
|
-
export type { CastDevice, CastForm, CastSnapshot, Resolved } from "./state.ts";
|
|
10
|
+
export { castDevices, castForm, castFree, castFreeSpeakers, castStatus, } from "./state.ts";
|
|
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. */
|
|
14
14
|
export declare const configSchema: z.ZodObject<{
|
|
@@ -58,6 +58,8 @@ export type CastConfig = {
|
|
|
58
58
|
export declare function cast(config: CastConfig): Integration<CastSnapshot, "cast"> & CastActions;
|
|
59
59
|
/** One Cast device or group in a few words: "Nest Hub: idle · 47%", "Group: idle · 43% · 3 members: A, B, C, led by A". */
|
|
60
60
|
export declare function castText(d: CastDevice, s: CastSnapshot): string;
|
|
61
|
-
/**
|
|
62
|
-
export
|
|
61
|
+
/** A Cast device or group as a home device: `kind` is its form (`cast` for a model it doesn't know) or `group`; `detail` its `CastStatus`. */
|
|
62
|
+
export type CastHomeDevice = DeviceRecord<"cast", CastForm | "cast" | "group", CastStatus["state"], CastStatus>;
|
|
63
|
+
/** Every Cast device and group as a home device, devices first, each by name. */
|
|
64
|
+
export declare function castHomeDevices(part: Part<CastSnapshot> | undefined): CastHomeDevice[];
|
|
63
65
|
export declare const manifest: Manifest<CastSnapshot>;
|
|
@@ -3,11 +3,12 @@ import { asOf, realClock } from "../../clock.js";
|
|
|
3
3
|
import { errorText } from "../../connector.js";
|
|
4
4
|
import { HOUR, lostDetail, rescanDelay } from "../../discover.js";
|
|
5
5
|
import { poller } from "../../poll.js";
|
|
6
|
+
import { deviceRecords, } from "../../home.js";
|
|
6
7
|
import { NS } from "./codec.js";
|
|
7
8
|
import { CastPayload, KNOWN_TYPES } from "./schemas.js";
|
|
8
|
-
import { castKey, connection, found, mediaApp, reduce, } from "./state.js";
|
|
9
|
+
import { castKey, connection, found, mediaApp, reduce, castDevices, castStatus, } from "./state.js";
|
|
9
10
|
import { castActions, } from "./writes.js";
|
|
10
|
-
export { castForm } from "./state.js";
|
|
11
|
+
export { castDevices, castForm, castFree, castFreeSpeakers, castStatus, } from "./state.js";
|
|
11
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. */
|
|
12
13
|
export const configSchema = z.looseObject({
|
|
13
14
|
connector: z.literal("cast"),
|
|
@@ -411,8 +412,33 @@ export function castText(d, s) {
|
|
|
411
412
|
];
|
|
412
413
|
return `${d.name}: ${parts.filter(Boolean).join(" · ")}`;
|
|
413
414
|
}
|
|
414
|
-
/**
|
|
415
|
-
export
|
|
415
|
+
/** Every Cast device and group as a home device, devices first, each by name. */
|
|
416
|
+
export function castHomeDevices(part) {
|
|
417
|
+
const s = part?.state;
|
|
418
|
+
if (!s)
|
|
419
|
+
return [];
|
|
420
|
+
return deviceRecords(part, castStatus(s).map((detail) => {
|
|
421
|
+
const d = s.devices[detail.id];
|
|
422
|
+
// Connected and heard from: available. Connecting, or connected with no status yet: not known.
|
|
423
|
+
const available = d.status === "disconnected"
|
|
424
|
+
? false
|
|
425
|
+
: d.status === "connected" && detail.state !== "offline"
|
|
426
|
+
? true
|
|
427
|
+
: undefined;
|
|
428
|
+
return {
|
|
429
|
+
connector: "cast",
|
|
430
|
+
id: d.id,
|
|
431
|
+
name: d.name,
|
|
432
|
+
kind: d.kind === "group" ? "group" : (d.form ?? "cast"),
|
|
433
|
+
...(available === undefined ? {} : { available }),
|
|
434
|
+
...(d.asOf ? { asOf: d.asOf } : {}), // never answered: no time to give
|
|
435
|
+
state: detail.state,
|
|
436
|
+
text: castText(d, s),
|
|
437
|
+
...(d.members ? { members: d.members } : {}),
|
|
438
|
+
detail,
|
|
439
|
+
};
|
|
440
|
+
}));
|
|
441
|
+
}
|
|
416
442
|
/** How many entries have an address, and one line per device and group. */
|
|
417
443
|
const castView = (s) => ({
|
|
418
444
|
found: Object.values(s.devices).filter((d) => d.host).length,
|
|
@@ -64,3 +64,69 @@ export declare const mediaApp: (app?: {
|
|
|
64
64
|
}[];
|
|
65
65
|
transportId?: string;
|
|
66
66
|
}) => boolean;
|
|
67
|
+
/** Google's own player for a URL of audio or video: what `announce` plays a clip with, and can put back. */
|
|
68
|
+
export declare const DEFAULT_MEDIA_RECEIVER = "CC1AD845";
|
|
69
|
+
/** Devices first, then groups, each by name. */
|
|
70
|
+
export declare const castDevices: (s: CastSnapshot) => CastDevice[];
|
|
71
|
+
/** A target is a castId, else a name in any case. `announce` and `castFree` find it the same way. */
|
|
72
|
+
export declare const findCast: (devices: Record<string, CastDevice>, target: string) => CastDevice | undefined;
|
|
73
|
+
/**
|
|
74
|
+
* What a device or group is doing, for a caller choosing where to play something. `state`:
|
|
75
|
+
* - `idle`: no app, or the idle screen.
|
|
76
|
+
* - `restorable`: the Default Media Receiver, whose clip `announce` puts back at its position.
|
|
77
|
+
* - `busy`: another app, playing or paused. An announce ends it and nothing here can resume it.
|
|
78
|
+
* - `offline`: not connected, or connected and not heard from yet.
|
|
79
|
+
* A member playing its group's media takes the group's state, with `via` naming the group and `app` the group's. A group
|
|
80
|
+
* is as free as its least free connected member. `standby` is a TV that is off or on another input: `idle` by its app,
|
|
81
|
+
* but a clip may wake it.
|
|
82
|
+
*/
|
|
83
|
+
export type CastStatus = {
|
|
84
|
+
id: string;
|
|
85
|
+
name: string;
|
|
86
|
+
kind: "device" | "group";
|
|
87
|
+
state: "idle" | "restorable" | "busy" | "offline";
|
|
88
|
+
app?: string;
|
|
89
|
+
playerState?: string;
|
|
90
|
+
title?: string;
|
|
91
|
+
artist?: string;
|
|
92
|
+
via?: string;
|
|
93
|
+
volume?: number;
|
|
94
|
+
muted?: boolean;
|
|
95
|
+
standby?: boolean;
|
|
96
|
+
members?: CastStatus[];
|
|
97
|
+
};
|
|
98
|
+
/** Every device and group in the snapshot as a `CastStatus`, devices first, each by name. */
|
|
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; `form` is one whose form wasn't asked for. */
|
|
101
|
+
export type CastSkipped = {
|
|
102
|
+
name: string;
|
|
103
|
+
why: "busy" | "offline" | "standby" | "unknown" | "form";
|
|
104
|
+
app?: string;
|
|
105
|
+
};
|
|
106
|
+
/**
|
|
107
|
+
* Of these targets (names in any case, or ids), the ones an announcement would not disturb: `idle` or `restorable`, and
|
|
108
|
+
* not a TV in standby. `free` is names, for `announceAll`; `skipped` says which were left out and why.
|
|
109
|
+
* - A group stays a group while every connected member is free. An offline member doesn't split it: the group plays without it.
|
|
110
|
+
* - A group playing another app is skipped whole. Otherwise a group with a busy member, or one whose own socket is down,
|
|
111
|
+
* becomes its free members, named one by one (they then play out of step with each other).
|
|
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
|
+
* 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.
|
|
117
|
+
*/
|
|
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) => {
|
|
130
|
+
free: string[];
|
|
131
|
+
skipped: CastSkipped[];
|
|
132
|
+
};
|
|
@@ -143,3 +143,144 @@ function link(s) {
|
|
|
143
143
|
delete d.playingVia;
|
|
144
144
|
return { devices, asOf: s.asOf };
|
|
145
145
|
}
|
|
146
|
+
/** Google's own player for a URL of audio or video: what `announce` plays a clip with, and can put back. */
|
|
147
|
+
export const DEFAULT_MEDIA_RECEIVER = "CC1AD845";
|
|
148
|
+
/** Devices first, then groups, each by name. */
|
|
149
|
+
export const castDevices = (s) => Object.values(s.devices).sort((a, b) => a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name));
|
|
150
|
+
/** A target is a castId, else a name in any case. `announce` and `castFree` find it the same way. */
|
|
151
|
+
export const findCast = (devices, target) => {
|
|
152
|
+
const all = Object.values(devices);
|
|
153
|
+
return (all.find((x) => x.id === castKey(target)) ??
|
|
154
|
+
all.find((x) => x.name.toLowerCase() === target.toLowerCase()));
|
|
155
|
+
};
|
|
156
|
+
const RANK = { idle: 0, restorable: 1, busy: 2, offline: 3 };
|
|
157
|
+
/** By its own app alone: not its group's media, not its members. */
|
|
158
|
+
const ownState = (d) => d.status !== "connected" || d.idle === undefined
|
|
159
|
+
? "offline"
|
|
160
|
+
: d.idle
|
|
161
|
+
? "idle"
|
|
162
|
+
: d.appId === DEFAULT_MEDIA_RECEIVER
|
|
163
|
+
? "restorable"
|
|
164
|
+
: "busy";
|
|
165
|
+
/** Every device and group in the snapshot as a `CastStatus`, devices first, each by name. */
|
|
166
|
+
export function castStatus(s) {
|
|
167
|
+
const one = (d) => {
|
|
168
|
+
const group = d.playingVia ? s.devices[d.playingVia] : undefined;
|
|
169
|
+
const own = ownState(d);
|
|
170
|
+
// Its group's media is playing on it: the group's app decides, and an unknown one counts as busy.
|
|
171
|
+
const self = own === "offline" || !d.playingVia
|
|
172
|
+
? own
|
|
173
|
+
: group && ownState(group) === "restorable"
|
|
174
|
+
? "restorable"
|
|
175
|
+
: "busy";
|
|
176
|
+
const members = d.kind === "group"
|
|
177
|
+
? (d.members ?? []).flatMap((id) => s.devices[id] ? [one(s.devices[id])] : [])
|
|
178
|
+
: undefined;
|
|
179
|
+
const state = self === "offline"
|
|
180
|
+
? self
|
|
181
|
+
: (members ?? [])
|
|
182
|
+
.map((m) => m.state)
|
|
183
|
+
.filter((m) => m !== "offline")
|
|
184
|
+
.reduce((a, b) => (RANK[b] > RANK[a] ? b : a), self);
|
|
185
|
+
const status = {
|
|
186
|
+
id: d.id,
|
|
187
|
+
name: d.name,
|
|
188
|
+
kind: d.kind,
|
|
189
|
+
state,
|
|
190
|
+
app: d.idle === false
|
|
191
|
+
? d.app
|
|
192
|
+
: group?.idle === false
|
|
193
|
+
? group.app
|
|
194
|
+
: undefined,
|
|
195
|
+
playerState: d.playerState,
|
|
196
|
+
title: d.title,
|
|
197
|
+
artist: d.artist,
|
|
198
|
+
via: d.playingVia && (group?.name ?? d.playingVia),
|
|
199
|
+
volume: d.volume,
|
|
200
|
+
muted: d.muted,
|
|
201
|
+
standby: d.standby,
|
|
202
|
+
members,
|
|
203
|
+
};
|
|
204
|
+
for (const k of Object.keys(status))
|
|
205
|
+
if (status[k] === undefined)
|
|
206
|
+
delete status[k];
|
|
207
|
+
return status;
|
|
208
|
+
};
|
|
209
|
+
return castDevices(s).map(one);
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Of these targets (names in any case, or ids), the ones an announcement would not disturb: `idle` or `restorable`, and
|
|
213
|
+
* not a TV in standby. `free` is names, for `announceAll`; `skipped` says which were left out and why.
|
|
214
|
+
* - A group stays a group while every connected member is free. An offline member doesn't split it: the group plays without it.
|
|
215
|
+
* - A group playing another app is skipped whole. Otherwise a group with a busy member, or one whose own socket is down,
|
|
216
|
+
* becomes its free members, named one by one (they then play out of step with each other).
|
|
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
|
+
* 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.
|
|
222
|
+
*/
|
|
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
|
+
};
|
|
231
|
+
const status = new Map(castStatus(s).map((c) => [c.id, c]));
|
|
232
|
+
const free = new Map();
|
|
233
|
+
const covered = new Set(); // members of the groups in `free`
|
|
234
|
+
const skipped = new Map();
|
|
235
|
+
const ok = (c) => (c.state === "idle" || c.state === "restorable") && !c.standby;
|
|
236
|
+
const skip = (c) => skipped.set(c.id, {
|
|
237
|
+
name: c.name,
|
|
238
|
+
why: !wanted(c.id)
|
|
239
|
+
? "form"
|
|
240
|
+
: c.state === "offline"
|
|
241
|
+
? "offline"
|
|
242
|
+
: c.standby && c.state !== "busy"
|
|
243
|
+
? "standby"
|
|
244
|
+
: "busy",
|
|
245
|
+
...(c.app ? { app: c.app } : {}),
|
|
246
|
+
});
|
|
247
|
+
const device = (c) => ok(c) && wanted(c.id) ? free.set(c.id, c) : skip(c);
|
|
248
|
+
for (const target of targets) {
|
|
249
|
+
const d = findCast(s.devices, target);
|
|
250
|
+
const c = d && status.get(d.id);
|
|
251
|
+
if (!d || !c)
|
|
252
|
+
skipped.set(target, { name: target, why: "unknown" });
|
|
253
|
+
else if (c.kind === "device")
|
|
254
|
+
device(c);
|
|
255
|
+
else {
|
|
256
|
+
const members = c.members ?? [];
|
|
257
|
+
const own = ownState(d);
|
|
258
|
+
const up = members.filter((m) => m.state !== "offline");
|
|
259
|
+
if (own === "busy" || (own === "offline" && !members.length))
|
|
260
|
+
skip(c);
|
|
261
|
+
else if (own !== "offline" &&
|
|
262
|
+
up.every(ok) &&
|
|
263
|
+
(d.members ?? []).every(wanted) &&
|
|
264
|
+
!members.some((m) => covered.has(m.id))) {
|
|
265
|
+
free.set(c.id, c);
|
|
266
|
+
for (const m of members)
|
|
267
|
+
covered.add(m.id);
|
|
268
|
+
}
|
|
269
|
+
else
|
|
270
|
+
for (const m of members)
|
|
271
|
+
device(m);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return {
|
|
275
|
+
free: [...free.values()]
|
|
276
|
+
.filter((c) => c.kind === "group" || !covered.has(c.id))
|
|
277
|
+
.map((c) => c.name),
|
|
278
|
+
skipped: [...skipped.values()],
|
|
279
|
+
};
|
|
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,7 +1,5 @@
|
|
|
1
1
|
import type { Clock } from "../../clock.ts";
|
|
2
2
|
import { type CastDevice } from "./state.ts";
|
|
3
|
-
/** Google's own player for a URL of audio or video. */
|
|
4
|
-
export declare const DEFAULT_MEDIA_RECEIVER = "CC1AD845";
|
|
5
3
|
/** Each request (a status, a volume, LAUNCH, LOAD, STOP) gets this long to answer. */
|
|
6
4
|
export declare const ANSWER_MS = 10000;
|
|
7
5
|
/** How long an announcement waits for its clip to finish, beyond the clip's own length when the device reports it. */
|
|
@@ -2,11 +2,9 @@ import { errorText } from "../../connector.js";
|
|
|
2
2
|
import { writeLane } from "../../lane.js";
|
|
3
3
|
import { NS } from "./codec.js";
|
|
4
4
|
import { MediaStatus, MultizoneStatus, ReceiverStatus, } from "./schemas.js";
|
|
5
|
-
import { castKey } from "./state.js";
|
|
5
|
+
import { castKey, DEFAULT_MEDIA_RECEIVER, findCast, } from "./state.js";
|
|
6
6
|
// Writes over the sockets `cast()` already holds: volume, mute, stop, and an announcement that plays a clip and puts
|
|
7
|
-
// the device back as it was. Never called by the integration itself;
|
|
8
|
-
/** Google's own player for a URL of audio or video. */
|
|
9
|
-
export const DEFAULT_MEDIA_RECEIVER = "CC1AD845";
|
|
7
|
+
// the device back as it was. Never called by the integration itself; announce, volume and mute have run on real devices (the README has the dates).
|
|
10
8
|
/** Each request (a status, a volume, LAUNCH, LOAD, STOP) gets this long to answer. */
|
|
11
9
|
export const ANSWER_MS = 10_000;
|
|
12
10
|
/** How long an announcement waits for its clip to finish, beyond the clip's own length when the device reports it. */
|
|
@@ -83,8 +81,7 @@ export function castActions(ctx) {
|
|
|
83
81
|
const { clock } = ctx;
|
|
84
82
|
const find = (target) => {
|
|
85
83
|
const all = Object.values(ctx.devices());
|
|
86
|
-
const d =
|
|
87
|
-
all.find((x) => x.name.toLowerCase() === target.toLowerCase());
|
|
84
|
+
const d = findCast(ctx.devices(), target);
|
|
88
85
|
if (!d)
|
|
89
86
|
throw new Error(`no Cast device or group "${target}"; have: ${all
|
|
90
87
|
.map((x) => x.name)
|
|
@@ -244,7 +241,7 @@ export function castActions(ctx) {
|
|
|
244
241
|
/**
|
|
245
242
|
* Read the target's state, set the volume if asked and unmute a muted target, launch the Default Media Receiver, play the clip to its end, then
|
|
246
243
|
* put back what it can, always: what the Default Media Receiver was playing is loaded again where it was, an idle
|
|
247
|
-
* device gets STOP, then the volume (a group's, member by member through each member's own socket). Another app
|
|
244
|
+
* device gets STOP, then the volume (a group's own first, then member by member through each member's own socket). Another app
|
|
248
245
|
* (Spotify, YouTube) is interrupted and cannot be resumed from here; the result says so. Throws only before anything changed.
|
|
249
246
|
*/
|
|
250
247
|
async announce(target, clip, options = {}) {
|
|
@@ -360,6 +357,27 @@ export function castActions(ctx) {
|
|
|
360
357
|
muted: before.volume?.muted,
|
|
361
358
|
},
|
|
362
359
|
];
|
|
360
|
+
// A group keeps a level of its own, which its members' levels don't bring back (live, 2026-11-10: it stayed at the
|
|
361
|
+
// announcement's 30 with every member restored). First, so whatever it does to the members, their own levels land last.
|
|
362
|
+
if (d.kind === "group" && options.volume !== undefined) {
|
|
363
|
+
const level = before.volume?.level;
|
|
364
|
+
if (level === undefined)
|
|
365
|
+
result.notRestored.push({
|
|
366
|
+
name: d.name,
|
|
367
|
+
why: "its volume before was not reported",
|
|
368
|
+
});
|
|
369
|
+
else
|
|
370
|
+
try {
|
|
371
|
+
await receiver(s, { type: "SET_VOLUME", volume: { level } });
|
|
372
|
+
result.restored.push(`${d.name}: volume`);
|
|
373
|
+
}
|
|
374
|
+
catch (error) {
|
|
375
|
+
result.notRestored.push({
|
|
376
|
+
name: d.name,
|
|
377
|
+
why: `volume not restored: ${errorText(error)}`,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
}
|
|
363
381
|
for (const m of each) {
|
|
364
382
|
const ms = m.id === d.id ? s : ctx.session(m.id);
|
|
365
383
|
if (m.level === undefined)
|