@khalido/home 0.2.1 → 0.2.2

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.
@@ -183,12 +183,31 @@ export function spotify(config = {}) {
183
183
  * Soloist accepted the command that carried it (a merged write's may be another's: a pause merged into a later play never
184
184
  * confirms). A drop ends the wait early, unconfirmed.
185
185
  */
186
- const change = async (slot, body) => {
186
+ const change = async (slot, body, options = {}) => {
187
+ if (options.dryRun)
188
+ return dry(body);
187
189
  const sent = await write(slot, body);
188
190
  const took = () => body.took?.(state, sent.before, sent.at) ?? true;
189
191
  await waitFor(() => took() || !isOpen(), CONFIRM_MS);
190
192
  return { state, confirmed: took() };
191
193
  };
194
+ /** A dry run: `command`'s own checks (connected, signed in), then the commands `send` would send, in order. */
195
+ const dry = (body) => {
196
+ if (!socket || !isOpen() || socket.readyState !== OPEN)
197
+ throw offline();
198
+ if (state.loggedIn === false)
199
+ throw new ConnectorError(WAITING, { code: "waiting" });
200
+ const one = (command, fields) => fields ? { command, fields } : { command };
201
+ return {
202
+ state,
203
+ confirmed: false,
204
+ wouldSend: [
205
+ ...(body.activeFirst && inactive() ? [one("activate")] : []),
206
+ one(body.command, body.fields),
207
+ ...(body.then ? [one(body.then.command, body.then.fields)] : []),
208
+ ],
209
+ };
210
+ };
192
211
  /** The track restarted: a new anchor near 0 when it had been 5 s or more in (a prev past the first seconds, a next on repeat-track). */
193
212
  const restarted = (s, b, at) => s.position !== b.position &&
194
213
  (s.position?.ms ?? Infinity) < 5000 &&
@@ -196,11 +215,11 @@ export function spotify(config = {}) {
196
215
  const newTrack = (s, b) => !!s.track && s.track.uri !== b.track?.uri;
197
216
  const invalid = (message) => new ConnectorError(message, { code: "invalid" });
198
217
  const near = (volume, v) => volume !== undefined && Math.abs(volume - v) <= VOLUME_SLACK;
199
- const volumeTo = (v) => change("volume", {
218
+ const volumeTo = (v, options) => change("volume", {
200
219
  command: "set_volume",
201
220
  fields: { volume: v },
202
221
  took: (s) => near(s.volume, v),
203
- });
222
+ }, options);
204
223
  /** Soloist accepts a `set_volume` while it isn't the active device and drops it (three times live, 2026-11-09). */
205
224
  const inactive = () => isOpen() && state.loggedIn !== false && state.active === false;
206
225
  /** Ask Soloist for something, through the lane; the same query twice merges. A refusal is an anomaly, a drop is the status's to say. */
@@ -473,6 +492,27 @@ export function spotify(config = {}) {
473
492
  asides = p.catch(() => { });
474
493
  return p;
475
494
  };
495
+ /** Why `interrupt` would leave the music alone, if it would. */
496
+ const interruptSkip = () => notPlaying() ??
497
+ // "playing" while inactive is the account playing on another speaker (live, 2026-11-10): a pause would stop that one.
498
+ (inactive()
499
+ ? "it is not the active device, so the music playing is on another speaker"
500
+ : undefined);
501
+ /** Why `duck` would leave the volume alone, if it would. */
502
+ const duckSkip = (to) => notPlaying() ??
503
+ (inactive()
504
+ ? "it is not the active device, so its volume can't be changed"
505
+ : state.volume === undefined
506
+ ? "its volume is not known"
507
+ : state.volume <= to
508
+ ? `it was already at ${state.volume}`
509
+ : undefined);
510
+ /** A dry `interrupt` or `duck`: `fn` runs, the music is not touched, and `why` says what would have happened to it. */
511
+ const dryAside = async (fn, skip, would) => ({
512
+ value: await fn(),
513
+ music: "untouched",
514
+ why: `dry run: ${skip ? `${skip}, so the music would be left alone` : `would ${would}`}`,
515
+ });
476
516
  const putBack = (r, what) => r.confirmed
477
517
  ? { music: "restored" }
478
518
  : {
@@ -480,22 +520,23 @@ export function spotify(config = {}) {
480
520
  why: failed(`sent the ${what}; Soloist has not reported it`),
481
521
  };
482
522
  const actions = {
483
- async activate() {
523
+ async activate(options) {
484
524
  if (isOpen() && state.loggedIn !== false && state.active === true)
485
- return { state, confirmed: true }; // a second activate might make a new stream
486
- return change("activate", {
487
- command: "activate",
488
- took: (s) => s.active === true,
489
- });
525
+ return {
526
+ state,
527
+ confirmed: true,
528
+ ...(options?.dryRun ? { wouldSend: [] } : {}),
529
+ }; // a second activate might make a new stream
530
+ return change("activate", { command: "activate", took: (s) => s.active === true }, options);
490
531
  },
491
532
  // A `play` while inactive didn't take live; going active first is untested for this. `resume` worked inactive, so it doesn't.
492
- async play(linkOrUri) {
533
+ async play(linkOrUri, options) {
493
534
  if (linkOrUri === undefined)
494
535
  return change("transport", {
495
536
  command: "play",
496
537
  activeFirst: true,
497
538
  took: (s) => s.status === "playing",
498
- });
539
+ }, options);
499
540
  const uri = toSpotifyUri(linkOrUri);
500
541
  if (!uri)
501
542
  throw invalid(`not a Spotify link or URI: ${linkOrUri}`);
@@ -505,28 +546,22 @@ export function spotify(config = {}) {
505
546
  activeFirst: true,
506
547
  took: (s) => s.status === "playing" &&
507
548
  (s.track?.uri === uri || s.context?.uri === uri),
508
- });
549
+ }, options);
509
550
  },
510
- pause: () => change("transport", {
511
- command: "pause",
512
- took: (s) => s.status === "paused",
513
- }),
514
- resume: () => change("transport", {
515
- command: "play",
516
- took: (s) => s.status === "playing",
517
- }),
551
+ pause: (options) => change("transport", { command: "pause", took: (s) => s.status === "paused" }, options),
552
+ resume: (options) => change("transport", { command: "play", took: (s) => s.status === "playing" }, options),
518
553
  // The end of the queue goes idle; repeat-track restarts it.
519
- next: () => change(`once-${++once}`, {
554
+ next: (options) => change(`once-${++once}`, {
520
555
  command: "skip_next",
521
556
  took: (s, b, at) => newTrack(s, b) ||
522
557
  (s.status === "idle" && b.status !== "idle") ||
523
558
  restarted(s, b, at),
524
- }),
525
- prev: () => change(`once-${++once}`, {
559
+ }, options),
560
+ prev: (options) => change(`once-${++once}`, {
526
561
  command: "skip_prev",
527
562
  took: (s, b, at) => newTrack(s, b) || restarted(s, b, at),
528
- }),
529
- async seek(ms) {
563
+ }, options),
564
+ async seek(ms, options) {
530
565
  if (!Number.isFinite(ms) || ms < 0)
531
566
  throw invalid(`a position in ms, 0 or more, not ${ms}`);
532
567
  return change("seek", {
@@ -534,16 +569,16 @@ export function spotify(config = {}) {
534
569
  fields: { position_ms: ms },
535
570
  took: (s, b) => s.position !== b.position &&
536
571
  Math.abs((s.position?.ms ?? -Infinity) - ms) < 2000,
537
- });
572
+ }, options);
538
573
  },
539
- async setVolume(volume) {
574
+ async setVolume(volume, options) {
540
575
  if (!Number.isFinite(volume) || volume < 0 || volume > 100)
541
576
  throw invalid(`volume is 0–100, not ${volume}`);
542
577
  if (inactive())
543
578
  throw new ConnectorError("the speaker is not the active Spotify device, so Soloist would drop this; activate it first (`home spotify activate`; its volume may jump to the level last saved)", { code: "inactive" });
544
- return volumeTo(Math.round(cap === undefined ? volume : Math.min(volume, cap)));
579
+ return volumeTo(Math.round(cap === undefined ? volume : Math.min(volume, cap)), options);
545
580
  },
546
- async queue(track) {
581
+ async queue(track, options) {
547
582
  const uri = toSpotifyUri(track);
548
583
  if (!uri?.startsWith("spotify:track:"))
549
584
  throw invalid(`only a Spotify track can be queued, not ${track}`);
@@ -551,14 +586,14 @@ export function spotify(config = {}) {
551
586
  command: "add_to_queue",
552
587
  fields: { uri },
553
588
  took: (s) => !!s.upNext?.some((t) => t.uri === uri),
554
- });
589
+ }, options);
555
590
  },
556
- setShuffle: (on) => change("shuffle", {
591
+ setShuffle: (on, options) => change("shuffle", {
557
592
  command: "set_shuffle",
558
593
  fields: { enabled: on },
559
594
  took: (s) => s.shuffle === on,
560
- }),
561
- async setRepeat(mode) {
595
+ }, options),
596
+ async setRepeat(mode, options) {
562
597
  // developer.spotify.com/documentation/soloist/reference/websocket-api: clear the other mode first, then set this one.
563
598
  if (!Object.hasOwn(REPEAT_ORDER, mode))
564
599
  throw invalid(`repeat is off, context or track, not ${mode}`);
@@ -568,18 +603,17 @@ export function spotify(config = {}) {
568
603
  fields: { enabled: first[1] },
569
604
  then: { command: second[0], fields: { enabled: second[1] } },
570
605
  took: (s) => s.repeat === mode,
571
- });
606
+ }, options);
572
607
  },
