@khalido/home 0.1.2 → 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 +33 -2
- package/README.md +1 -1
- package/dist/clock.js +2 -0
- 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 +52 -0
- package/dist/connectors/cast/state.js +122 -0
- package/dist/connectors/cast/writes.d.ts +0 -2
- package/dist/connectors/cast/writes.js +25 -7
- 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/index.d.ts +6 -1
- package/dist/connectors/spotify/index.js +26 -1
- 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 +27 -0
- package/dist/devices.js +61 -0
- 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/docs/cli.md +1 -1
- package/docs/config.md +8 -3
- package/docs/decisions.md +4 -0
- package/docs/using.md +17 -0
- package/package.json +1 -1
- package/skills/use-home/SKILL.md +7 -7
- package/src/cli/enphase.ts +5 -1
- package/src/cli/status.ts +26 -3
- package/src/clock.ts +1 -0
- package/src/connector.ts +18 -2
- package/src/connectors/amber/index.ts +1 -0
- package/src/connectors/cast/README.md +7 -3
- package/src/connectors/cast/index.ts +59 -7
- package/src/connectors/cast/state.ts +177 -0
- package/src/connectors/cast/writes.ts +29 -8
- 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 +14 -2
- package/src/connectors/spotify/index.ts +46 -2
- 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 +102 -0
- package/src/health.ts +4 -2
- package/src/home.ts +75 -8
- package/src/index.ts +5 -1
package/docs/cli.md
CHANGED
|
@@ -26,7 +26,7 @@ home spotify activate | pause | play [link] | next | volume <0-100> # the Soloi
|
|
|
26
26
|
|
|
27
27
|
- **stdout is data, stderr everything else.** Notes such as "nothing to categorise" or "no webhooks" go to stderr, so `home … | jq` never sees them.
|
|
28
28
|
- **Bun on PATH.** The command starts with `#!/usr/bin/env bun`. A non-login SSH shell may not have `~/.bun/bin` on its PATH: run `PATH="$HOME/.bun/bin:$PATH" home`.
|
|
29
|
-
- **`--json` shapes.** `home --json` is one object keyed by connector, with `now` (`energyNow`) first. `home <connector> --json` is that connector's object: `status`, `needs`, `access`, `detail`, `anomaly`, then its state (the weather adds `line`, its text). Lists (`home up transactions`, `uncategorised`, `spend --by merchant`) are arrays, `[]` when empty.
|
|
29
|
+
- **`--json` shapes.** `home --json` is one object keyed by connector, with `now` (`energyNow`) first. `home <connector> --json` is that connector's object: `status`, `needs`, `access`, `detail`, `reason` (why it is `unavailable`, when known: `auth`, `config` or `crashed`), `anomaly`, then its state (the weather adds `line`, its text). Lists (`home up transactions`, `uncategorised`, `spend --by merchant`) are arrays, `[]` when empty.
|
|
30
30
|
- **Errors under `--json`** are one object on stderr: `{"error": "…", "code": "usage" | "auth" | "network" | "error", "fix": "home enphase login"}`; `fix` is there only when one command resolves it. In text: `home: <error> (run \`<fix>\`)`.
|
|
31
31
|
- **Exit codes.** 0 success, empty results included. 1 a failure, and `home <connector>` when that connector is not `connected`, so a script can test one. 2 a wrong command, verb or argument. `home` alone lists every connector and exits 0 whatever their state.
|
|
32
32
|
|
package/docs/config.md
CHANGED
|
@@ -4,12 +4,17 @@ One JSONC file lays out the home, so the `home` command, jaldi and chota-bot rea
|
|
|
4
4
|
|
|
5
5
|
```ts
|
|
6
6
|
import { readFileSync } from "node:fs";
|
|
7
|
+
import { homedir } from "node:os";
|
|
7
8
|
import { dirname, resolve } from "node:path";
|
|
8
9
|
import { createHome, loadHome, parseJsonc } from "@khalido/home";
|
|
9
10
|
import { ftpFetch } from "@khalido/home/weather/ftp";
|
|
10
11
|
|
|
11
12
|
const path = "/path/to/home.jsonc";
|
|
12
|
-
const readFile = (p: string) =>
|
|
13
|
+
const readFile = (p: string) =>
|
|
14
|
+
readFileSync(
|
|
15
|
+
resolve(dirname(path), p.replace(/^~(?=\/)/, homedir())),
|
|
16
|
+
"utf8",
|
|
17
|
+
); // relative to the config, or from ~/
|
|
13
18
|
const home = createHome(
|
|
14
19
|
loadHome(parseJsonc(readFileSync(path, "utf8")), {
|
|
15
20
|
env: process.env,
|
|
@@ -22,12 +27,12 @@ const home = createHome(
|
|
|
22
27
|
`loadHome` runs on Bun and Node. Hue and Enphase need a fetch that handles their certificates: Bun's `tls` fetch option by default, and under Node `tls: NODE_TLS` from `@khalido/home/node`. Under Node, a config with Hue or Enphase and no `tls` is a problem `loadHome` names, not a silent failure at the first read.
|
|
23
28
|
|
|
24
29
|
- **A device entry is only how to reach it and where it is**: its identity (the Hue `bridgeId`, a Daikin's `mac`, the Enphase `serial`, a Cast `castId`), its last known `host`, a key's env name, a file's path, its `room`. What a device reports about itself (model, firmware) comes live; a note about it is a comment.
|
|
25
|
-
- **Secrets are env variable names** (`"key": "AMBER_KEY"`), resolved against the `env` the caller passes; a pasted key fails the schema. Files (`ca`, `token`) are paths the caller reads, relative to the config; the library reads nothing.
|
|
30
|
+
- **Secrets are env variable names** (`"key": "AMBER_KEY"`), resolved against the `env` the caller passes; a pasted key fails the schema. Files (`ca`, `token`) are paths the caller reads, relative to the config or from `~/`, which the caller expands (`home discover --write` writes Enphase's token as `~/.config/home/enphase-token.json`); the library reads nothing.
|
|
26
31
|
- **Problems name the field**: an unknown connector, a missing env name or a device in a missing room is an error, never a skip. `checkHome` returns them all; `loadHome` throws them all. Unknown keys pass through for a consumer's own settings.
|
|
27
32
|
- **One device per connector** for now (each integration is named by its connector), except printers (one entry each, by `uuid`) and Cast: one entry per device and speaker group (`name`, the Google Home name, and `castId`, the mDNS id), all read by one `cast` integration that finds every address by discovery, since a group's moves with its leader. A `spotify` entry is only its Soloist `url` (and `maxVolume`): a socket on the same machine, with nothing to discover.
|
|
28
33
|
- **Identity, not address.** A device is recognised by the id its own discovery reports; `host` is a cache. `home discover` lists what answers (mDNS, Daikin's UDP broadcast), in two passes 5 s apart since both drop answers now and then: the first pass prints at once, the second only what it adds (`--passes 1` for a quick look; `--json` waits for both); with a config (`--config` or `HOME_CONFIG`) it then matches those against it by identity and prints new devices, moved addresses and conflicts (a different device at an entry's host). `home discover --write <file>` adds the new ones (each with an id no entry has), updates moved hosts and refreshes the `name` of a Cast entry renamed in the Google Home app through `jsonc-parser`'s edits, every comment and other line left as it was, and writes only a result that passes `checkPlan`. Nothing else writes the file; the library never does. Two devices with one id are a config problem.
|
|
29
34
|
- **A device that moves is found again, without periodic scans**, when the caller passes `finders` to `loadHome` (`FINDERS` from `@khalido/home/finders`, Node-side like `weather/ftp`; the CLI does, and jaldi and chota-bot can). After three failed reads in a row (Hue: three failed reconnects of its event stream), Hue, Daikin and Enphase run their discovery (two passes, the second skipped when the first finds the device), switch to the address their identity now answers at and say "moved from a to b" in `detail` (persist it with `home discover --write`); otherwise they look again at 1, 5 and 15 minutes, then hourly for as long as reads fail, so a device back from the wall socket reads again without a restart. The detail says what discovery found ("not found by discovery", or "discovery finds it at its address but reads fail", or "discovery failed: …" when it could not run), and "no answer for a day" once a day has passed; the part stays `disconnected`, since `unavailable` is for a rejected key. `refresh()` resets the wait. Amber and the weather have no discovery and do not take part. Cast always needs `finders.cast`: discovery is how it finds every address.
|
|
30
|
-
- **Discovery is rare: addresses are cached between runs.** `loadHome(config, { cache, configAt })` takes an `AddressCache` (`read()` and `write(entries)`, keyed by connector and identity, each entry `{ host, port?, name?, seenAt }`). A cached address newer than the config (`configAt`, the file's modified time) is tried first, a failure there scans at once rather than after three, and each address that answers is written back when it changed or predates the config, so a config edit is re-trusted at the first good read. Nothing evicts an entry but `home cache clear`: a device gone for good keeps its entry, and `home status` without a config, which reads every Cast entry from the cache, shows it as not found until then. The consumer persists it: the CLI keeps `$XDG_CACHE_HOME/home/addresses.json` (default `~/.cache/home/addresses.json`, 0600) through `fileCache` from `@khalido/home/finders`, written at the end of `status`, `probe` and `discover`;
|
|
35
|
+
- **Discovery is rare: addresses are cached between runs.** `loadHome(config, { cache, configAt })` takes an `AddressCache` (`read()` and `write(entries)`, keyed by connector and identity, each entry `{ host, port?, name?, seenAt }`). A cached address newer than the config (`configAt`, the file's modified time) is tried first, a failure there scans at once rather than after three, and each address that answers is written back when it changed or predates the config, so a config edit is re-trusted at the first good read. Nothing evicts an entry but `home cache clear`: a device gone for good keeps its entry, and `home status` without a config, which reads every Cast entry from the cache, shows it as not found until then. The consumer persists it: the CLI keeps `$XDG_CACHE_HOME/home/addresses.json` (default `~/.cache/home/addresses.json`, 0600) through `fileCache` from `@khalido/home/finders`, written at the end of `status`, `probe` and `discover`; another consumer passes `FINDERS` and its own cache the same way. The config's `host` stays the human-set fallback and `home discover --write` the way to change the config. `home status` from a Cast config: 0.33–0.44 s cold, 0.13 s with a warm cache, 1.4 s once when a cached address has gone stale (Bun 1.4.2, measured 2026-10-13).
|
|
31
36
|
- **Parsing** is through `jsonc-parser` (comments and trailing commas), on Bun and Node alike; a syntax error throws. `bun run schema` rewrites `home.schema.json` from the zod schema, and a test fails if they drift.
|
|
32
37
|
- `createHome` stays for code-first use.
|
|
33
38
|
|
package/docs/decisions.md
CHANGED
|
@@ -149,3 +149,7 @@ KO's decisions on this package, newest block last. They override every other doc
|
|
|
149
149
|
- **Stalls (Soloist issue #5) are noticed by default and repaired only when asked** (`repairStalls: true`): the repair is the library's first write nobody asked for, so a consumer opts in. Two signals, since the issue doesn't say whether the reported position freezes: a frozen position across fresh reports, and still playing well past the track's end, the symptom actually reported. One repair per track, never a loop. The nudges (seek, then pause and play; a skip past the end) are guesses until a stall is seen live.
|
|
150
150
|
- **The project is home.ts; the npm package stays `@khalido/home`** (KO, 2026-11-08), so imports stay short (`@khalido/home/spotify`). It is home automation for TypeScript, starting with the devices in KO's house, and is meant to grow into more of the basics.
|
|
151
151
|
- **Spotify: inactive is refused, and activating is the caller's choice** (KO, 2026-11-09). Soloist accepts and drops `set_volume` while it isn't the active device. `setVolume` refuses with code `inactive`, and `activate()` is separate, because activating makes a new stream and can jump the volume to the level last saved. `duck` runs `fn` anyway, with `music: "untouched"`, because the announcement matters more. `play` goes active itself, since someone asking for music wants it to start; `resume` doesn't, since it worked inactive. The `maxVolume` cap says "lowered" only once Soloist reports it.
|
|
152
|
+
|
|
153
|
+
- **`Part.reason`, only on `unavailable`** (KO, 2026-11-09). An admin page had to match `detail` text like "pair again" to know a person must re-key. `reason` is `auth` (a key or token rejected or unusable), `config` (a setting or file is wrong) or `crashed` (a `run` threw), for code to switch on; `detail` stays the text for people. `disconnected` gets none: it already says unreachable. Additive, so a patch. `config` came a day later (KO, 2026-11-10), so "re-key", "fix a setting" and "report a bug" are three answers; `crashed` stays though nothing reads it yet. Weather has one too: each source says why it stopped (`stopReason`), the part is `auth` if any stopped source is, else `config`, and `state.sources.<name>.reason` shows a dead key while another source keeps the part `connected`.
|
|
154
|
+
- **Google's key goes in the `X-Goog-Api-Key` header, not the URL** (KO, 2026-11-10), so nothing that prints a URL can print it. Its rejection is read from the error body: a bad key and a bad latitude are both HTTP 400, and only the key's carries `API_KEY_INVALID` (live, 2026-11-10).
|
|
155
|
+
- **Every device answers the same few questions** (KO, 2026-11-10: "nice and consistent so we can easily and consistently add devices"). `DeviceRecord` is identity, availability, one word of state and one line of text, the same for a light, a speaker and a printer; `homeDevices(snapshot, config?)` lists them all. Everything particular to a kind stays in the connector's own typed row, carried as `detail`, so the common record never grows a field only one kind has. Three levels, each inside the last: the house list, the connector's typed row, the raw snapshot. Each connector exports its own typed function, listed by hand in `src/devices.ts`, and not a manifest field: `MANIFESTS` is untyped, and `detail` must narrow by `connector`. The list is read-only: a write means something different for a light, a speaker and an air conditioner, so there is no generic `turnOn`. Not done, for a 0.2.0: Hue's `reachable` becoming `available`, and its groups joining its lights in one call.
|
package/docs/using.md
CHANGED
|
@@ -37,6 +37,23 @@ health(home.snapshot(), Date.now()); // per integration: ok, stale or down, with
|
|
|
37
37
|
|
|
38
38
|
`createHome` stamps each part with `lastGoodAt` (its last good read) and `downSince` (when it stopped being connected). `health` judges from those: a polling connector is stale after 2½ polls with no good read and down after 3 polls not connected; Hue and Cast are down after 2 minutes not connected. Health takes each connector's interval from its manifest; a home that changes one (the weather's `pollMinutes`) passes it: `health(snapshot, now, { pollSeconds: { weather: 60 * 60 } })`. For a one-shot read (a script, a printed brief), `settle(integrations, { ms })` runs them until each has answered and returns the snapshot.
|
|
39
39
|
|
|
40
|
+
## Every device, the same way
|
|
41
|
+
|
|
42
|
+
`homeDevices(home.snapshot(), config)` lists every device the home has: each Hue light, room and zone, the Daikin, the Enphase gateway, each Cast device and group, each printer and the Soloist speaker. Each answers the same questions in the same words: `connector`, `id`, `name`, `kind` (light, group, speaker, display, tv, ac, printer, solar; `cast` for a Cast model the library doesn't know), `available` (and when it is false, `reason`: `offline`, `auth`, `config` or `crashed`), `asOf` (when the device last reported or was last read; a quiet light can be hours old and current), `state` (one word: on, off, idle, busy, cool, printing), `text` (one line for people), `room` and, for a group, `members`. A field the device hasn't reported is absent. The config is optional: with it a device takes its entry's `room`, and a device with no name of its own (the Daikin, the gateway) its entry's `name`; a device's own name is never replaced. While a connector isn't connected none of its devices is `available`, and each keeps its last values.
|
|
43
|
+
|
|
44
|
+
To drill down, `detail` is the connector's own typed row, and `connector` (with `kind` for Hue) tells TypeScript which:
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
for (const d of homeDevices(home.snapshot(), config)) {
|
|
48
|
+
console.log(d.text); // "Kitchen: on · 60%"
|
|
49
|
+
if (d.connector === "cast") d.detail.volume; // CastStatus
|
|
50
|
+
if (d.connector === "hue" && d.kind === "light") d.detail.brightness; // LightView
|
|
51
|
+
if (d.connector === "printers") d.detail.markers; // Printer
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Below that is the connector's snapshot, exactly as the device sent it (`home.part("hue")?.state`). One connector alone: `castHomeDevices(home.part("cast"))`, and likewise `hueHomeDevices`, `daikinHomeDevices`, `enphaseHomeDevices`, `printersHomeDevices`, `spotifyHomeDevices`. The list is for reading; a write goes to the device's connector, with `d.id` where it takes one.
|
|
56
|
+
|
|
40
57
|
## Writes
|
|
41
58
|
|
|
42
59
|
Writes hang off the integration: `home.integration("daikin")?.client.write(setMode("cool"))`, `home.integration("hue")?.client.recallScene(id)`, `home.integration("cast")?.setVolume(…)`, `home.integration("spotify")?.play(link)`. Each goes through the device's write lane: spaced, and a burst to one light or the AC merges so the last value lands. Spotify's writes resolve with `{ state, confirmed }`, the state once Soloist reported the change, and `duck(fn, { to })` and `interrupt(fn)` turn the music down or pause it around an announcement, returning what happened to it (`music`: `untouched`, `restored`, `kept` or `failed`). `speakerState(part)` says the speaker in one word (offline, waiting, idle, playing, paused) and `spotifyChanges(change.before, change.after)` turns an `onChange` into events (started, resumed, paused, stopped, track, stalled, recovered, offline, waiting, picked), those from the library's own `duck` or `interrupt` marked `own`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@khalido/home",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "home.ts: small, typed home automation for TypeScript: Hue, Daikin, Enphase, Google Cast, AirPrint printers and a Spotify Soloist speaker on the local network; Amber prices, Up Bank spending and the weather from the cloud.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Khalid Omar",
|
package/skills/use-home/SKILL.md
CHANGED
|
@@ -29,13 +29,13 @@ The contract (in `docs/cli.md`): stdout is data, stderr everything else. `--json
|
|
|
29
29
|
|
|
30
30
|
What a status means:
|
|
31
31
|
|
|
32
|
-
| Status | Meaning
|
|
33
|
-
| -------------- |
|
|
34
|
-
| `connected` | read and current
|
|
35
|
-
| `connecting` | still starting (a 15 s cap in the CLI)
|
|
36
|
-
| `disconnected` | the device or service did not answer; the last good state is kept, with its `asOf`
|
|
37
|
-
| `unavailable` | stopped for good: a key rejected, a setting wrong
|
|
38
|
-
| `not set up` | env names or a file missing (`needs` lists them)
|
|
32
|
+
| Status | Meaning | What to do |
|
|
33
|
+
| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
|
|
34
|
+
| `connected` | read and current | use it |
|
|
35
|
+
| `connecting` | still starting (a 15 s cap in the CLI) | wait, or run again |
|
|
36
|
+
| `disconnected` | the device or service did not answer; the last good state is kept, with its `asOf` | say so; check the device is on and at its address |
|
|
37
|
+
| `unavailable` | stopped for good: a key rejected, a setting wrong; `reason`, when present, is `auth` (a person must re-key), `config` (a setting or file is wrong) or `crashed` | the `detail` names it; fix the key or setting |
|
|
38
|
+
| `not set up` | env names or a file missing (`needs` lists them) | set them, or say what is needed |
|
|
39
39
|
|
|
40
40
|
Weather: `blocks` holds morning, afternoon and evening for today and tomorrow in the home's time zone; `rainSoon` says when rain starts in the next two hours (needs the Google key). Energy: `now` in `home --json` is the one owner of solar, use, grid flow and cost per hour; never recompute it.
|
|
41
41
|
|
package/src/cli/enphase.ts
CHANGED
|
@@ -23,7 +23,11 @@ export function enphaseToken(): { token: string; expiresAt: Date } | undefined {
|
|
|
23
23
|
const { token } = JSON.parse(text) as { token: string };
|
|
24
24
|
return { token, expiresAt: tokenExpiry(token) };
|
|
25
25
|
} catch (error) {
|
|
26
|
-
return fail(
|
|
26
|
+
return fail(
|
|
27
|
+
`${TOKEN_FILE}: ${(error as Error).message}`,
|
|
28
|
+
"auth",
|
|
29
|
+
"home enphase login",
|
|
30
|
+
);
|
|
27
31
|
}
|
|
28
32
|
}
|
|
29
33
|
|
package/src/cli/status.ts
CHANGED
|
@@ -116,6 +116,7 @@ async function statusFromEnv(
|
|
|
116
116
|
try {
|
|
117
117
|
return [await from.build()];
|
|
118
118
|
} catch (error) {
|
|
119
|
+
const code = error instanceof CliError ? error.code : undefined;
|
|
119
120
|
const fix =
|
|
120
121
|
error instanceof CliError && error.fix
|
|
121
122
|
? ` (run \`${error.fix}\`)`
|
|
@@ -123,6 +124,13 @@ async function statusFromEnv(
|
|
|
123
124
|
rows[m.name] = {
|
|
124
125
|
status: "unavailable",
|
|
125
126
|
needs,
|
|
127
|
+
// A build reads settings and files, so a failure is config unless it was a key (auth) or the network (nothing to fix here).
|
|
128
|
+
reason:
|
|
129
|
+
code === "auth"
|
|
130
|
+
? "auth"
|
|
131
|
+
: code === "network"
|
|
132
|
+
? undefined
|
|
133
|
+
: "config",
|
|
126
134
|
detail: `${(error as Error).message}${fix}`,
|
|
127
135
|
};
|
|
128
136
|
return [];
|
|
@@ -136,6 +144,18 @@ async function statusFromEnv(
|
|
|
136
144
|
return rows;
|
|
137
145
|
}
|
|
138
146
|
|
|
147
|
+
/** The config with only this connector's devices, and the weather only for `home weather`, so `home amber` doesn't need Up's key. A malformed one passes through for `loadHome` to report. */
|
|
148
|
+
function narrowed(input: unknown, only: string): unknown {
|
|
149
|
+
const config = input as { devices?: unknown } | null;
|
|
150
|
+
if (!Array.isArray(config?.devices)) return input;
|
|
151
|
+
const { location, weather, ...rest } = config as Record<string, unknown>;
|
|
152
|
+
return {
|
|
153
|
+
...rest,
|
|
154
|
+
...(only === "weather" ? { location, weather } : {}),
|
|
155
|
+
devices: config.devices.filter((d) => d?.connector === only),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
139
159
|
/** The rows from a config: its integrations (or the one named) run until each has answered (weather: every source), at most 15 s. */
|
|
140
160
|
async function statusFromConfig(
|
|
141
161
|
path: string,
|
|
@@ -150,9 +170,11 @@ async function statusFromConfig(
|
|
|
150
170
|
cache: addresses.cache,
|
|
151
171
|
configAt: statSync(path).mtimeMs,
|
|
152
172
|
};
|
|
153
|
-
const
|
|
154
|
-
const integrations =
|
|
155
|
-
only === undefined ?
|
|
173
|
+
const input = readConfig(path);
|
|
174
|
+
const integrations = loadHome(
|
|
175
|
+
only === undefined ? input : narrowed(input, only),
|
|
176
|
+
options,
|
|
177
|
+
);
|
|
156
178
|
if (!integrations.length) fail(`no ${only} in ${path}`);
|
|
157
179
|
return Object.fromEntries(
|
|
158
180
|
Object.entries(await settle(integrations)).map(([name, p]) => [
|
|
@@ -170,6 +192,7 @@ function rowRecord(name: string, r: Row): Record<string, unknown> {
|
|
|
170
192
|
needs: r.needs,
|
|
171
193
|
access: m?.access ?? "",
|
|
172
194
|
detail: r.detail,
|
|
195
|
+
reason: r.reason,
|
|
173
196
|
anomaly: r.anomaly,
|
|
174
197
|
...(m?.record && r.state ? m.record(r as Part) : (r.state as object)),
|
|
175
198
|
};
|
package/src/clock.ts
CHANGED
package/src/connector.ts
CHANGED
|
@@ -17,13 +17,29 @@ export function errorText(error: unknown): string {
|
|
|
17
17
|
: error.message;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Every `code` a `ConnectorError` from this library carries, so a consumer's table of messages fails to compile when one is added.
|
|
22
|
+
* The spotify connector's writes: `offline` (not connected), `waiting` (nobody signed in), `inactive` (not the active device), `refused`
|
|
23
|
+
* (the device said no), `timeout` (no answer), `invalid` (bad input, nothing sent). `link_button`: Hue wants its button
|
|
24
|
+
* pressed. `ipp`: a printer answered with an IPP error, its code in `status`.
|
|
25
|
+
*/
|
|
26
|
+
export type ConnectorCode =
|
|
27
|
+
| "offline"
|
|
28
|
+
| "waiting"
|
|
29
|
+
| "inactive"
|
|
30
|
+
| "refused"
|
|
31
|
+
| "timeout"
|
|
32
|
+
| "invalid"
|
|
33
|
+
| "link_button"
|
|
34
|
+
| "ipp";
|
|
35
|
+
|
|
20
36
|
/** A failed device or service call. `status` is the HTTP status (or a device's own code); `code` a connector's own reason. */
|
|
21
37
|
export class ConnectorError extends Error {
|
|
22
38
|
readonly status?: number;
|
|
23
|
-
readonly code?:
|
|
39
|
+
readonly code?: ConnectorCode;
|
|
24
40
|
constructor(
|
|
25
41
|
message: string,
|
|
26
|
-
options: { status?: number; code?:
|
|
42
|
+
options: { status?: number; code?: ConnectorCode; cause?: unknown } = {},
|
|
27
43
|
) {
|
|
28
44
|
super(
|
|
29
45
|
message,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Google Cast
|
|
2
2
|
|
|
3
|
-
Nest speakers, Nest Hubs, Chromecasts, Google TV and speaker groups, read over CastV2 on the local network. `iot_class`: **local_push** (one socket per device or group, status pushed, a PING every 5 s). The integration only reads; the writes (volume, mute, stop, announcements) are calls on the same object, never made by the integration itself, and
|
|
3
|
+
Nest speakers, Nest Hubs, Chromecasts, Google TV and speaker groups, read over CastV2 on the local network. `iot_class`: **local_push** (one socket per device or group, status pushed, a PING every 5 s). The integration only reads; the writes (volume, mute, stop, announcements) are calls on the same object, never made by the integration itself, and announcements, volume and mute have run on real devices (2026-11-09 and 2026-11-10, below); `stop` and `announceAll` only on a scripted fake socket. The design and what comes next (casting a page) are in [docs/cast.md](../../../docs/cast.md).
|
|
4
4
|
|
|
5
5
|
## Built
|
|
6
6
|
|
|
@@ -10,8 +10,10 @@ Nest speakers, Nest Hubs, Chromecasts, Google TV and speaker groups, read over C
|
|
|
10
10
|
- The part is `connecting` until every device has answered or failed, then `connected` while any is up; `detail` names the ones down. A payload of a known type the schema rejects is an `anomaly`, never thrown.
|
|
11
11
|
- State (`CastSnapshot`, by mDNS id): `form` (`speaker`, `display` or `tv`, from `castForm(model)`; none for a model it doesn't know; the address cache keeps the model), app, `idle` (no app, or Backdrop's `isIdleScreen`), `standby`, volume (unrounded), muted, `volumeControl`, player state, title, artist; groups carry `members` and `leader`; devices carry `groups`, and `playingVia` when they show a playing group's media because their own is empty (Home Assistant's model, not yet seen live).
|
|
12
12
|
- Names: a device's `name` in the snapshot is its own, from discovery's `fn` or its own multizone status (the same text on all six live devices, 2026-10-13), so a rename in the Google Home app shows once the device answers, even when every address came from the cache and discovery never ran; the config's name is only the placeholder until then. A group's multizone status lists its members, not itself, so a group's new name shows at the next discovery.
|
|
13
|
+
- `castStatus(snapshot)`: what each device and group is doing, for a caller choosing where to play something. `state` is `idle`, `restorable` (the Default Media Receiver, which `announce` puts back), `busy` (another app, playing or paused: an announce ends it) or `offline` (not connected, or not heard from yet); with the app, title, artist, volume, `via` (the group whose media a member is playing), `standby`, and a group's `members`. A group is as free as its least free connected member.
|
|
14
|
+
- `castFree(snapshot, targets)`: the targets an announcement would not disturb, as names for `announceAll`, and `skipped` with why (`busy`, `offline`, `standby`, `unknown`). A TV in standby is skipped, since a clip may wake it. A group stays a group while every connected member is free; one playing another app is skipped whole; otherwise a group with a busy member becomes its free members one by one (they then play out of step). A device is never named beside a free group that holds it. Both functions are pure, over the snapshot, and tested on built snapshots only: what a group's members report while it plays Spotify hasn't been checked live.
|
|
13
15
|
- Writes, on the object `cast()` returns (`CastActions`), over the sockets it already holds, never a second connection. A target is a name (any case) or a castId; volume is 0 to 1, as the snapshot's. `setVolume`, `setMuted` (receiver `SET_VOLUME`; a group's goes to the group's socket; per device, levels and mutes each at most 30 a minute, the newest waiting one winning; one device's lane never waits on another's) and `stop` (receiver `STOP` of the running app; nothing when idle). Each request waits 10 s for its reply and a refusal (`LAUNCH_ERROR`, `LOAD_FAILED`, …) rejects, naming it.
|
|
14
|
-
- `announce(target, clip, { volume?, timeoutMs? })`: `clip` is `{ url, type? }` or `{ bytes, type }`, bytes served through `serve` (`serveClip` from `@khalido/home/cast/serve`, passed to `cast()`). It reads the receiver status (and a Default Media Receiver's media status), for a group re-runs discovery first and reconnects if the leader moved, sets the volume if given and unmutes a muted target (as two requests: a `SET_VOLUME` takes a level or a mute, not both), launches the Default Media Receiver (`CC1AD845`), `LOAD`s the clip and waits for its media session to go `IDLE` with `FINISHED` (60 s plus the clip's length when the device reports it, or `timeoutMs`). Then, always: what the Default Media Receiver was playing is loaded again at its `currentTime`, an idle device gets `STOP`, then the volume and mute (a group's member by member, through each member's own socket, from the group's multizone status). Another app (Spotify, YouTube) is interrupted, as Google's own announcements do, and the result names it in `notRestored`. It returns `{ played, why?, restored, notRestored }` and throws only before anything changed (an unknown target, a socket down). A clip gets 15 s to reach `PLAYING`, then the name of its type goes in the failure, since a device that can't play it took 35-44 s to say so; a receiver error sooner carries its reason and code. `timeoutMs` replaces both waits. `announceAll(targets, clip, options)` does several at once, one result per device, an unreachable target its own `played: false`; a group and one of its members would clash, untried.
|
|
16
|
+
- `announce(target, clip, { volume?, timeoutMs? })`: `clip` is `{ url, type? }` or `{ bytes, type }`, bytes served through `serve` (`serveClip` from `@khalido/home/cast/serve`, passed to `cast()`). It reads the receiver status (and a Default Media Receiver's media status), for a group re-runs discovery first and reconnects if the leader moved, sets the volume if given and unmutes a muted target (as two requests: a `SET_VOLUME` takes a level or a mute, not both), launches the Default Media Receiver (`CC1AD845`), `LOAD`s the clip and waits for its media session to go `IDLE` with `FINISHED` (60 s plus the clip's length when the device reports it, or `timeoutMs`). Then, always: what the Default Media Receiver was playing is loaded again at its `currentTime`, an idle device gets `STOP`, then the volume and mute (a group's its own level first, then member by member, through each member's own socket, from the group's multizone status). Another app (Spotify, YouTube) is interrupted, as Google's own announcements do, and the result names it in `notRestored`. It returns `{ played, why?, restored, notRestored }` and throws only before anything changed (an unknown target, a socket down). A clip gets 15 s to reach `PLAYING`, then the name of its type goes in the failure, since a device that can't play it took 35-44 s to say so; a receiver error sooner carries its reason and code. `timeoutMs` replaces both waits. `announceAll(targets, clip, options)` does several at once, one result per device, an unreachable target its own `played: false`; a group and one of its members would clash, untried.
|
|
15
17
|
- `serveClip(bytes, type, { host?, signal? })` (`node:http`, Node-only like `tls.ts`): an address of this machine on the target's network (else the first non-internal IPv4), a random port and an unguessable path, `Content-Type` and `Content-Length`; the whole body to every GET and HEAD (a `Range` probe gets a full 200, which HTTP allows), so a player that probes before it fetches still gets the clip. It closes on `close()`, which `announce` calls once the clip has played, when `signal` aborts, or after 60 s. Not yet seen live: how the Default Media Receiver fetches, and the macOS local-network prompt the first serve may raise.
|
|
16
18
|
- CLI: `home cast announce --to <name> [--to <name>…] (--say "<text>" | --file <path>) [--volume 0-100]`, `home cast volume <name> <0-100>`, `home cast mute <name> on|off`, `home cast stop <name>`. `--say` runs `say -o <tmp>.m4a --file-format=m4af --data-format=aac` (without the data format, m4af holds uncompressed PCM), serves it as `audio/mp4` and deletes the file. The library never runs `say`.
|
|
17
19
|
- Config: one entry per device or group, `{ "connector": "cast", "name": "…", "castId": "…" }`, all folded into one `cast` integration; `home discover --write` fills them and refreshes a renamed entry's `name` (the same `castId` with a new `fn`), printed as `renamed`. No host: discovery gives every address.
|
|
@@ -28,7 +30,9 @@ CastV2 is TLS to port 8009 (a group: its own port), carrying length-prefixed pro
|
|
|
28
30
|
- No device sent a `PING` in 12 s of listening, so our 5 s ping is what keeps the socket's silence check honest.
|
|
29
31
|
- Media status first seen live 2026-11-09 (Bun 1.4.4): a paused title and artist on a TV.
|
|
30
32
|
- **First announcements, 2026-11-09, from a Pi on 0.1.1.** MP3 played on two audio-only speakers and two displays, 7-8 s each, the clip fetched from the Pi's LAN address, idle and volume restored. A 24 kHz mono WAV played on the displays and failed on both audio-only speakers, "the clip stopped: ERROR" after 35-44 s. One WAV format tried: use MP3 or AAC.
|
|
31
|
-
-
|
|
33
|
+
- **On 0.1.2, 2026-11-10, from the Pi (Bun 1.4.4; chota-bot's session, KO listening).** MP3 at volume 30 on one audio-only speaker: played and restored in 2 s. To a group of four: played in 9.5 s, each member back at its own level. `setVolume` and `setMuted` on one speaker read back as set. Over Spotify playing on the speaker: played, the volume restored, Spotify listed in `notRestored`, and KO had to pick the speaker again in the Spotify app.
|
|
34
|
+
- **A group's own level was not restored** (same run; fixed after 0.1.2, untried live): after the group announce at 30 the group read 30 (it had been 53) though every member was back; setting the group to 53 afterwards did not move the members. `restoreVolume` restored members only; since fixed: the group's own level goes back first, then each member's. Untried live.
|
|
35
|
+
- Not seen yet: `stop`, an announce over a Default Media Receiver stream, `announceAll`, a leader change, member changes, and Node's `tls` (no Node caller yet).
|
|
32
36
|
|
|
33
37
|
## TLS: verification off, the name checked
|
|
34
38
|
|
|
@@ -3,7 +3,13 @@ import { asOf, type Clock, realClock } from "../../clock.ts";
|
|
|
3
3
|
import { errorText } from "../../connector.ts";
|
|
4
4
|
import { type Found, HOUR, lostDetail, rescanDelay } from "../../discover.ts";
|
|
5
5
|
import { poller } from "../../poll.ts";
|
|
6
|
-
import
|
|
6
|
+
import {
|
|
7
|
+
type DeviceRecord,
|
|
8
|
+
deviceRecords,
|
|
9
|
+
type Integration,
|
|
10
|
+
type Part,
|
|
11
|
+
type Status,
|
|
12
|
+
} from "../../home.ts";
|
|
7
13
|
import type { Manifest } from "../../manifest.ts";
|
|
8
14
|
import { type CastChannel, type CastConnect, NS } from "./codec.ts";
|
|
9
15
|
import { CastPayload, KNOWN_TYPES } from "./schemas.ts";
|
|
@@ -16,6 +22,10 @@ import {
|
|
|
16
22
|
mediaApp,
|
|
17
23
|
reduce,
|
|
18
24
|
type Resolved,
|
|
25
|
+
castDevices,
|
|
26
|
+
type CastForm,
|
|
27
|
+
type CastStatus,
|
|
28
|
+
castStatus,
|
|
19
29
|
} from "./state.ts";
|
|
20
30
|
import {
|
|
21
31
|
type CastActions,
|
|
@@ -27,8 +37,15 @@ import {
|
|
|
27
37
|
} from "./writes.ts";
|
|
28
38
|
|
|
29
39
|
export type { CastConnect } from "./codec.ts";
|
|
30
|
-
export { castForm } from "./state.ts";
|
|
31
|
-
export type {
|
|
40
|
+
export { castDevices, castForm, castFree, castStatus } from "./state.ts";
|
|
41
|
+
export type {
|
|
42
|
+
CastDevice,
|
|
43
|
+
CastForm,
|
|
44
|
+
CastSkipped,
|
|
45
|
+
CastSnapshot,
|
|
46
|
+
CastStatus,
|
|
47
|
+
Resolved,
|
|
48
|
+
} from "./state.ts";
|
|
32
49
|
export type {
|
|
33
50
|
AnnounceResult,
|
|
34
51
|
CastActions,
|
|
@@ -536,11 +553,46 @@ export function castText(d: CastDevice, s: CastSnapshot): string {
|
|
|
536
553
|
return `${d.name}: ${parts.filter(Boolean).join(" · ")}`;
|
|
537
554
|
}
|
|
538
555
|
|
|
539
|
-
/**
|
|
540
|
-
export
|
|
541
|
-
|
|
542
|
-
|
|
556
|
+
/** 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`. */
|
|
557
|
+
export type CastHomeDevice = DeviceRecord<
|
|
558
|
+
"cast",
|
|
559
|
+
CastForm | "cast" | "group",
|
|
560
|
+
CastStatus["state"],
|
|
561
|
+
CastStatus
|
|
562
|
+
>;
|
|
563
|
+
|
|
564
|
+
/** Every Cast device and group as a home device, devices first, each by name. */
|
|
565
|
+
export function castHomeDevices(
|
|
566
|
+
part: Part<CastSnapshot> | undefined,
|
|
567
|
+
): CastHomeDevice[] {
|
|
568
|
+
const s = part?.state;
|
|
569
|
+
if (!s) return [];
|
|
570
|
+
return deviceRecords(
|
|
571
|
+
part,
|
|
572
|
+
castStatus(s).map((detail): CastHomeDevice => {
|
|
573
|
+
const d = s.devices[detail.id]!;
|
|
574
|
+
// Connected and heard from: available. Connecting, or connected with no status yet: not known.
|
|
575
|
+
const available =
|
|
576
|
+
d.status === "disconnected"
|
|
577
|
+
? false
|
|
578
|
+
: d.status === "connected" && detail.state !== "offline"
|
|
579
|
+
? true
|
|
580
|
+
: undefined;
|
|
581
|
+
return {
|
|
582
|
+
connector: "cast",
|
|
583
|
+
id: d.id,
|
|
584
|
+
name: d.name,
|
|
585
|
+
kind: d.kind === "group" ? "group" : (d.form ?? "cast"),
|
|
586
|
+
...(available === undefined ? {} : { available }),
|
|
587
|
+
...(d.asOf ? { asOf: d.asOf } : {}), // never answered: no time to give
|
|
588
|
+
state: detail.state,
|
|
589
|
+
text: castText(d, s),
|
|
590
|
+
...(d.members ? { members: d.members } : {}),
|
|
591
|
+
detail,
|
|
592
|
+
};
|
|
593
|
+
}),
|
|
543
594
|
);
|
|
595
|
+
}
|
|
544
596
|
|
|
545
597
|
/** How many entries have an address, and one line per device and group. */
|
|
546
598
|
const castView = (s: CastSnapshot) => ({
|
|
@@ -213,3 +213,180 @@ function link(s: CastSnapshot): CastSnapshot {
|
|
|
213
213
|
if (d.playingVia === undefined) delete d.playingVia;
|
|
214
214
|
return { devices, asOf: s.asOf };
|
|
215
215
|
}
|
|
216
|
+
|
|
217
|
+
/** Google's own player for a URL of audio or video: what `announce` plays a clip with, and can put back. */
|
|
218
|
+
export const DEFAULT_MEDIA_RECEIVER = "CC1AD845";
|
|
219
|
+
|
|
220
|
+
/** Devices first, then groups, each by name. */
|
|
221
|
+
export const castDevices = (s: CastSnapshot) =>
|
|
222
|
+
Object.values(s.devices).sort(
|
|
223
|
+
(a, b) => a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name),
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
/** A target is a castId, else a name in any case. `announce` and `castFree` find it the same way. */
|
|
227
|
+
export const findCast = (
|
|
228
|
+
devices: Record<string, CastDevice>,
|
|
229
|
+
target: string,
|
|
230
|
+
): CastDevice | undefined => {
|
|
231
|
+
const all = Object.values(devices);
|
|
232
|
+
return (
|
|
233
|
+
all.find((x) => x.id === castKey(target)) ??
|
|
234
|
+
all.find((x) => x.name.toLowerCase() === target.toLowerCase())
|
|
235
|
+
);
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* What a device or group is doing, for a caller choosing where to play something. `state`:
|
|
240
|
+
* - `idle`: no app, or the idle screen.
|
|
241
|
+
* - `restorable`: the Default Media Receiver, whose clip `announce` puts back at its position.
|
|
242
|
+
* - `busy`: another app, playing or paused. An announce ends it and nothing here can resume it.
|
|
243
|
+
* - `offline`: not connected, or connected and not heard from yet.
|
|
244
|
+
* A member playing its group's media takes the group's state, with `via` naming the group and `app` the group's. A group
|
|
245
|
+
* is as free as its least free connected member. `standby` is a TV that is off or on another input: `idle` by its app,
|
|
246
|
+
* but a clip may wake it.
|
|
247
|
+
*/
|
|
248
|
+
export type CastStatus = {
|
|
249
|
+
id: string;
|
|
250
|
+
name: string;
|
|
251
|
+
kind: "device" | "group";
|
|
252
|
+
state: "idle" | "restorable" | "busy" | "offline";
|
|
253
|
+
app?: string;
|
|
254
|
+
playerState?: string;
|
|
255
|
+
title?: string;
|
|
256
|
+
artist?: string;
|
|
257
|
+
via?: string;
|
|
258
|
+
volume?: number;
|
|
259
|
+
muted?: boolean;
|
|
260
|
+
standby?: boolean;
|
|
261
|
+
members?: CastStatus[]; // groups: each member the snapshot has
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const RANK = { idle: 0, restorable: 1, busy: 2, offline: 3 } as const;
|
|
265
|
+
|
|
266
|
+
/** By its own app alone: not its group's media, not its members. */
|
|
267
|
+
const ownState = (d: CastDevice): CastStatus["state"] =>
|
|
268
|
+
d.status !== "connected" || d.idle === undefined
|
|
269
|
+
? "offline"
|
|
270
|
+
: d.idle
|
|
271
|
+
? "idle"
|
|
272
|
+
: d.appId === DEFAULT_MEDIA_RECEIVER
|
|
273
|
+
? "restorable"
|
|
274
|
+
: "busy";
|
|
275
|
+
|
|
276
|
+
/** Every device and group in the snapshot as a `CastStatus`, devices first, each by name. */
|
|
277
|
+
export function castStatus(s: CastSnapshot): CastStatus[] {
|
|
278
|
+
const one = (d: CastDevice): CastStatus => {
|
|
279
|
+
const group = d.playingVia ? s.devices[d.playingVia] : undefined;
|
|
280
|
+
const own = ownState(d);
|
|
281
|
+
// Its group's media is playing on it: the group's app decides, and an unknown one counts as busy.
|
|
282
|
+
const self =
|
|
283
|
+
own === "offline" || !d.playingVia
|
|
284
|
+
? own
|
|
285
|
+
: group && ownState(group) === "restorable"
|
|
286
|
+
? "restorable"
|
|
287
|
+
: "busy";
|
|
288
|
+
const members =
|
|
289
|
+
d.kind === "group"
|
|
290
|
+
? (d.members ?? []).flatMap((id) =>
|
|
291
|
+
s.devices[id] ? [one(s.devices[id])] : [],
|
|
292
|
+
)
|
|
293
|
+
: undefined;
|
|
294
|
+
const state =
|
|
295
|
+
self === "offline"
|
|
296
|
+
? self
|
|
297
|
+
: (members ?? [])
|
|
298
|
+
.map((m) => m.state)
|
|
299
|
+
.filter((m) => m !== "offline")
|
|
300
|
+
.reduce((a, b) => (RANK[b] > RANK[a] ? b : a), self);
|
|
301
|
+
const status: CastStatus = {
|
|
302
|
+
id: d.id,
|
|
303
|
+
name: d.name,
|
|
304
|
+
kind: d.kind,
|
|
305
|
+
state,
|
|
306
|
+
app:
|
|
307
|
+
d.idle === false
|
|
308
|
+
? d.app
|
|
309
|
+
: group?.idle === false
|
|
310
|
+
? group.app
|
|
311
|
+
: undefined,
|
|
312
|
+
playerState: d.playerState,
|
|
313
|
+
title: d.title,
|
|
314
|
+
artist: d.artist,
|
|
315
|
+
via: d.playingVia && (group?.name ?? d.playingVia),
|
|
316
|
+
volume: d.volume,
|
|
317
|
+
muted: d.muted,
|
|
318
|
+
standby: d.standby,
|
|
319
|
+
members,
|
|
320
|
+
};
|
|
321
|
+
for (const k of Object.keys(status) as (keyof CastStatus)[])
|
|
322
|
+
if (status[k] === undefined) delete status[k];
|
|
323
|
+
return status;
|
|
324
|
+
};
|
|
325
|
+
return castDevices(s).map(one);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** A target `castFree` left out: `standby` is a TV that is off, which a clip may wake. */
|
|
329
|
+
export type CastSkipped = {
|
|
330
|
+
name: string;
|
|
331
|
+
why: "busy" | "offline" | "standby" | "unknown";
|
|
332
|
+
app?: string;
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Of these targets (names in any case, or ids), the ones an announcement would not disturb: `idle` or `restorable`, and
|
|
337
|
+
* not a TV in standby. `free` is names, for `announceAll`; `skipped` says which were left out and why.
|
|
338
|
+
* - A group stays a group while every connected member is free. An offline member doesn't split it: the group plays without it.
|
|
339
|
+
* - A group playing another app is skipped whole. Otherwise a group with a busy member, or one whose own socket is down,
|
|
340
|
+
* becomes its free members, named one by one (they then play out of step with each other).
|
|
341
|
+
* - A device is never named beside a free group that holds it, nor two groups that share a member: the second becomes its
|
|
342
|
+
* members not yet covered.
|
|
343
|
+
*/
|
|
344
|
+
export function castFree(
|
|
345
|
+
s: CastSnapshot,
|
|
346
|
+
targets: string[],
|
|
347
|
+
): { free: string[]; skipped: CastSkipped[] } {
|
|
348
|
+
const status = new Map(castStatus(s).map((c) => [c.id, c]));
|
|
349
|
+
const free = new Map<string, CastStatus>();
|
|
350
|
+
const covered = new Set<string>(); // members of the groups in `free`
|
|
351
|
+
const skipped = new Map<string, CastSkipped>();
|
|
352
|
+
const ok = (c: CastStatus) =>
|
|
353
|
+
(c.state === "idle" || c.state === "restorable") && !c.standby;
|
|
354
|
+
const skip = (c: CastStatus) =>
|
|
355
|
+
skipped.set(c.id, {
|
|
356
|
+
name: c.name,
|
|
357
|
+
why:
|
|
358
|
+
c.state === "offline"
|
|
359
|
+
? "offline"
|
|
360
|
+
: c.standby && c.state !== "busy"
|
|
361
|
+
? "standby"
|
|
362
|
+
: "busy",
|
|
363
|
+
...(c.app ? { app: c.app } : {}),
|
|
364
|
+
});
|
|
365
|
+
const device = (c: CastStatus) => (ok(c) ? free.set(c.id, c) : skip(c));
|
|
366
|
+
for (const target of targets) {
|
|
367
|
+
const d = findCast(s.devices, target);
|
|
368
|
+
const c = d && status.get(d.id);
|
|
369
|
+
if (!d || !c) skipped.set(target, { name: target, why: "unknown" });
|
|
370
|
+
else if (c.kind === "device") device(c);
|
|
371
|
+
else {
|
|
372
|
+
const members = c.members ?? [];
|
|
373
|
+
const own = ownState(d);
|
|
374
|
+
const up = members.filter((m) => m.state !== "offline");
|
|
375
|
+
if (own === "busy" || (own === "offline" && !members.length)) skip(c);
|
|
376
|
+
else if (
|
|
377
|
+
own !== "offline" &&
|
|
378
|
+
up.every(ok) &&
|
|
379
|
+
!members.some((m) => covered.has(m.id))
|
|
380
|
+
) {
|
|
381
|
+
free.set(c.id, c);
|
|
382
|
+
for (const m of members) covered.add(m.id);
|
|
383
|
+
} else for (const m of members) device(m);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return {
|
|
387
|
+
free: [...free.values()]
|
|
388
|
+
.filter((c) => c.kind === "group" || !covered.has(c.id))
|
|
389
|
+
.map((c) => c.name),
|
|
390
|
+
skipped: [...skipped.values()],
|
|
391
|
+
};
|
|
392
|
+
}
|