@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.
Files changed (88) hide show
  1. package/CHANGELOG.md +60 -2
  2. package/README.md +12 -12
  3. package/dist/clock.js +2 -0
  4. package/dist/config.js +3 -1
  5. package/dist/connector.d.ts +9 -2
  6. package/dist/connectors/amber/index.js +1 -0
  7. package/dist/connectors/cast/index.d.ts +10 -5
  8. package/dist/connectors/cast/index.js +36 -4
  9. package/dist/connectors/cast/schemas.d.ts +2 -0
  10. package/dist/connectors/cast/schemas.js +1 -0
  11. package/dist/connectors/cast/state.d.ts +56 -0
  12. package/dist/connectors/cast/state.js +134 -0
  13. package/dist/connectors/cast/writes.d.ts +9 -2
  14. package/dist/connectors/cast/writes.js +67 -13
  15. package/dist/connectors/daikin/index.d.ts +6 -2
  16. package/dist/connectors/daikin/index.js +21 -1
  17. package/dist/connectors/enphase/index.d.ts +5 -1
  18. package/dist/connectors/enphase/index.js +34 -2
  19. package/dist/connectors/hue/index.d.ts +6 -2
  20. package/dist/connectors/hue/index.js +39 -6
  21. package/dist/connectors/printers/index.d.ts +6 -2
  22. package/dist/connectors/printers/index.js +23 -0
  23. package/dist/connectors/spotify/changes.d.ts +1 -1
  24. package/dist/connectors/spotify/changes.js +4 -2
  25. package/dist/connectors/spotify/client.d.ts +1 -1
  26. package/dist/connectors/spotify/index.d.ts +9 -2
  27. package/dist/connectors/spotify/index.js +130 -45
  28. package/dist/connectors/up/index.d.ts +4 -2
  29. package/dist/connectors/up/index.js +3 -1
  30. package/dist/connectors/weather/bom.js +1 -0
  31. package/dist/connectors/weather/google.js +52 -9
  32. package/dist/connectors/weather/index.d.ts +2 -1
  33. package/dist/connectors/weather/index.js +7 -0
  34. package/dist/connectors/weather/source.d.ts +2 -0
  35. package/dist/devices.d.ts +27 -0
  36. package/dist/devices.js +61 -0
  37. package/dist/discover.d.ts +2 -0
  38. package/dist/discover.js +4 -1
  39. package/dist/health.d.ts +2 -1
  40. package/dist/health.js +2 -1
  41. package/dist/home.d.ts +41 -3
  42. package/dist/home.js +24 -7
  43. package/dist/index.d.ts +3 -2
  44. package/dist/index.js +2 -1
  45. package/dist/lane.js +24 -20
  46. package/docs/cast.md +1 -1
  47. package/docs/cli.md +3 -3
  48. package/docs/config.md +8 -3
  49. package/docs/decisions.md +5 -0
  50. package/docs/using.md +17 -0
  51. package/package.json +1 -1
  52. package/skills/use-home/SKILL.md +7 -7
  53. package/src/cli/cast.ts +19 -11
  54. package/src/cli/enphase.ts +5 -1
  55. package/src/cli/spotify.ts +2 -1
  56. package/src/cli/status.ts +26 -3
  57. package/src/clock.ts +1 -0
  58. package/src/config.ts +3 -1
  59. package/src/connector.ts +18 -2
  60. package/src/connectors/amber/index.ts +1 -0
  61. package/src/connectors/cast/README.md +10 -5
  62. package/src/connectors/cast/index.ts +74 -9
  63. package/src/connectors/cast/schemas.ts +1 -0
  64. package/src/connectors/cast/state.ts +189 -0
  65. package/src/connectors/cast/writes.ts +87 -17
  66. package/src/connectors/daikin/README.md +4 -0
  67. package/src/connectors/daikin/index.ts +41 -2
  68. package/src/connectors/enphase/index.ts +49 -2
  69. package/src/connectors/hue/index.ts +57 -8
  70. package/src/connectors/printers/README.md +4 -2
  71. package/src/connectors/printers/index.ts +43 -1
  72. package/src/connectors/spotify/README.md +33 -9
  73. package/src/connectors/spotify/changes.ts +4 -2
  74. package/src/connectors/spotify/client.ts +1 -1
  75. package/src/connectors/spotify/index.ts +157 -49
  76. package/src/connectors/up/README.md +1 -1
  77. package/src/connectors/up/index.ts +6 -2
  78. package/src/connectors/weather/README.md +1 -1
  79. package/src/connectors/weather/bom.ts +1 -0
  80. package/src/connectors/weather/google.ts +66 -10
  81. package/src/connectors/weather/index.ts +16 -2
  82. package/src/connectors/weather/source.ts +2 -0
  83. package/src/devices.ts +102 -0
  84. package/src/discover.ts +6 -2
  85. package/src/health.ts +4 -2
  86. package/src/home.ts +75 -8
  87. package/src/index.ts +5 -1
  88. package/src/lane.ts +23 -20
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 all = loadHome(readConfig(path), options);
154
- const integrations =
155
- only === undefined ? all : all.filter((i) => i.name === only);
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
@@ -9,6 +9,7 @@ export const realClock: Clock = {
9
9
  now: () => Date.now(),
10
10
  sleep: (ms, signal) =>
11
11
  new Promise((resolve) => {
12
+ if (signal?.aborted) return resolve();
12
13
  const done = () => (
13
14
  clearTimeout(timer),
14
15
  signal?.removeEventListener("abort", done),
package/src/config.ts CHANGED
@@ -509,7 +509,9 @@ export function loadHome(
509
509
  {
510
510
  castId: x.castId,
511
511
  name: x.name,
512
- ...(c?.port === undefined ? {} : { host: c.host, port: c.port }),
512
+ ...(c?.port === undefined
513
+ ? {}
514
+ : { host: c.host, port: c.port, model: c.model }),
513
515
  },
514
516
  ];
515
517
  });
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?: string;
39
+ readonly code?: ConnectorCode;
24
40
  constructor(
25
41
  message: string,
26
- options: { status?: number; code?: string; cause?: unknown } = {},
42
+ options: { status?: number; code?: ConnectorCode; cause?: unknown } = {},
27
43
  ) {
28
44
  super(
29
45
  message,
@@ -65,6 +65,7 @@ export function amber(
65
65
  if (error instanceof AmberError && error.status === 401)
66
66
  return update({
67
67
  status: "unavailable",
68
+ reason: "auth",
68
69
  detail: "Amber rejected the key",
69
70
  state: last,
70
71
  });
@@ -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 so far tried only on a scripted fake socket. The design and what comes next (casting a page) are in [docs/cast.md](../../../docs/cast.md).
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
 
@@ -8,12 +8,14 @@ Nest speakers, Nest Hubs, Chromecasts, Google TV and speaker groups, read over C
8
8
  - Per socket: `CONNECT` to `receiver-0`, receiver `GET_STATUS`, multizone `GET_STATUS` (a group's members; on a device's own port, the device alone, by its current name), then whatever the device pushes. A running app with the media namespace gets a `CONNECT` and media `GET_STATUS` on its `transportId`, as Home Assistant does. A device's `PING` is answered with `PONG`; a socket silent for three heartbeats (15 s), or without a receiver status 10 s after connecting, is closed and the detail says which.
9
9
  - A dropped socket reconnects with backoff (1 s doubling to 60 s) at whatever address discovery last gave. The drop starts one shared `resolve()` at once (a group's address changes with its leader); while anything stays down or missing, that one discovery repeats at 1, 5 and 15 minutes, then hourly, as the relocator does, never once per socket. `refresh()` re-asks every open socket and nudges the discovery, which also finds devices when nothing was found at start.
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
- - State (`CastSnapshot`, by mDNS id): 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).
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).
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
- - CLI: `home cast announce --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`.
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.
18
20
 
19
21
  ## Protocol
@@ -27,7 +29,10 @@ CastV2 is TLS to port 8009 (a group: its own port), carrying length-prefixed pro
27
29
  - **Receiver status**: hubs and the Chromecast Ultra run Backdrop (`E8C28D3C`) with `isIdleScreen: true`; the speakers report no application; the TV Streamer reports `isStandBy: true`, `isActiveInput: false` and a `fixed` volume; the Ultra's volume is `attenuation`.
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
- - Not seen yet: a group playing, a leader change, member changes, and Node's `tls` (no Node caller yet).
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.
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).
31
36
 
32
37
  ## TLS: verification off, the name checked
33
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 type { Integration, Part, Status } from "../../home.ts";
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,7 +37,15 @@ import {
27
37
  } from "./writes.ts";
28
38
 
29
39
  export type { CastConnect } from "./codec.ts";
30
- export type { CastDevice, CastSnapshot, Resolved } from "./state.ts";
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";
31
49
  export type {
32
50
  AnnounceResult,
33
51
  CastActions,
@@ -77,11 +95,17 @@ export type CastConfig = {
77
95
  * `host` and `port` (an address cache) an entry connects at once; discovery runs only when an entry has none, or a
78
96
  * cached address fails. A cached port other than 8009 is a group's (devices always listen on 8009).
79
97
  */
80
- expect?: { castId: string; name: string; host?: string; port?: number }[];
98
+ expect?: {
99
+ castId: string;
100
+ name: string;
101
+ model?: string;
102
+ host?: string;
103
+ port?: number;
104
+ }[];
81
105
  /** Each address that connected and passed the certificate check, for an address cache; again with the device's new name when it reports a rename. `name` is the device's own, never a config's. */
82
106
  seen?: (
83
107
  id: string,
84
- address: { host: string; port: number; name?: string },
108
+ address: { host: string; port: number; name?: string; model?: string },
85
109
  ) => void;
86
110
  clock?: Clock;
87
111
  /** Heartbeat interval; a socket silent for three of them is dropped. */
@@ -304,6 +328,7 @@ export function cast(
304
328
  host: d.host!,
305
329
  port: d.port!,
306
330
  name: named.has(id) ? d.name : undefined,
331
+ model: d.model,
307
332
  }); // a config's name never reaches the cache
308
333
  publish();
309
334
  void (async () => {
@@ -468,14 +493,19 @@ export function cast(
468
493
  report = update;
469
494
  running = signal;
470
495
  const expect = config.expect ?? [];
496
+ // A device with no model (a cache from before 0.1.2) goes through discovery once, or it would never get a `form`.
471
497
  const cached = expect.filter(
472
- (e) => e.host !== undefined && e.port !== undefined,
498
+ (e) =>
499
+ e.host !== undefined &&
500
+ e.port !== undefined &&
501
+ (e.port !== 8009 || e.model !== undefined),
473
502
  );
474
503
  snapshot = found(
475
504
  snapshot,
476
505
  cached.map((e) => ({
477
506
  id: e.castId,
478
507
  name: e.name,
508
+ model: e.model,
479
509
  host: e.host!,
480
510
  port: e.port!,
481
511
  group: e.port !== 8009,
@@ -523,11 +553,46 @@ export function castText(d: CastDevice, s: CastSnapshot): string {
523
553
  return `${d.name}: ${parts.filter(Boolean).join(" · ")}`;
524
554
  }
525
555
 
526
- /** Devices first, then groups, each by name. */
527
- export const castDevices = (s: CastSnapshot) =>
528
- Object.values(s.devices).sort(
529
- (a, b) => a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name),
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
+ }),
530
594
  );
595
+ }
531
596
 
532
597
  /** How many entries have an address, and one line per device and group. */
533
598
  const castView = (s: CastSnapshot) => ({
@@ -43,6 +43,7 @@ export const MediaStatus = z.looseObject({
43
43
  mediaSessionId: z.number().optional(),
44
44
  playerState: z.string().optional(), // "PLAYING" | "PAUSED" | "BUFFERING" | "IDLE"
45
45
  idleReason: z.string().optional(), // "FINISHED" | "CANCELLED" | "INTERRUPTED" | "ERROR"
46
+ detailedErrorCode: z.number().optional(),
46
47
  currentTime: z.number().optional(), // seconds into the media
47
48
  media: z
48
49
  .looseObject({
@@ -1,5 +1,15 @@
1
1
  import type { CastPayload } from "./schemas.ts";
2
2
 
3
+ /** What a device is for, read from its model name; a model it doesn't know has none. A group is `kind: "group"`. */
4
+ export type CastForm = "speaker" | "display" | "tv";
5
+ export function castForm(model?: string): CastForm | undefined {
6
+ if (!model) return undefined;
7
+ if (/hub|tablet|display/i.test(model)) return "display";
8
+ if (/audio|speaker|mini|google home/i.test(model)) return "speaker";
9
+ if (/chromecast|streamer|\btv\b/i.test(model)) return "tv";
10
+ return undefined;
11
+ }
12
+
3
13
  /** A Cast device or group as found by discovery: what `resolve` returns for each. */
4
14
  export type Resolved = {
5
15
  id: string;
@@ -18,6 +28,7 @@ export type CastDevice = {
18
28
  id: string;
19
29
  name: string;
20
30
  model?: string;
31
+ form?: CastForm; // devices: speaker, display or tv, when the model is one it knows
21
32
  kind: "device" | "group";
22
33
  host?: string;
23
34
  port?: number;
@@ -63,6 +74,7 @@ export function found(
63
74
  id,
64
75
  name: e.name,
65
76
  model: e.model,
77
+ form: kind === "device" ? castForm(e.model) : undefined,
66
78
  kind,
67
79
  host: e.host,
68
80
  port: e.port,
@@ -201,3 +213,180 @@ function link(s: CastSnapshot): CastSnapshot {
201
213
  if (d.playingVia === undefined) delete d.playingVia;
202
214
  return { devices, asOf: s.asOf };
203
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
+ }