573
- interrupt(fn) {
608
+ interrupt(fn, options) {
609
+ if (options?.dryRun)
610
+ return dryAside(fn, interruptSkip(), "pause the music, then resume it");
574
611
  let track;
575
612
  let shown = false; // the pause showed
576
613
  return aside("interrupt", fn, async () => {
577
- const no = notPlaying();
614
+ const no = interruptSkip();
578
615
  if (no)
579
616
  return no;
580
- // "playing" while inactive is the account playing on another speaker (live, 2026-11-10): a pause would stop that one.
581
- if (inactive())
582
- return "it is not the active device, so the music playing is on another speaker";
583
617
  track = state.track?.uri;
584
618
  try {
585
619
  shown = (await actions.pause()).confirmed; // a pause that never shows still runs fn: what interrupts matters more
@@ -608,21 +642,17 @@ export function spotify(config = {}) {
608
642
  }
609
643
  });
610
644
  },
611
- duck(fn, { to }) {
645
+ duck(fn, { to, dryRun }) {
612
646
  if (!Number.isFinite(to) || to < 0 || to > 100)
613
647
  return Promise.reject(invalid(`duck's volume is 0–100, not ${to}`));
648
+ if (dryRun)
649
+ return dryAside(fn, duckSkip(to), `lower the volume from ${state.volume} to ${Math.round(to)}, then put it back`);
614
650
  let earlier = 0;
615
651
  let shown = false; // the lowered volume showed
616
652
  return aside("duck", fn, async () => {
617
- const no = notPlaying();
653
+ const no = duckSkip(to);
618
654
  if (no)
619
655
  return no;
620
- if (inactive())
621
- return "it is not the active device, so its volume can't be changed";
622
- if (state.volume === undefined)
623
- return "its volume is not known";
624
- if (state.volume <= to)
625
- return `it was already at ${state.volume}`;
626
656
  earlier = state.volume;
627
657
  try {
628
658
  shown = (await volumeTo(Math.round(to))).confirmed;
package/docs/cast.md CHANGED
@@ -109,7 +109,7 @@ No host: a group's host changes with its leader. All entries fold into one `cast
109
109
  - `announce(target, clip, { volume?, timeoutMs? })`, target a device or group, `clip` a URL or bytes with their type (KO's own dictation as recorded, or TTS output; the library never synthesises) served by `serveClip`. The result names each member it could not restore. Read status, set volume, launch Default Media Receiver (`CC1AD845`), `LOAD` the clip, wait for `IDLE`/`FINISHED` (with a timeout), then restore: a prior Default Media Receiver `contentId` is re-loaded at its `currentTime`, an idle device gets `STOP`, then the volume. Spotify, YouTube and other apps cannot be resumed generically (our launch ends their session); the result says so.
110
110
  - `castUrl(name, url)` launches DashCast (`84912283`, pychromecast's list) with the URL. HA casts its dashboard with its own registered receiver (`A078F6B0`), a hint at DashCast's limits. KO's own receiver costs a one-off Cast SDK fee and must be published (unpublished ones run only on serial-registered devices). Try DashCast first.
111
111
 
112
- **TTS.** The library plays a URL, never synthesises. The speaker fetches it, so it must be HTTP on the LAN, not loopback, and in a format Default Media Receiver plays (MP3 or AAC; not AIFF; a 24 kHz mono WAV failed on audio-only Nest speakers, live 2026-11-09): `say -o msg.m4a --file-format=m4af --data-format=aac "…"` (m4af alone holds uncompressed PCM). Smallest server: a ~25-line `serveClip(bytes, type)` over `node:http` beside `tls.ts`, bound to the Mac's LAN address, random port, unguessable path, closed when the clip has played or after a minute (not after the first fetch, so a probe before it does no harm). The consumer runs `say`; `serveClip` picks the address on the target's network; the sidecar will raise macOS's local-network prompt once. A cloud voice later is MP3 bytes served the same way.
112
+ **TTS.** The library plays a URL, never synthesises. The speaker fetches it, so it must be HTTP on the LAN, not loopback, and in a format Default Media Receiver plays (MP3 or AAC; not AIFF; a 24 kHz mono WAV failed on audio-only Nest speakers, live 2026-11-09; `announce` now rewrites WAV bytes as 48 kHz 16-bit stereo, which played on every audio-only speaker and display here, 2026-11-12): `say -o msg.m4a --file-format=m4af --data-format=aac "…"` (m4af alone holds uncompressed PCM). Smallest server: a ~25-line `serveClip(bytes, type)` over `node:http` beside `tls.ts`, bound to the Mac's LAN address, random port, unguessable path, closed when the clip has played or after a minute (not after the first fetch, so a probe before it does no harm). The consumer runs `say`; `serveClip` picks the address on the target's network; the sidecar will raise macOS's local-network prompt once. A cloud voice later is MP3 bytes served the same way.
113
113
 
114
114
  **CLI.** `home status`: `cast 7 · Display A idle 47% · TV B standby · Whole house (3, led by Display A) idle`. `home probe`: this probe.
115
115
 
package/docs/cli.md CHANGED
@@ -12,7 +12,7 @@ home scene <name> [in <room>] # a scene; name the room when several have one by
12
12
  home pair # waits 60 s for the bridge's button, counting down; prints the env lines to set
13
13
  home daikin on | off # power only: the AC comes back as it was last left
14
14
  home daikin cool | heat | auto [°C] | dry | fan # mode, on, and the setpoint: the one given, else cool 25 and heat 27; prints what the unit then reports. Live on KO's unit: on, and dry to cool at 25 (2026-11-11); the rest untried
15
- # --dry-run on a hue or daikin write (lights, light, room, scene, daikin …): check it and print what would be sent; nothing is sent. Refused on every other command
15
+ # --dry-run on a write (lights, light, room, scene, daikin, cast, spotify, printers print): check it and print what would be sent; nothing is sent. Refused on every other command
16
16
  home enphase login # once a year: mints the Enphase owner token into ~/.config/home/ (0600)
17
17
  home discover [--json] [--passes n] # which smart-home devices answer here (mDNS + Daikin UDP), which this package supports, then with a config how they match it; two passes 5 s apart
18
18
  home cache [clear] # the addresses kept between runs, or delete them so the next run discovers afresh
package/docs/using.md CHANGED
@@ -74,7 +74,7 @@ A write changes someone's house: make one only when a person asked for it. Write
74
74
  | hue | `.client.put(kind, id, body)`, `.client.recallScene(id)` | the bridge's answer, for what the typed writes don't cover |
75
75
 
76
76
  - **`confirmed`** is true once the device reported what was asked. False is a result, not an error: the write was sent and the device hasn't shown it yet, and `state` is how it stood.
77
- - **Dry run.** `opts` is `{ dryRun: true }` on Hue's and the Daikin's writes: every check made before sending runs (the name, the range, a lost light; a colour off the gamut shows moved in the body) and nothing is sent. The device can still refuse a real write that a dry run passed. The result has `wouldSend`, the request it would make (Hue: `{ method, path, body }`; Daikin adds `sets`, `{ on?, mode?, targetC? }`), `state` as things stand now, and `confirmed: false`, so check `wouldSend` first. A wrong name still throws with the names that exist. For testing a bot end to end with people home; `home … --dry-run` does the same from the terminal.
77
+ - **Dry run.** `opts` is `{ dryRun: true }` on Hue's, the Daikin's, Cast's and Spotify's writes: every check made before sending runs (the name, the range, a lost light; a colour off the gamut shows moved in the body) and nothing is sent. The device can still refuse a real write that a dry run passed. The result has `wouldSend`, the request it would make (Hue: `{ method, path, body }`; Daikin adds `sets`, `{ on?, mode?, targetC? }`; Spotify: the WebSocket commands in order), `state` as things stand now, and `confirmed: false`, so check `wouldSend` first. Cast's `setVolume`, `setMuted` and `stop` resolve `{ wouldSend }` (each `{ device, channel, payload }`), and `announce` its usual result with `played: false`, `why: "dry run"`, `wouldSend` and, in `notRestored`, what the real one could not put back (an app it would interrupt); nothing is served. Spotify's `duck` and `interrupt` run `fn`, touch nothing, and say in `why` what they would have done, so `duck(() => cast.announce(…, { dryRun: true }), { to: 20, dryRun: true })` tests an announcement end to end without a sound. A wrong name still throws with the names that exist. For testing a bot end to end with people home; `home … --dry-run` does the same from the terminal.
78
78
  - **Use the returned state.** The Daikin's part in the snapshot is from its last poll and can be 120 s behind, unless you call `home.refresh("daikin")`.
79
79
  - **Daikin defaults.** `mode("cool")` is 25 °C and `mode("heat")` 27 (`DEFAULT_TARGET_C`); `mode("cool", 23)` for another. `power(true)` brings the unit back as it was last left.
80
80
  - **A target** for Cast is a device's or group's name or id. A clip is `{ url, type? }` or `{ bytes, type }`; bytes need `castServe`.
package/llms.txt CHANGED
@@ -11,8 +11,8 @@ The library never reads the environment or a file: callers pass addresses, keys
11
11
  - **Why a part is down:** `part.status` (`connected`, `disconnected`, `unavailable`) and, when `unavailable`, `part.reason` (`auth`, `config`, `crashed`). Errors are `ConnectorError` with a typed `code`.
12
12
  - **Hue** (`@khalido/home/hue`): `pairWhenPressed` waits for the bridge's button and returns `{ key }`; Signify's root certificates are in the package, so `hue({ host, bridgeId, key })` needs no file. Writes by name: `client.light(name, change)`, `client.room(name, change)`, `client.scene(name, room?)`, `client.allOff()`, with `change` `{ on?, brightness?, color?, kelvin?, transitionMs? }`, each resolving `{ state, confirmed }`. `{ dryRun: true }` as the last argument checks and returns `wouldSend` without sending.
13
13
  - **Daikin** (`@khalido/home/daikin`): `client.power(on)` and `client.mode(mode, targetC?)` resolve `{ state, confirmed }`, and take `{ dryRun: true }` last; cool defaults to 25 °C and heat to 27.
14
- - **Cast** (`@khalido/home/cast`): `castStatus(state)` and `castFree(state, targets, { forms? })`, on the Cast part's state, say what is playing and where an announcement would disturb nothing; `castFreeSpeakers(state)` is the one call for every free speaker and display; `announce`, `announceAll`, `setVolume`, `setMuted`, `stop`.
15
- - **Spotify Soloist** (`@khalido/home/spotify`): `play`, `pause`, `setVolume` and the rest resolve `{ state, confirmed }`; `duck` and `interrupt` wrap an announcement. `speakerState(part)` is `elsewhere` when the music is on another Spotify device.
14
+ - **Cast** (`@khalido/home/cast`): `castStatus(state)` and `castFree(state, targets, { forms? })`, on the Cast part's state, say what is playing and where an announcement would disturb nothing; `castFreeSpeakers(state)` is the one call for every free speaker and display; `announce`, `announceAll`, `setVolume`, `setMuted`, `stop`. Its writes take `{ dryRun: true }`.
15
+ - **Spotify Soloist** (`@khalido/home/spotify`): `play`, `pause`, `setVolume` and the rest resolve `{ state, confirmed }`; `duck` and `interrupt` wrap an announcement. Every write takes `{ dryRun: true }`. `speakerState(part)` is `elsewhere` when the music is on another Spotify device.
16
16
  - **Discovery** (`@khalido/home/finders`, Node-side): `FINDERS`, one finder per connector, and `fileCache`. **Node** needs the fetches in `@khalido/home/node`.
17
17
  - **The command:** `home`, `home devices`, `home <connector>`, and writes such as `home daikin cool`, `home room <name> on`, `home cast announce`.
18
18
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@khalido/home",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
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",
@@ -10,7 +10,7 @@ description: Use the `home` command and the @khalido/home library to see and run
10
10
  ## Rules
11
11
 
12
12
  - **Reads are free.** `home`, `home <connector>` with no verb, `home devices`, `home discover`, `home probe`, `home up spend` and every `--json` read touch nothing.
13
- - **Writes only on request.** Lights, AC, speakers, printing, Up categories and tags, webhooks: run one only when the person asked for that change, then read back and report the state it left. Never write to "test" something: `--dry-run` on a Hue or Daikin write (`home room …`, `home scene …`, `home lights off`, `home daikin …`, `home printers print …`) checks it and prints what it would send, sending nothing (a print's dry run also asks the printer to validate the job, which prints nothing); other commands refuse the flag.
13
+ - **Writes only on request.** Lights, AC, speakers, printing, Up categories and tags, webhooks: run one only when the person asked for that change, then read back and report the state it left. Never write to "test" something: `--dry-run` on a write (`home room …`, `home scene …`, `home lights off`, `home daikin …`, `home cast …`, `home spotify …`, `home printers print …`) checks it and prints what it would send, sending nothing (a print's dry run also asks the printer to validate the job, which prints nothing); other commands refuse the flag.
14
14
  - **Never print a key, token or password**, not even part of one. Say "set" or "unset". Env names are fine; values are not.
15
15
  - **Nothing private into a public repo**: no IPs, MACs, serials, device or room names, Up transactions or balances. A real `home.jsonc`, `home.svg` and floor plan live in a private folder.
16
16
  - **Show values as read.** A missing field is unknown, not zero. An `anomaly` is something odd the device said: report it, never smooth it.
package/src/cli/cast.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  resolvedFrom,
14
14
  } from "../connectors/cast/index.ts";
15
15
  import { findCast } from "../connectors/cast/state.ts";
16
+ import type { CastDryRun, CastRequest } from "../connectors/cast/writes.ts";
16
17
  import { serveClip } from "../connectors/cast/serve.ts";
17
18
  import { tlsConnect } from "../connectors/cast/tls.ts";
18
19
  import { addressBook } from "../discover.ts";
@@ -114,14 +115,32 @@ export const CAST_OPTIONS: [string, string, { type: unknown[] }?][] = [
114
115
  /** How long a write waits for the device to report the change before saying it was only sent. */
115
116
  const SHOWN_MS = 2000;
116
117
 
117
- /** One write, then the device's line once it reports what was asked, or that it has not yet. */
118
+ /** A dry run's messages, one line each: `would send <device> <channel> <payload>`. */
119
+ const dryLines = (sends: CastRequest[], who = "") =>
120
+ sends.length
121
+ ? sends.forEach((r) =>
122
+ console.log(
123
+ `${who}would send ${r.device} ${r.channel} ${JSON.stringify(r.payload)}`,
124
+ ),
125
+ )
126
+ : console.log(`${who}nothing to send`);
127
+
128
+ /** One write, then the device's line once it reports what was asked, or that it has not yet. Dry: what would be sent. */
118
129
  const wrote = (
119
130
  name: string,
120
- write: (c: ReturnType<typeof castNow>) => Promise<void>,
131
+ write: (
132
+ c: ReturnType<typeof castNow>,
133
+ options: { dryRun: boolean },
134
+ ) => Promise<CastDryRun | undefined>,
121
135
  shown: (d: CastDevice) => boolean,
136
+ dryRun: boolean,
122
137
  ) =>
123
138
  withCast(async (c, state) => {
124
- await write(c);
139
+ const dry = await write(c, { dryRun });
140
+ if (dry) {
141
+ console.log("dry run, nothing sent:");
142
+ return dryLines(dry.wouldSend);
143
+ }
125
144
  const now = () => {
126
145
  const s = state();
127
146
  const d = s && findCast(s.devices, name);
@@ -140,29 +159,32 @@ const wrote = (
140
159
  });
141
160
 
142
161
  export const castVerbs: Verbs = {
143
- async volume([name, value]) {
162
+ async volume([name, value], o) {
144
163
  if (!name) usage(CAST_USAGE);
145
164
  const level = percentLevel(value);
146
165
  await wrote(
147
166
  name!,
148
- (c) => c.setVolume(name!, level),
167
+ (c, dry) => c.setVolume(name!, level, dry),
149
168
  (d) => d.volume !== undefined && Math.abs(d.volume - level) < 0.006,
169
+ !!o.dryRun,
150
170
  );
151
171
  },
152
- async mute([name, value]) {
172
+ async mute([name, value], o) {
153
173
  if (!name || (value !== "on" && value !== "off")) usage(CAST_USAGE);
154
174
  await wrote(
155
175
  name!,
156
- (c) => c.setMuted(name!, value === "on"),
176
+ (c, dry) => c.setMuted(name!, value === "on", dry),
157
177
  (d) => d.muted === (value === "on"),
178
+ !!o.dryRun,
158
179
  );
159
180
  },
160
- async stop([name]) {
181
+ async stop([name], o) {
161
182
  if (!name) usage(CAST_USAGE);
162
183
  await wrote(
163
184
  name!,
164
- (c) => c.stop(name!),
185
+ (c, dry) => c.stop(name!, dry),
165
186
  (d) => d.idle === true,
187
+ !!o.dryRun,
166
188
  );
167
189
  },
168
190
  async announce(_, o) {
@@ -209,13 +231,21 @@ export const castVerbs: Verbs = {
209
231
  type = "audio/mp4";
210
232
  }
211
233
  const results = await withCast((c) =>
212
- c.announceAll(to, { bytes, type }, { volume }),
234
+ c.announceAll(to, { bytes, type }, { volume, dryRun: !!o.dryRun }),
213
235
  );
214
- if (results.some((r) => !r.played)) process.exitCode = 1; // docs/cli.md: 1 is a failure, so a script can tell
236
+ const failed = results.some((r) => (o.dryRun ? !r.wouldSend : !r.played));
237
+ if (failed) process.exitCode = 1; // docs/cli.md: 1 is a failure, so a script can tell
215
238
  if (o.json) return printJson(to.length === 1 ? results[0] : results);
216
239
  const named = results.length > 1;
240
+ if (o.dryRun) console.log("dry run, nothing sent:");
217
241
  for (const r of results) {
218
242
  const who = named ? `${r.target}: ` : "";
243
+ if (r.wouldSend) {
244
+ dryLines(r.wouldSend, who);
245
+ for (const n of r.notRestored)
246
+ console.log(`${who}would not restore: ${n.name}: ${n.why}`);
247
+ continue;
248
+ }
219
249
  console.log(`${who}${r.played ? "played" : `not played: ${r.why}`}`);
220
250
  if (r.restored.length)
221
251
  console.log(`${who}restored: ${r.restored.join(", ")}`);
@@ -38,7 +38,7 @@ export const usage = (message: string): never => fail(message, "usage");
38
38
  /** The options cac parses: the global ones, and each command's own. */
39
39
  export type Options = {
40
40
  json?: boolean;
41
- /** `--dry-run`: Hue and Daikin writes check and say what they would send, and send nothing. */
41
+ /** `--dry-run`: Hue, Daikin, Cast, Spotify and printer writes check and say what they would send, and send nothing. */
42
42
  dryRun?: boolean;
43
43
  config?: string;
44
44
  write?: string | true;
package/src/cli/main.ts CHANGED
@@ -57,9 +57,13 @@ const VERBS: Record<
57
57
  },
58
58
  daikin: { verbs: daikinVerbs, usage: DAIKIN_USAGE, options: [DRY_RUN] },
59
59
  enphase: { verbs: enphaseVerbs, usage: "usage: home enphase login" },
60
- cast: { verbs: castVerbs, usage: CAST_USAGE, options: CAST_OPTIONS },
60
+ cast: {
61
+ verbs: castVerbs,
62
+ usage: CAST_USAGE,
63
+ options: [...CAST_OPTIONS, DRY_RUN],
64
+ },
61
65
  up: { verbs: upVerbs, usage: UP_USAGE, options: UP_OPTIONS },
62
- spotify: { verbs: spotifyVerbs, usage: SPOTIFY_USAGE },
66
+ spotify: { verbs: spotifyVerbs, usage: SPOTIFY_USAGE, options: [DRY_RUN] },
63
67
  printers: {
64
68
  verbs: printersVerbs,
65
69
  usage: PRINTERS_USAGE,
@@ -7,7 +7,7 @@ import {
7
7
  type SpotifyState,
8
8
  spotifyText,
9
9
  } from "../connectors/spotify/index.ts";
10
- import { env, fail, usage, type Verbs } from "./context.ts";
10
+ import { env, fail, type Options, usage, type Verbs } from "./context.ts";
11
11
 
12
12
  /** SOLOIST_MAX_VOLUME as a number 0–100, or undefined when unset. */
13
13
  function maxVolume(): number | undefined {
@@ -55,23 +55,27 @@ async function withSpotify<T>(
55
55
  }
56
56
  }
57
57
 
58
- /** What a write left, in a line: the speaker as Soloist then reported it, or that it has not reported the change yet. */
59
- const said = ({ state, confirmed }: Applied) =>
58
+ /** What a write left, in a line: the speaker as Soloist then reported it, that it has not reported the change yet, or what a dry run would send. */
59
+ const said = ({ state, confirmed, wouldSend }: Applied) =>
60
60
  console.log(
61
- confirmed
62
- ? spotifyText(state)
63
- : `sent; Soloist has not reported it yet (${spotifyText(state)})`,
61
+ wouldSend
62
+ ? `dry run, nothing sent: ${wouldSend.length ? `would send ${wouldSend.map((r) => JSON.stringify(r)).join(", then ")}` : "nothing to send"} (now ${spotifyText(state)})`
63
+ : confirmed
64
+ ? spotifyText(state)
65
+ : `sent; Soloist has not reported it yet (${spotifyText(state)})`,
64
66
  );
65
67
 
68
+ const dry = (o: Options) => ({ dryRun: !!o.dryRun });
69
+
66
70
  export const spotifyVerbs: Verbs = {
67
- activate: () => withSpotify((s) => s.activate()).then(said),
68
- pause: () => withSpotify((s) => s.pause()).then(said),
69
- next: () => withSpotify((s) => s.next()).then(said),
70
- play: ([link]) => withSpotify((s) => s.play(link)).then(said),
71
- async volume([value]) {
71
+ activate: (_, o) => withSpotify((s) => s.activate(dry(o))).then(said),
72
+ pause: (_, o) => withSpotify((s) => s.pause(dry(o))).then(said),
73
+ next: (_, o) => withSpotify((s) => s.next(dry(o))).then(said),
74
+ play: ([link], o) => withSpotify((s) => s.play(link, dry(o))).then(said),
75
+ async volume([value], o) {
72
76
  const n = Number(value);
73
77
  if (value === undefined || value.trim() === "" || !(n >= 0 && n <= 100))
74
78
  usage(SPOTIFY_USAGE);
75
- said(await withSpotify((s) => s.setVolume(n)));
79
+ said(await withSpotify((s) => s.setVolume(n, dry(o))));
76
80
  },
77
81
  };
@@ -15,6 +15,8 @@ Nest speakers, Nest Hubs, Chromecasts, Google TV and speaker groups, read over C
15
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.
16
16
  - `castFreeSpeakers(state)`: the one call for "where can I announce now": `castFree` over every device and group, with `forms: ["speaker", "display"]`. Read live 2026-11-11 (Bun 1.4.4, reads only): 8 devices and groups gave one free group of four, with two TVs and a device whose model was not known skipped as `form`.
17
17
  - `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.
18
+ - **WAV.** A clip of WAV bytes (`audio/wav` and its other names) is rewritten as 48 kHz, 16-bit, stereo PCM before it is served (`castWav`, in `wav.ts`, plain TypeScript, linear interpolation): a 24 kHz mono WAV failed on audio-only speakers (below) and Google lists WAV for them without rates. Integer PCM of 8 to 32 bits and 32-bit float are read; anything else throws before anything is sent. A clip already in that form is sent unchanged. `castWav(bytes)` is exported for a clip served by URL. Live 2026-11-12 (Bun 1.4.4, from the Mac, KO's OK): a 24 kHz mono 16-bit WAV from macOS `say`, converted, played to the end on a Nest Audio at volume 30 in 3.9 s, idle and volume restored. Then at volume 50, one at a time, KO home: a Nest Mini (the oldest here), a Google Home speaker, a Nest Hub and a Nest Hub Max all played it, 3.7 to 7.5 s each, each put back to idle and its volume (the Hub Max, muted, unmuted for the clip and muted again). KO heard each, and the Nest Audio again at volume 100 from the next room. So every Cast model here plays the converted WAV, and none needs MP3. After it, `home cast volume <name> 50` printed the speaker's line once it reported the level (`…: idle · 50%`), the first live run of that line.
19
+ - **Dry run.** Each write takes `{ dryRun: true }` last: the target is found and checked as for a real write, the reads it needs run (an announce reads the receiver status, a Default Media Receiver's media and a group's members, and re-runs discovery for a group), and no write is sent and nothing served. `setVolume`, `setMuted` and `stop` resolve `{ wouldSend }`; `announce` resolves `played: false`, `why: "dry run"`, `wouldSend` in order (volume, LAUNCH, LOAD, then the restores), and in `notRestored` what the real one could not put back. Its `STOP` has no `sessionId`: that is the launched app's, which only the real run knows. Live 2026-11-12 (Bun 1.4.4) on a Nest Hub: `home cast volume … --dry-run` and `home cast announce … --dry-run`, nothing played.
18
20
  - `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.
19
21
  - 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`.
20
22
  - 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.
@@ -52,10 +52,14 @@ export type {
52
52
  CastStatus,
53
53
  Resolved,
54
54
  } from "./state.ts";
55
+ export { CAST_WAV, castWav, type WavFormat } from "./wav.ts";
55
56
  export type {
56
57
  AnnounceResult,
57
58
  CastActions,
58
59
  CastClip,
60
+ CastDryRun,
61
+ CastRequest,
62
+ CastWriteOptions,
59
63
  ServeClip,
60
64
  } from "./writes.ts";
61
65