@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.
- package/CHANGELOG.md +9 -1
- package/dist/connectors/cast/index.d.ts +2 -1
- package/dist/connectors/cast/index.js +1 -0
- package/dist/connectors/cast/wav.d.ts +31 -0
- package/dist/connectors/cast/wav.js +149 -0
- package/dist/connectors/cast/writes.d.ts +25 -6
- package/dist/connectors/cast/writes.js +155 -62
- package/dist/connectors/spotify/index.d.ts +28 -14
- package/dist/connectors/spotify/index.js +77 -47
- package/docs/cast.md +1 -1
- package/docs/cli.md +1 -1
- package/docs/using.md +1 -1
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/skills/use-home/SKILL.md +1 -1
- package/src/cli/cast.ts +41 -11
- package/src/cli/context.ts +1 -1
- package/src/cli/main.ts +6 -2
- package/src/cli/spotify.ts +16 -12
- package/src/connectors/cast/README.md +2 -0
- package/src/connectors/cast/index.ts +4 -0
- package/src/connectors/cast/wav.ts +181 -0
- package/src/connectors/cast/writes.ts +211 -71
- package/src/connectors/spotify/README.md +2 -0
- package/src/connectors/spotify/index.ts +221 -105
|
@@ -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 {
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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.
|
|
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",
|
package/skills/use-home/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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: (
|
|
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
|
-
|
|
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(", ")}`);
|
package/src/cli/context.ts
CHANGED
|
@@ -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
|
|
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: {
|
|
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,
|
package/src/cli/spotify.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
62
|
-
? spotifyText(state)
|
|
63
|
-
:
|
|
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
|
|