@khalido/home 0.1.1 → 0.1.3
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/CHANGELOG.md +60 -2
- package/README.md +12 -12
- package/dist/clock.js +2 -0
- package/dist/config.js +3 -1
- package/dist/connector.d.ts +9 -2
- package/dist/connectors/amber/index.js +1 -0
- package/dist/connectors/cast/index.d.ts +10 -5
- package/dist/connectors/cast/index.js +36 -4
- package/dist/connectors/cast/schemas.d.ts +2 -0
- package/dist/connectors/cast/schemas.js +1 -0
- package/dist/connectors/cast/state.d.ts +56 -0
- package/dist/connectors/cast/state.js +134 -0
- package/dist/connectors/cast/writes.d.ts +9 -2
- package/dist/connectors/cast/writes.js +67 -13
- package/dist/connectors/daikin/index.d.ts +6 -2
- package/dist/connectors/daikin/index.js +21 -1
- package/dist/connectors/enphase/index.d.ts +5 -1
- package/dist/connectors/enphase/index.js +34 -2
- package/dist/connectors/hue/index.d.ts +6 -2
- package/dist/connectors/hue/index.js +39 -6
- package/dist/connectors/printers/index.d.ts +6 -2
- package/dist/connectors/printers/index.js +23 -0
- package/dist/connectors/spotify/changes.d.ts +1 -1
- package/dist/connectors/spotify/changes.js +4 -2
- package/dist/connectors/spotify/client.d.ts +1 -1
- package/dist/connectors/spotify/index.d.ts +9 -2
- package/dist/connectors/spotify/index.js +130 -45
- package/dist/connectors/up/index.d.ts +4 -2
- package/dist/connectors/up/index.js +3 -1
- 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 +27 -0
- package/dist/devices.js +61 -0
- package/dist/discover.d.ts +2 -0
- package/dist/discover.js +4 -1
- package/dist/health.d.ts +2 -1
- package/dist/health.js +2 -1
- package/dist/home.d.ts +41 -3
- package/dist/home.js +24 -7
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/lane.js +24 -20
- package/docs/cast.md +1 -1
- package/docs/cli.md +3 -3
- package/docs/config.md +8 -3
- package/docs/decisions.md +5 -0
- package/docs/using.md +17 -0
- package/package.json +1 -1
- package/skills/use-home/SKILL.md +7 -7
- package/src/cli/cast.ts +19 -11
- package/src/cli/enphase.ts +5 -1
- package/src/cli/spotify.ts +2 -1
- package/src/cli/status.ts +26 -3
- package/src/clock.ts +1 -0
- package/src/config.ts +3 -1
- package/src/connector.ts +18 -2
- package/src/connectors/amber/index.ts +1 -0
- package/src/connectors/cast/README.md +10 -5
- package/src/connectors/cast/index.ts +74 -9
- package/src/connectors/cast/schemas.ts +1 -0
- package/src/connectors/cast/state.ts +189 -0
- package/src/connectors/cast/writes.ts +87 -17
- package/src/connectors/daikin/README.md +4 -0
- package/src/connectors/daikin/index.ts +41 -2
- package/src/connectors/enphase/index.ts +49 -2
- package/src/connectors/hue/index.ts +57 -8
- package/src/connectors/printers/README.md +4 -2
- package/src/connectors/printers/index.ts +43 -1
- package/src/connectors/spotify/README.md +33 -9
- package/src/connectors/spotify/changes.ts +4 -2
- package/src/connectors/spotify/client.ts +1 -1
- package/src/connectors/spotify/index.ts +157 -49
- package/src/connectors/up/README.md +1 -1
- package/src/connectors/up/index.ts +6 -2
- 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 +102 -0
- package/src/discover.ts +6 -2
- package/src/health.ts +4 -2
- package/src/home.ts +75 -8
- package/src/index.ts +5 -1
- package/src/lane.ts +23 -20
package/CHANGELOG.md
CHANGED
|
@@ -1,9 +1,65 @@
|
|
|
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.1.3] - 2026-11-10
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`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`.
|
|
12
|
+
- **`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`.
|
|
13
|
+
- **`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.
|
|
14
|
+
- **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.
|
|
15
|
+
- **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`.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- **`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.
|
|
20
|
+
- **hue:** a room or zone the bridge gives no on or off for prints its name alone in `home hue`, no longer "off".
|
|
21
|
+
- **weather:** Google's key is sent in the `X-Goog-Api-Key` header, no longer in the URL.
|
|
22
|
+
- **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)`.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- **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.
|
|
27
|
+
- **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.
|
|
28
|
+
- **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.
|
|
29
|
+
- **`createHome`:** an `onChange` that throws or rejects no longer marks the integration `unavailable`; the error goes to `console.error` and the integration keeps running.
|
|
30
|
+
- **`createHome`:** `refresh()` before `start()` resolves at once instead of never.
|
|
31
|
+
- **`createHome`:** an integration whose `run` fails after a good report keeps its last `state`.
|
|
32
|
+
- **`createHome`:** a `run` that throws synchronously marks that integration `unavailable`; the others still start.
|
|
33
|
+
- **`home <connector> --config`** needs only that connector's keys, and runs only that connector.
|
|
34
|
+
- **`realClock.sleep`** resolves at once on an already-aborted signal.
|
|
35
|
+
- **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.
|
|
36
|
+
|
|
37
|
+
## [0.1.2] - 2026-11-09
|
|
38
|
+
|
|
39
|
+
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.
|
|
40
|
+
|
|
41
|
+
### Added
|
|
42
|
+
|
|
43
|
+
- **spotify:** `activate()`, and `home spotify activate`.
|
|
44
|
+
- **cast:** `announceAll(targets, clip, options)`, one result per device, and repeated `--to` on `home cast announce` (`--json` is an array with several).
|
|
45
|
+
- **cast:** `form` on a device whose model it knows (`speaker`, `display`, `tv`, from `castForm(model)`), so "all speakers" can skip TVs. The address cache keeps the model; a cached device without one goes through discovery once.
|
|
46
|
+
- **up:** the integration has `client`, for a consumer's writes.
|
|
47
|
+
|
|
48
|
+
### Changed
|
|
49
|
+
|
|
50
|
+
- **spotify:** `setVolume` while the speaker isn't the active device rejects with code `inactive`, instead of `confirmed: false`: Soloist accepts it and drops it. `duck` then runs `fn` with `music: "untouched"`.
|
|
51
|
+
- **spotify:** `play` while inactive goes active first.
|
|
52
|
+
- **spotify:** the `track` event waits for the track's name.
|
|
53
|
+
- **cast:** a clip that doesn't start playing fails after 15 s (or `timeoutMs`), naming its type. An error the receiver reports sooner gives its reason, and its code if it has one.
|
|
54
|
+
|
|
55
|
+
### Fixed
|
|
56
|
+
|
|
57
|
+
- **spotify:** `maxVolume` waits until the speaker is active, retries until Soloist reports the lower volume, and says "lowered it" only then; if it never shows, `detail` says so. It said "lowered it" at 56 with a cap of 40. The window also opens when the speaker goes active.
|
|
58
|
+
- **spotify:** `setRepeat("off")` from `track` stayed on `track`: the two commands now go 600 ms apart.
|
|
59
|
+
- **spotify:** a volume within 1 of the request confirms, since Soloist rounds it.
|
|
60
|
+
- **spotify:** a stall repair can run again when a track comes back after another.
|
|
61
|
+
- **`home cast announce`** exits 1 when a clip didn't play.
|
|
62
|
+
|
|
7
63
|
## [0.1.1] - 2026-11-09
|
|
8
64
|
|
|
9
65
|
Fixes from chota-bot's first live reads, on a Raspberry Pi and a Mac. No breaking changes.
|
|
@@ -42,6 +98,8 @@ First release. Nine connectors: Hue, Daikin, Enphase, Google Cast, AirPrint prin
|
|
|
42
98
|
- **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.
|
|
43
99
|
- **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.
|
|
44
100
|
|
|
45
|
-
[Unreleased]: https://github.com/khalido/home.ts/compare/v0.1.
|
|
101
|
+
[Unreleased]: https://github.com/khalido/home.ts/compare/v0.1.3...HEAD
|
|
102
|
+
[0.1.3]: https://github.com/khalido/home.ts/compare/v0.1.2...v0.1.3
|
|
103
|
+
[0.1.2]: https://github.com/khalido/home.ts/compare/v0.1.1...v0.1.2
|
|
46
104
|
[0.1.1]: https://github.com/khalido/home.ts/compare/v0.1.0...v0.1.1
|
|
47
105
|
[0.1.0]: https://github.com/khalido/home.ts/releases/tag/v0.1.0
|
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ 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.
|
|
17
17
|
|
|
18
18
|
## Try it
|
|
19
19
|
|
|
@@ -50,17 +50,17 @@ The library never reads your environment or a file: you pass addresses, keys and
|
|
|
50
50
|
|
|
51
51
|
## Connectors
|
|
52
52
|
|
|
53
|
-
| Connector | Reads | Writes
|
|
54
|
-
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
|
|
55
|
-
| [hue](src/connectors/hue/README.md) | lights, rooms, scenes, over the bridge's event stream | lights, rooms, scenes
|
|
56
|
-
| [daikin](src/connectors/daikin/README.md) | the AC, over its local API | power, mode, setpoint, fan
|
|
57
|
-
| [enphase](src/connectors/enphase/README.md) | solar production and consumption from the IQ Gateway | none
|
|
58
|
-
| [cast](src/connectors/cast/README.md) | every Cast device and speaker group | volume, mute, stop, announcements
|
|
59
|
-
| [printers](src/connectors/printers/README.md) | AirPrint printers over IPP: state, toner, paper | none
|
|
60
|
-
| [spotify](src/connectors/spotify/README.md) | a Spotify Soloist speaker: what is playing, volume, shuffle, over its local WebSocket | play a link, pause, resume, skip, seek, volume (capped), queue, shuffle, repeat, each confirmed by what the speaker reports; turn down or pause around an announcement; a stalled stream noticed and, if asked, restarted |
|
|
61
|
-
| [amber](src/connectors/amber/README.md) | live import and feed-in prices | none
|
|
62
|
-
| [up](src/connectors/up/README.md) | household spending by category, what still needs one | a category or tag on a transaction
|
|
63
|
-
| [weather](src/connectors/weather/README.md) | now, forecast by the hour and by part of the day, rain soon, the sun, from BOM, Open-Meteo and Google | none
|
|
53
|
+
| Connector | Reads | Writes |
|
|
54
|
+
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
55
|
+
| [hue](src/connectors/hue/README.md) | lights, rooms, scenes, over the bridge's event stream | lights, rooms, scenes |
|
|
56
|
+
| [daikin](src/connectors/daikin/README.md) | the AC, over its local API | power, mode, setpoint, fan |
|
|
57
|
+
| [enphase](src/connectors/enphase/README.md) | solar production and consumption from the IQ Gateway | none |
|
|
58
|
+
| [cast](src/connectors/cast/README.md) | every Cast device and speaker group | volume, mute, stop, announcements |
|
|
59
|
+
| [printers](src/connectors/printers/README.md) | AirPrint printers over IPP: state, toner, paper | none |
|
|
60
|
+
| [spotify](src/connectors/spotify/README.md) | a Spotify Soloist speaker: what is playing, volume, shuffle, over its local WebSocket | activate, play a link, pause, resume, skip, seek, volume (capped), queue, shuffle, repeat, each confirmed by what the speaker reports; turn down or pause around an announcement; a stalled stream noticed and, if asked, restarted |
|
|
61
|
+
| [amber](src/connectors/amber/README.md) | live import and feed-in prices | none |
|
|
62
|
+
| [up](src/connectors/up/README.md) | household spending by category, what still needs one | a category or tag on a transaction |
|
|
63
|
+
| [weather](src/connectors/weather/README.md) | now, forecast by the hour and by part of the day, rain soon, the sun, from BOM, Open-Meteo and Google | none |
|
|
64
64
|
|
|
65
65
|
Each connector's README says what has run against a real device and what has not. This covers only the devices I own; [Home Assistant](https://www.home-assistant.io/) is the inspiration and the reference for every protocol.
|
|
66
66
|
|
package/dist/clock.js
CHANGED
package/dist/config.js
CHANGED
|
@@ -331,7 +331,9 @@ export function loadHome(input, options) {
|
|
|
331
331
|
{
|
|
332
332
|
castId: x.castId,
|
|
333
333
|
name: x.name,
|
|
334
|
-
...(c?.port === undefined
|
|
334
|
+
...(c?.port === undefined
|
|
335
|
+
? {}
|
|
336
|
+
: { host: c.host, port: c.port, model: c.model }),
|
|
335
337
|
},
|
|
336
338
|
];
|
|
337
339
|
});
|
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,13 +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
|
|
10
|
+
export { castDevices, castForm, castFree, castStatus } from "./state.ts";
|
|
11
|
+
export type { CastDevice, CastForm, CastSkipped, CastSnapshot, CastStatus, Resolved, } from "./state.ts";
|
|
11
12
|
export type { AnnounceResult, CastActions, CastClip, ServeClip, } from "./writes.ts";
|
|
12
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. */
|
|
13
14
|
export declare const configSchema: z.ZodObject<{
|
|
@@ -30,6 +31,7 @@ export type CastConfig = {
|
|
|
30
31
|
expect?: {
|
|
31
32
|
castId: string;
|
|
32
33
|
name: string;
|
|
34
|
+
model?: string;
|
|
33
35
|
host?: string;
|
|
34
36
|
port?: number;
|
|
35
37
|
}[];
|
|
@@ -38,6 +40,7 @@ export type CastConfig = {
|
|
|
38
40
|
host: string;
|
|
39
41
|
port: number;
|
|
40
42
|
name?: string;
|
|
43
|
+
model?: string;
|
|
41
44
|
}) => void;
|
|
42
45
|
clock?: Clock;
|
|
43
46
|
/** Heartbeat interval; a socket silent for three of them is dropped. */
|
|
@@ -55,6 +58,8 @@ export type CastConfig = {
|
|
|
55
58
|
export declare function cast(config: CastConfig): Integration<CastSnapshot, "cast"> & CastActions;
|
|
56
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". */
|
|
57
60
|
export declare function castText(d: CastDevice, s: CastSnapshot): string;
|
|
58
|
-
/**
|
|
59
|
-
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[];
|
|
60
65
|
export declare const manifest: Manifest<CastSnapshot>;
|
|
@@ -3,10 +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";
|
|
11
|
+
export { castDevices, castForm, castFree, castStatus } from "./state.js";
|
|
10
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. */
|
|
11
13
|
export const configSchema = z.looseObject({
|
|
12
14
|
connector: z.literal("cast"),
|
|
@@ -205,6 +207,7 @@ export function cast(config) {
|
|
|
205
207
|
host: d.host,
|
|
206
208
|
port: d.port,
|
|
207
209
|
name: named.has(id) ? d.name : undefined,
|
|
210
|
+
model: d.model,
|
|
208
211
|
}); // a config's name never reaches the cache
|
|
209
212
|
publish();
|
|
210
213
|
void (async () => {
|
|
@@ -359,10 +362,14 @@ export function cast(config) {
|
|
|
359
362
|
report = update;
|
|
360
363
|
running = signal;
|
|
361
364
|
const expect = config.expect ?? [];
|
|
362
|
-
|
|
365
|
+
// A device with no model (a cache from before 0.1.2) goes through discovery once, or it would never get a `form`.
|
|
366
|
+
const cached = expect.filter((e) => e.host !== undefined &&
|
|
367
|
+
e.port !== undefined &&
|
|
368
|
+
(e.port !== 8009 || e.model !== undefined));
|
|
363
369
|
snapshot = found(snapshot, cached.map((e) => ({
|
|
364
370
|
id: e.castId,
|
|
365
371
|
name: e.name,
|
|
372
|
+
model: e.model,
|
|
366
373
|
host: e.host,
|
|
367
374
|
port: e.port,
|
|
368
375
|
group: e.port !== 8009,
|
|
@@ -405,8 +412,33 @@ export function castText(d, s) {
|
|
|
405
412
|
];
|
|
406
413
|
return `${d.name}: ${parts.filter(Boolean).join(" · ")}`;
|
|
407
414
|
}
|
|
408
|
-
/**
|
|
409
|
-
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
|
+
}
|
|
410
442
|
/** How many entries have an address, and one line per device and group. */
|
|
411
443
|
const castView = (s) => ({
|
|
412
444
|
found: Object.values(s.devices).filter((d) => d.host).length,
|
|
@@ -41,6 +41,7 @@ export declare const MediaStatus: z.ZodObject<{
|
|
|
41
41
|
mediaSessionId: z.ZodOptional<z.ZodNumber>;
|
|
42
42
|
playerState: z.ZodOptional<z.ZodString>;
|
|
43
43
|
idleReason: z.ZodOptional<z.ZodString>;
|
|
44
|
+
detailedErrorCode: z.ZodOptional<z.ZodNumber>;
|
|
44
45
|
currentTime: z.ZodOptional<z.ZodNumber>;
|
|
45
46
|
media: z.ZodOptional<z.ZodObject<{
|
|
46
47
|
contentId: z.ZodOptional<z.ZodString>;
|
|
@@ -118,6 +119,7 @@ export declare const CastPayload: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
118
119
|
mediaSessionId: z.ZodOptional<z.ZodNumber>;
|
|
119
120
|
playerState: z.ZodOptional<z.ZodString>;
|
|
120
121
|
idleReason: z.ZodOptional<z.ZodString>;
|
|
122
|
+
detailedErrorCode: z.ZodOptional<z.ZodNumber>;
|
|
121
123
|
currentTime: z.ZodOptional<z.ZodNumber>;
|
|
122
124
|
media: z.ZodOptional<z.ZodObject<{
|
|
123
125
|
contentId: z.ZodOptional<z.ZodString>;
|
|
@@ -36,6 +36,7 @@ export const MediaStatus = z.looseObject({
|
|
|
36
36
|
mediaSessionId: z.number().optional(),
|
|
37
37
|
playerState: z.string().optional(), // "PLAYING" | "PAUSED" | "BUFFERING" | "IDLE"
|
|
38
38
|
idleReason: z.string().optional(), // "FINISHED" | "CANCELLED" | "INTERRUPTED" | "ERROR"
|
|
39
|
+
detailedErrorCode: z.number().optional(),
|
|
39
40
|
currentTime: z.number().optional(), // seconds into the media
|
|
40
41
|
media: z
|
|
41
42
|
.looseObject({
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import type { CastPayload } from "./schemas.ts";
|
|
2
|
+
/** What a device is for, read from its model name; a model it doesn't know has none. A group is `kind: "group"`. */
|
|
3
|
+
export type CastForm = "speaker" | "display" | "tv";
|
|
4
|
+
export declare function castForm(model?: string): CastForm | undefined;
|
|
2
5
|
/** A Cast device or group as found by discovery: what `resolve` returns for each. */
|
|
3
6
|
export type Resolved = {
|
|
4
7
|
id: string;
|
|
@@ -16,6 +19,7 @@ export type CastDevice = {
|
|
|
16
19
|
id: string;
|
|
17
20
|
name: string;
|
|
18
21
|
model?: string;
|
|
22
|
+
form?: CastForm;
|
|
19
23
|
kind: "device" | "group";
|
|
20
24
|
host?: string;
|
|
21
25
|
port?: number;
|
|
@@ -60,3 +64,55 @@ export declare const mediaApp: (app?: {
|
|
|
60
64
|
}[];
|
|
61
65
|
transportId?: string;
|
|
62
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. */
|
|
101
|
+
export type CastSkipped = {
|
|
102
|
+
name: string;
|
|
103
|
+
why: "busy" | "offline" | "standby" | "unknown";
|
|
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
|
+
*/
|
|
115
|
+
export declare function castFree(s: CastSnapshot, targets: string[]): {
|
|
116
|
+
free: string[];
|
|
117
|
+
skipped: CastSkipped[];
|
|
118
|
+
};
|
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
export function castForm(model) {
|
|
2
|
+
if (!model)
|
|
3
|
+
return undefined;
|
|
4
|
+
if (/hub|tablet|display/i.test(model))
|
|
5
|
+
return "display";
|
|
6
|
+
if (/audio|speaker|mini|google home/i.test(model))
|
|
7
|
+
return "speaker";
|
|
8
|
+
if (/chromecast|streamer|\btv\b/i.test(model))
|
|
9
|
+
return "tv";
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
1
12
|
/** An id as the snapshot keys it: the mDNS id (32 hex) and the certificate's and multizone's dashed UUID are one key. */
|
|
2
13
|
export const castKey = (id) => id.toLowerCase().replace(/-/g, "");
|
|
3
14
|
/** Add or update what discovery found; devices it did not find this time are kept as they were. */
|
|
@@ -11,6 +22,7 @@ export function found(s, entries, asOf) {
|
|
|
11
22
|
id,
|
|
12
23
|
name: e.name,
|
|
13
24
|
model: e.model,
|
|
25
|
+
form: kind === "device" ? castForm(e.model) : undefined,
|
|
14
26
|
kind,
|
|
15
27
|
host: e.host,
|
|
16
28
|
port: e.port,
|
|
@@ -131,3 +143,125 @@ function link(s) {
|
|
|
131
143
|
delete d.playingVia;
|
|
132
144
|
return { devices, asOf: s.asOf };
|
|
133
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
|
+
*/
|
|
220
|
+
export function castFree(s, targets) {
|
|
221
|
+
const status = new Map(castStatus(s).map((c) => [c.id, c]));
|
|
222
|
+
const free = new Map();
|
|
223
|
+
const covered = new Set(); // members of the groups in `free`
|
|
224
|
+
const skipped = new Map();
|
|
225
|
+
const ok = (c) => (c.state === "idle" || c.state === "restorable") && !c.standby;
|
|
226
|
+
const skip = (c) => skipped.set(c.id, {
|
|
227
|
+
name: c.name,
|
|
228
|
+
why: c.state === "offline"
|
|
229
|
+
? "offline"
|
|
230
|
+
: c.standby && c.state !== "busy"
|
|
231
|
+
? "standby"
|
|
232
|
+
: "busy",
|
|
233
|
+
...(c.app ? { app: c.app } : {}),
|
|
234
|
+
});
|
|
235
|
+
const device = (c) => (ok(c) ? free.set(c.id, c) : skip(c));
|
|
236
|
+
for (const target of targets) {
|
|
237
|
+
const d = findCast(s.devices, target);
|
|
238
|
+
const c = d && status.get(d.id);
|
|
239
|
+
if (!d || !c)
|
|
240
|
+
skipped.set(target, { name: target, why: "unknown" });
|
|
241
|
+
else if (c.kind === "device")
|
|
242
|
+
device(c);
|
|
243
|
+
else {
|
|
244
|
+
const members = c.members ?? [];
|
|
245
|
+
const own = ownState(d);
|
|
246
|
+
const up = members.filter((m) => m.state !== "offline");
|
|
247
|
+
if (own === "busy" || (own === "offline" && !members.length))
|
|
248
|
+
skip(c);
|
|
249
|
+
else if (own !== "offline" &&
|
|
250
|
+
up.every(ok) &&
|
|
251
|
+
!members.some((m) => covered.has(m.id))) {
|
|
252
|
+
free.set(c.id, c);
|
|
253
|
+
for (const m of members)
|
|
254
|
+
covered.add(m.id);
|
|
255
|
+
}
|
|
256
|
+
else
|
|
257
|
+
for (const m of members)
|
|
258
|
+
device(m);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return {
|
|
262
|
+
free: [...free.values()]
|
|
263
|
+
.filter((c) => c.kind === "group" || !covered.has(c.id))
|
|
264
|
+
.map((c) => c.name),
|
|
265
|
+
skipped: [...skipped.values()],
|
|
266
|
+
};
|
|
267
|
+
}
|
|
@@ -1,11 +1,11 @@
|
|
|
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. */
|
|
8
6
|
export declare const PLAY_MS = 60000;
|
|
7
|
+
/** How long a clip gets to start playing. A clip a speaker can't play took 35–44 s to say so (live, 2026-11-09); good ones start within a few seconds. A stated guess. */
|
|
8
|
+
export declare const START_MS = 15000;
|
|
9
9
|
/** Level writes, and mute writes, per device or group in any minute; more wait their turn, the newest winning. A guess: Cast publishes no limit. */
|
|
10
10
|
export declare const VOLUME_PER_MINUTE = 30;
|
|
11
11
|
/** What to play: a URL the device can fetch (type defaults to audio/mpeg), or bytes for `serve` to put on the LAN. */
|
|
@@ -44,6 +44,13 @@ export type CastActions = {
|
|
|
44
44
|
volume?: number;
|
|
45
45
|
timeoutMs?: number;
|
|
46
46
|
}): Promise<AnnounceResult>;
|
|
47
|
+
/** `announce` to several targets at once, one result per device, in the order given. A target that can't be reached is its own `played: false`. A group and one of its own members are not told apart. */
|
|
48
|
+
announceAll(targets: string[], clip: CastClip, options?: {
|
|
49
|
+
volume?: number;
|
|
50
|
+
timeoutMs?: number;
|
|
51
|
+
}): Promise<(AnnounceResult & {
|
|
52
|
+
target: string;
|
|
53
|
+
})[]>;
|
|
47
54
|
};
|
|
48
55
|
/** A payload as it came, before any schema. */
|
|
49
56
|
export type RawPayload = {
|