@khalido/home 0.1.0 → 0.1.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 +50 -1
- package/README.md +11 -11
- package/dist/config.js +3 -1
- package/dist/connectors/cast/index.d.ts +4 -1
- package/dist/connectors/cast/index.js +10 -2
- package/dist/connectors/cast/schemas.d.ts +2 -0
- package/dist/connectors/cast/schemas.js +1 -0
- package/dist/connectors/cast/serve.d.ts +8 -1
- package/dist/connectors/cast/state.d.ts +4 -0
- package/dist/connectors/cast/state.js +12 -0
- package/dist/connectors/cast/tls.js +1 -1
- package/dist/connectors/cast/writes.d.ts +9 -0
- package/dist/connectors/cast/writes.js +42 -6
- package/dist/connectors/spotify/changes.d.ts +1 -1
- package/dist/connectors/spotify/changes.js +4 -2
- package/dist/connectors/spotify/client.d.ts +1 -1
- package/dist/connectors/spotify/index.d.ts +3 -1
- package/dist/connectors/spotify/index.js +114 -45
- package/dist/connectors/up/index.d.ts +4 -2
- package/dist/connectors/up/index.js +2 -1
- package/dist/connectors/weather/index.js +16 -3
- package/dist/discover.d.ts +5 -1
- package/dist/discover.js +12 -3
- package/dist/energy.js +1 -1
- package/dist/finders.d.ts +3 -0
- package/dist/finders.js +6 -3
- package/dist/lane.js +24 -20
- package/docs/cast.md +1 -1
- package/docs/cli.md +5 -3
- package/docs/config.md +2 -2
- package/docs/decisions.md +1 -0
- package/docs/google.md +1 -1
- package/package.json +2 -2
- package/skills/draw-home/README.md +5 -6
- package/skills/draw-home/SKILL.md +3 -5
- package/src/cli/cast.ts +19 -11
- package/src/cli/discover.ts +8 -1
- package/src/cli/spotify.ts +2 -1
- package/src/config.ts +3 -1
- package/src/connectors/cast/README.md +7 -5
- package/src/connectors/cast/index.ts +19 -5
- package/src/connectors/cast/schemas.ts +1 -0
- package/src/connectors/cast/serve.ts +11 -1
- package/src/connectors/cast/state.ts +12 -0
- package/src/connectors/cast/tls.ts +1 -1
- package/src/connectors/cast/writes.ts +58 -9
- package/src/connectors/daikin/README.md +1 -1
- package/src/connectors/spotify/README.md +23 -8
- package/src/connectors/spotify/changes.ts +4 -2
- package/src/connectors/spotify/client.ts +1 -1
- package/src/connectors/spotify/index.ts +120 -48
- package/src/connectors/up/README.md +1 -1
- package/src/connectors/up/index.ts +5 -2
- package/src/connectors/weather/README.md +1 -1
- package/src/connectors/weather/index.ts +19 -3
- package/src/discover.ts +14 -6
- package/src/energy.ts +1 -1
- package/src/finders.ts +8 -5
- package/src/lane.ts +23 -20
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,53 @@ All notable changes to home.ts (`@khalido/home` on npm), in [Keep a Changelog](h
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.1.2] - 2026-11-09
|
|
8
|
+
|
|
9
|
+
From chota-bot's live writes on a Raspberry Pi. `setVolume` on an inactive speaker now rejects, and `track` events can come later than before.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **spotify:** `activate()`, and `home spotify activate`.
|
|
14
|
+
- **cast:** `announceAll(targets, clip, options)`, one result per device, and repeated `--to` on `home cast announce` (`--json` is an array with several).
|
|
15
|
+
- **cast:** `form` on a device whose model it knows (`speaker`, `display`, `tv`, from `castForm(model)`), so "all speakers" can skip TVs. The address cache keeps the model; a cached device without one goes through discovery once.
|
|
16
|
+
- **up:** the integration has `client`, for a consumer's writes.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- **spotify:** `setVolume` while the speaker isn't the active device rejects with code `inactive`, instead of `confirmed: false`: Soloist accepts it and drops it. `duck` then runs `fn` with `music: "untouched"`.
|
|
21
|
+
- **spotify:** `play` while inactive goes active first.
|
|
22
|
+
- **spotify:** the `track` event waits for the track's name.
|
|
23
|
+
- **cast:** a clip that doesn't start playing fails after 15 s (or `timeoutMs`), naming its type. An error the receiver reports sooner gives its reason, and its code if it has one.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- **spotify:** `maxVolume` waits until the speaker is active, retries until Soloist reports the lower volume, and says "lowered it" only then; if it never shows, `detail` says so. It said "lowered it" at 56 with a cap of 40. The window also opens when the speaker goes active.
|
|
28
|
+
- **spotify:** `setRepeat("off")` from `track` stayed on `track`: the two commands now go 600 ms apart.
|
|
29
|
+
- **spotify:** a volume within 1 of the request confirms, since Soloist rounds it.
|
|
30
|
+
- **spotify:** a stall repair can run again when a track comes back after another.
|
|
31
|
+
- **`home cast announce`** exits 1 when a clip didn't play.
|
|
32
|
+
|
|
33
|
+
## [0.1.1] - 2026-11-09
|
|
34
|
+
|
|
35
|
+
Fixes from chota-bot's first live reads, on a Raspberry Pi and a Mac. No breaking changes.
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- **`discover`:** a `stopOn` option, for an error no later pass could get past.
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
|
|
43
|
+
- **cast:** `tlsConnect` gives up after 3 s, down from 10 s, so one offline device holds up a one-shot read for less. A device whose cached address fails and that discovery can't find no longer gets a second try before it shows as disconnected. `home cast` with one device down: 13.3 s to 2.3 s (Bun 1.4.4, macOS, 2026-11-09).
|
|
44
|
+
|
|
45
|
+
### Fixed
|
|
46
|
+
|
|
47
|
+
- **spotify:** `maxVolume`'s first-play window now opens on a start through `buffering` too: idle → buffering → playing escaped it.
|
|
48
|
+
- **weather:** the text's chance of rain for today is the highest of the hours still to come, once that's lower than the day's figure and from the same source, and it says "the rest of the day". `forecast.today.rainChance` stays as the source gave it.
|
|
49
|
+
- **`home discover`:** without `avahi-browse` or `dns-sd` it fails at once, naming the package, instead of after both passes.
|
|
50
|
+
- **spotify:** a frame or open event from a socket that had already ended (dropped, or closed after 10 s with no message) no longer changes the state or the next socket's status.
|
|
51
|
+
- **cast:** `@khalido/home/cast/serve`'s types no longer name `NodeJS.Dict`, so they compile without `@types/node`.
|
|
52
|
+
- **Docs:** corrected the config parser, the printers exception to one device per connector, Daikin's `home status` address, and the weather user agent. `skills/draw-home` no longer points at private paths.
|
|
53
|
+
|
|
7
54
|
## [0.1.0] - 2026-11-08
|
|
8
55
|
|
|
9
56
|
First release. Nine connectors: 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. Each connector's README says what has run against a real device.
|
|
@@ -21,5 +68,7 @@ First release. Nine connectors: Hue, Daikin, Enphase, Google Cast, AirPrint prin
|
|
|
21
68
|
- **Agent skills** in `skills/`: `use-home` (reading safely, connecting, the rules for writes) and `draw-home` (an SVG plan of a home and its rooms). `llms.txt` points agents at the docs.
|
|
22
69
|
- **Node.** Hue and Enphase take the fetches in `@khalido/home/node`. The library runs on Bun 1.4+ and Node 24+; the `home` command needs Bun.
|
|
23
70
|
|
|
24
|
-
[Unreleased]: https://github.com/khalido/home.ts/compare/v0.1.
|
|
71
|
+
[Unreleased]: https://github.com/khalido/home.ts/compare/v0.1.2...HEAD
|
|
72
|
+
[0.1.2]: https://github.com/khalido/home.ts/compare/v0.1.1...v0.1.2
|
|
73
|
+
[0.1.1]: https://github.com/khalido/home.ts/compare/v0.1.0...v0.1.1
|
|
25
74
|
[0.1.0]: https://github.com/khalido/home.ts/releases/tag/v0.1.0
|
package/README.md
CHANGED
|
@@ -50,17 +50,17 @@ The library never reads your environment or a file: you pass addresses, keys and
|
|
|
50
50
|
|
|
51
51
|
## Connectors
|
|
52
52
|
|
|
53
|
-
| Connector | Reads | Writes
|
|
54
|
-
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
|
|
55
|
-
| [hue](src/connectors/hue/README.md) | lights, rooms, scenes, over the bridge's event stream | lights, rooms, scenes
|
|
56
|
-
| [daikin](src/connectors/daikin/README.md) | the AC, over its local API | power, mode, setpoint, fan
|
|
57
|
-
| [enphase](src/connectors/enphase/README.md) | solar production and consumption from the IQ Gateway | none
|
|
58
|
-
| [cast](src/connectors/cast/README.md) | every Cast device and speaker group | volume, mute, stop, announcements
|
|
59
|
-
| [printers](src/connectors/printers/README.md) | AirPrint printers over IPP: state, toner, paper | none
|
|
60
|
-
| [spotify](src/connectors/spotify/README.md) | a Spotify Soloist speaker: what is playing, volume, shuffle, over its local WebSocket | play a link, pause, resume, skip, seek, volume (capped), queue, shuffle, repeat, each confirmed by what the speaker reports; turn down or pause around an announcement; a stalled stream noticed and, if asked, restarted |
|
|
61
|
-
| [amber](src/connectors/amber/README.md) | live import and feed-in prices | none
|
|
62
|
-
| [up](src/connectors/up/README.md) | household spending by category, what still needs one | a category or tag on a transaction
|
|
63
|
-
| [weather](src/connectors/weather/README.md) | now, forecast by the hour and by part of the day, rain soon, the sun, from BOM, Open-Meteo and Google | none
|
|
53
|
+
| Connector | Reads | Writes |
|
|
54
|
+
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
55
|
+
| [hue](src/connectors/hue/README.md) | lights, rooms, scenes, over the bridge's event stream | lights, rooms, scenes |
|
|
56
|
+
| [daikin](src/connectors/daikin/README.md) | the AC, over its local API | power, mode, setpoint, fan |
|
|
57
|
+
| [enphase](src/connectors/enphase/README.md) | solar production and consumption from the IQ Gateway | none |
|
|
58
|
+
| [cast](src/connectors/cast/README.md) | every Cast device and speaker group | volume, mute, stop, announcements |
|
|
59
|
+
| [printers](src/connectors/printers/README.md) | AirPrint printers over IPP: state, toner, paper | none |
|
|
60
|
+
| [spotify](src/connectors/spotify/README.md) | a Spotify Soloist speaker: what is playing, volume, shuffle, over its local WebSocket | activate, play a link, pause, resume, skip, seek, volume (capped), queue, shuffle, repeat, each confirmed by what the speaker reports; turn down or pause around an announcement; a stalled stream noticed and, if asked, restarted |
|
|
61
|
+
| [amber](src/connectors/amber/README.md) | live import and feed-in prices | none |
|
|
62
|
+
| [up](src/connectors/up/README.md) | household spending by category, what still needs one | a category or tag on a transaction |
|
|
63
|
+
| [weather](src/connectors/weather/README.md) | now, forecast by the hour and by part of the day, rain soon, the sun, from BOM, Open-Meteo and Google | none |
|
|
64
64
|
|
|
65
65
|
Each connector's README says what has run against a real device and what has not. This covers only the devices I own; [Home Assistant](https://www.home-assistant.io/) is the inspiration and the reference for every protocol.
|
|
66
66
|
|
package/dist/config.js
CHANGED
|
@@ -331,7 +331,9 @@ export function loadHome(input, options) {
|
|
|
331
331
|
{
|
|
332
332
|
castId: x.castId,
|
|
333
333
|
name: x.name,
|
|
334
|
-
...(c?.port === undefined
|
|
334
|
+
...(c?.port === undefined
|
|
335
|
+
? {}
|
|
336
|
+
: { host: c.host, port: c.port, model: c.model }),
|
|
335
337
|
},
|
|
336
338
|
];
|
|
337
339
|
});
|
|
@@ -7,7 +7,8 @@ import { type CastConnect } from "./codec.ts";
|
|
|
7
7
|
import { type CastDevice, type CastSnapshot, type Resolved } from "./state.ts";
|
|
8
8
|
import { type CastActions, type ServeClip } from "./writes.ts";
|
|
9
9
|
export type { CastConnect } from "./codec.ts";
|
|
10
|
-
export
|
|
10
|
+
export { castForm } from "./state.ts";
|
|
11
|
+
export type { CastDevice, CastForm, CastSnapshot, Resolved } from "./state.ts";
|
|
11
12
|
export type { AnnounceResult, CastActions, CastClip, ServeClip, } from "./writes.ts";
|
|
12
13
|
/** A Cast device or speaker group in a home config. No host: a group's moves with its leader, so every address comes from discovery. */
|
|
13
14
|
export declare const configSchema: z.ZodObject<{
|
|
@@ -30,6 +31,7 @@ export type CastConfig = {
|
|
|
30
31
|
expect?: {
|
|
31
32
|
castId: string;
|
|
32
33
|
name: string;
|
|
34
|
+
model?: string;
|
|
33
35
|
host?: string;
|
|
34
36
|
port?: number;
|
|
35
37
|
}[];
|
|
@@ -38,6 +40,7 @@ export type CastConfig = {
|
|
|
38
40
|
host: string;
|
|
39
41
|
port: number;
|
|
40
42
|
name?: string;
|
|
43
|
+
model?: string;
|
|
41
44
|
}) => void;
|
|
42
45
|
clock?: Clock;
|
|
43
46
|
/** Heartbeat interval; a socket silent for three of them is dropped. */
|
|
@@ -7,6 +7,7 @@ import { NS } from "./codec.js";
|
|
|
7
7
|
import { CastPayload, KNOWN_TYPES } from "./schemas.js";
|
|
8
8
|
import { castKey, connection, found, mediaApp, reduce, } from "./state.js";
|
|
9
9
|
import { castActions, } from "./writes.js";
|
|
10
|
+
export { castForm } from "./state.js";
|
|
10
11
|
/** A Cast device or speaker group in a home config. No host: a group's moves with its leader, so every address comes from discovery. */
|
|
11
12
|
export const configSchema = z.looseObject({
|
|
12
13
|
connector: z.literal("cast"),
|
|
@@ -205,6 +206,7 @@ export function cast(config) {
|
|
|
205
206
|
host: d.host,
|
|
206
207
|
port: d.port,
|
|
207
208
|
name: named.has(id) ? d.name : undefined,
|
|
209
|
+
model: d.model,
|
|
208
210
|
}); // a config's name never reaches the cache
|
|
209
211
|
publish();
|
|
210
212
|
void (async () => {
|
|
@@ -297,7 +299,9 @@ export function cast(config) {
|
|
|
297
299
|
// A cached address that fails is only stale: discover now and try where it answers, still "connecting".
|
|
298
300
|
cached = false;
|
|
299
301
|
await discover();
|
|
300
|
-
|
|
302
|
+
// Unless discovery can't find it: then the address is the one that just failed, and a second try only waits out the timeout. The backoff still retries.
|
|
303
|
+
if (!lastFound || lastFound.has(id))
|
|
304
|
+
continue;
|
|
301
305
|
}
|
|
302
306
|
const why = errorText(error);
|
|
303
307
|
// A cached address the last discovery did not find says so, as a polling connector's does.
|
|
@@ -357,10 +361,14 @@ export function cast(config) {
|
|
|
357
361
|
report = update;
|
|
358
362
|
running = signal;
|
|
359
363
|
const expect = config.expect ?? [];
|
|
360
|
-
|
|
364
|
+
// A device with no model (a cache from before 0.1.2) goes through discovery once, or it would never get a `form`.
|
|
365
|
+
const cached = expect.filter((e) => e.host !== undefined &&
|
|
366
|
+
e.port !== undefined &&
|
|
367
|
+
(e.port !== 8009 || e.model !== undefined));
|
|
361
368
|
snapshot = found(snapshot, cached.map((e) => ({
|
|
362
369
|
id: e.castId,
|
|
363
370
|
name: e.name,
|
|
371
|
+
model: e.model,
|
|
364
372
|
host: e.host,
|
|
365
373
|
port: e.port,
|
|
366
374
|
group: e.port !== 8009,
|
|
@@ -41,6 +41,7 @@ export declare const MediaStatus: z.ZodObject<{
|
|
|
41
41
|
mediaSessionId: z.ZodOptional<z.ZodNumber>;
|
|
42
42
|
playerState: z.ZodOptional<z.ZodString>;
|
|
43
43
|
idleReason: z.ZodOptional<z.ZodString>;
|
|
44
|
+
detailedErrorCode: z.ZodOptional<z.ZodNumber>;
|
|
44
45
|
currentTime: z.ZodOptional<z.ZodNumber>;
|
|
45
46
|
media: z.ZodOptional<z.ZodObject<{
|
|
46
47
|
contentId: z.ZodOptional<z.ZodString>;
|
|
@@ -118,6 +119,7 @@ export declare const CastPayload: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
118
119
|
mediaSessionId: z.ZodOptional<z.ZodNumber>;
|
|
119
120
|
playerState: z.ZodOptional<z.ZodString>;
|
|
120
121
|
idleReason: z.ZodOptional<z.ZodString>;
|
|
122
|
+
detailedErrorCode: z.ZodOptional<z.ZodNumber>;
|
|
121
123
|
currentTime: z.ZodOptional<z.ZodNumber>;
|
|
122
124
|
media: z.ZodOptional<z.ZodObject<{
|
|
123
125
|
contentId: z.ZodOptional<z.ZodString>;
|
|
@@ -36,6 +36,7 @@ export const MediaStatus = z.looseObject({
|
|
|
36
36
|
mediaSessionId: z.number().optional(),
|
|
37
37
|
playerState: z.string().optional(), // "PLAYING" | "PAUSED" | "BUFFERING" | "IDLE"
|
|
38
38
|
idleReason: z.string().optional(), // "FINISHED" | "CANCELLED" | "INTERRUPTED" | "ERROR"
|
|
39
|
+
detailedErrorCode: z.number().optional(),
|
|
39
40
|
currentTime: z.number().optional(), // seconds into the media
|
|
40
41
|
media: z
|
|
41
42
|
.looseObject({
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import type { ServeClip } from "./writes.ts";
|
|
2
2
|
/** How long a clip stays served when nothing fetches it. */
|
|
3
3
|
export declare const SERVE_MS = 60000;
|
|
4
|
+
type NetworkAddress = {
|
|
5
|
+
family: string;
|
|
6
|
+
address: string;
|
|
7
|
+
netmask: string;
|
|
8
|
+
internal: boolean;
|
|
9
|
+
};
|
|
4
10
|
/** This machine's IPv4 on the subnet that holds `host`, or with no host, its first non-internal IPv4. */
|
|
5
|
-
export declare function lanAddress(host?: string, interfaces?:
|
|
11
|
+
export declare function lanAddress(host?: string, interfaces?: Record<string, NetworkAddress[] | undefined>): string;
|
|
6
12
|
/**
|
|
7
13
|
* Serve `bytes`: a random port, an unguessable path, the given `Content-Type` and its `Content-Length`, the whole body
|
|
8
14
|
* to every GET (a player may probe before it fetches). It closes on `close()` (`announce` calls it when the clip has
|
|
9
15
|
* played), when `signal` aborts, or after `SERVE_MS`. `host` is the device that will fetch it, so the address is on its network.
|
|
10
16
|
*/
|
|
11
17
|
export declare const serveClip: ServeClip;
|
|
18
|
+
export {};
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import type { CastPayload } from "./schemas.ts";
|
|
2
|
+
/** What a device is for, read from its model name; a model it doesn't know has none. A group is `kind: "group"`. */
|
|
3
|
+
export type CastForm = "speaker" | "display" | "tv";
|
|
4
|
+
export declare function castForm(model?: string): CastForm | undefined;
|
|
2
5
|
/** A Cast device or group as found by discovery: what `resolve` returns for each. */
|
|
3
6
|
export type Resolved = {
|
|
4
7
|
id: string;
|
|
@@ -16,6 +19,7 @@ export type CastDevice = {
|
|
|
16
19
|
id: string;
|
|
17
20
|
name: string;
|
|
18
21
|
model?: string;
|
|
22
|
+
form?: CastForm;
|
|
19
23
|
kind: "device" | "group";
|
|
20
24
|
host?: string;
|
|
21
25
|
port?: number;
|
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
export function castForm(model) {
|
|
2
|
+
if (!model)
|
|
3
|
+
return undefined;
|
|
4
|
+
if (/hub|tablet|display/i.test(model))
|
|
5
|
+
return "display";
|
|
6
|
+
if (/audio|speaker|mini|google home/i.test(model))
|
|
7
|
+
return "speaker";
|
|
8
|
+
if (/chromecast|streamer|\btv\b/i.test(model))
|
|
9
|
+
return "tv";
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
1
12
|
/** An id as the snapshot keys it: the mDNS id (32 hex) and the certificate's and multizone's dashed UUID are one key. */
|
|
2
13
|
export const castKey = (id) => id.toLowerCase().replace(/-/g, "");
|
|
3
14
|
/** Add or update what discovery found; devices it did not find this time are kept as they were. */
|
|
@@ -11,6 +22,7 @@ export function found(s, entries, asOf) {
|
|
|
11
22
|
id,
|
|
12
23
|
name: e.name,
|
|
13
24
|
model: e.model,
|
|
25
|
+
form: kind === "device" ? castForm(e.model) : undefined,
|
|
14
26
|
kind,
|
|
15
27
|
host: e.host,
|
|
16
28
|
port: e.port,
|
|
@@ -6,7 +6,7 @@ import { encode, framer, } from "./codec.js";
|
|
|
6
6
|
// Verification is off: every Cast device presents a self-signed certificate that rotates every 48 hours, so there is
|
|
7
7
|
// nothing to pin. The integration instead checks the certificate's name (`peer`, the device's id) against discovery.
|
|
8
8
|
/** Open a Cast socket: TLS to host:port within `timeoutMs`, framed CastMessages in and out. */
|
|
9
|
-
export const tlsConnect = ({ host, port, signal, timeoutMs =
|
|
9
|
+
export const tlsConnect = ({ host, port, signal, timeoutMs = 3000, }) => new Promise((resolve, reject) => {
|
|
10
10
|
const socket = connect({ host, port, rejectUnauthorized: false });
|
|
11
11
|
let timer;
|
|
12
12
|
const fail = (error) => (clearTimeout(timer),
|
|
@@ -6,6 +6,8 @@ export declare const DEFAULT_MEDIA_RECEIVER = "CC1AD845";
|
|
|
6
6
|
export declare const ANSWER_MS = 10000;
|
|
7
7
|
/** How long an announcement waits for its clip to finish, beyond the clip's own length when the device reports it. */
|
|
8
8
|
export declare const PLAY_MS = 60000;
|
|
9
|
+
/** How long a clip gets to start playing. A clip a speaker can't play took 35–44 s to say so (live, 2026-11-09); good ones start within a few seconds. A stated guess. */
|
|
10
|
+
export declare const START_MS = 15000;
|
|
9
11
|
/** Level writes, and mute writes, per device or group in any minute; more wait their turn, the newest winning. A guess: Cast publishes no limit. */
|
|
10
12
|
export declare const VOLUME_PER_MINUTE = 30;
|
|
11
13
|
/** What to play: a URL the device can fetch (type defaults to audio/mpeg), or bytes for `serve` to put on the LAN. */
|
|
@@ -44,6 +46,13 @@ export type CastActions = {
|
|
|
44
46
|
volume?: number;
|
|
45
47
|
timeoutMs?: number;
|
|
46
48
|
}): Promise<AnnounceResult>;
|
|
49
|
+
/** `announce` to several targets at once, one result per device, in the order given. A target that can't be reached is its own `played: false`. A group and one of its own members are not told apart. */
|
|
50
|
+
announceAll(targets: string[], clip: CastClip, options?: {
|
|
51
|
+
volume?: number;
|
|
52
|
+
timeoutMs?: number;
|
|
53
|
+
}): Promise<(AnnounceResult & {
|
|
54
|
+
target: string;
|
|
55
|
+
})[]>;
|
|
47
56
|
};
|
|
48
57
|
/** A payload as it came, before any schema. */
|
|
49
58
|
export type RawPayload = {
|
|
@@ -11,6 +11,8 @@ export const DEFAULT_MEDIA_RECEIVER = "CC1AD845";
|
|
|
11
11
|
export const ANSWER_MS = 10_000;
|
|
12
12
|
/** How long an announcement waits for its clip to finish, beyond the clip's own length when the device reports it. */
|
|
13
13
|
export const PLAY_MS = 60_000;
|
|
14
|
+
/** How long a clip gets to start playing. A clip a speaker can't play took 35–44 s to say so (live, 2026-11-09); good ones start within a few seconds. A stated guess. */
|
|
15
|
+
export const START_MS = 15_000;
|
|
14
16
|
const RECEIVER = "receiver-0";
|
|
15
17
|
/** Level writes, and mute writes, per device or group in any minute; more wait their turn, the newest winning. A guess: Cast publishes no limit. */
|
|
16
18
|
export const VOLUME_PER_MINUTE = 30;
|
|
@@ -33,6 +35,7 @@ function checkLevel(level) {
|
|
|
33
35
|
* aborts, or after `ms` (a function is read again each time it would expire, so a deadline can grow).
|
|
34
36
|
*/
|
|
35
37
|
function waitFor(clock, s, ms, what, pick, signal) {
|
|
38
|
+
const name = () => (typeof what === "string" ? what : what());
|
|
36
39
|
return new Promise((resolve, reject) => {
|
|
37
40
|
const done = new AbortController();
|
|
38
41
|
const finish = (settle) => done.signal.aborted ||
|
|
@@ -40,10 +43,10 @@ function waitFor(clock, s, ms, what, pick, signal) {
|
|
|
40
43
|
signal?.removeEventListener("abort", cancel),
|
|
41
44
|
done.abort(),
|
|
42
45
|
settle());
|
|
43
|
-
const cancel = () => finish(() => reject(new Error(`stopped waiting for ${
|
|
46
|
+
const cancel = () => finish(() => reject(new Error(`stopped waiting for ${name()}`)));
|
|
44
47
|
const off = s.listen((raw) => {
|
|
45
48
|
if (raw === undefined)
|
|
46
|
-
return finish(() => reject(new Error(`the socket closed waiting for ${
|
|
49
|
+
return finish(() => reject(new Error(`the socket closed waiting for ${name()}`)));
|
|
47
50
|
try {
|
|
48
51
|
const value = pick(raw);
|
|
49
52
|
if (value !== undefined)
|
|
@@ -59,7 +62,7 @@ function waitFor(clock, s, ms, what, pick, signal) {
|
|
|
59
62
|
void (async () => {
|
|
60
63
|
for (let left = limit(); left > 0 && !done.signal.aborted; left = start + limit() - clock.now())
|
|
61
64
|
await clock.sleep(left, done.signal);
|
|
62
|
-
finish(() => reject(new Error(`no ${
|
|
65
|
+
finish(() => reject(new Error(`no ${name()} in ${Math.round(limit() / 1000)} s`)));
|
|
63
66
|
})();
|
|
64
67
|
});
|
|
65
68
|
}
|
|
@@ -171,8 +174,11 @@ export function castActions(ctx) {
|
|
|
171
174
|
const loadId = s.nextId();
|
|
172
175
|
let session;
|
|
173
176
|
let durationS = 0;
|
|
177
|
+
let started = false; // it has reached PLAYING: BUFFERING is where a clip it can't play sits
|
|
174
178
|
const cancel = new AbortController();
|
|
175
|
-
const finished = waitFor(clock, s, () => timeoutMs ?? PLAY_MS + durationS * 1000
|
|
179
|
+
const finished = waitFor(clock, s, () => timeoutMs ?? (started ? PLAY_MS + durationS * 1000 : START_MS), () => started
|
|
180
|
+
? "end of the clip"
|
|
181
|
+
: `start of the clip (${media.contentType}, which this device may not play)`, (raw) => {
|
|
176
182
|
const r = MediaStatus.safeParse(raw);
|
|
177
183
|
if (!r.success)
|
|
178
184
|
return undefined;
|
|
@@ -187,11 +193,13 @@ export function castActions(ctx) {
|
|
|
187
193
|
continue;
|
|
188
194
|
if (typeof m.media?.duration === "number")
|
|
189
195
|
durationS = m.media.duration;
|
|
196
|
+
if (m.playerState === "PLAYING")
|
|
197
|
+
started = true;
|
|
190
198
|
if (m.playerState !== "IDLE" || !m.idleReason)
|
|
191
199
|
continue;
|
|
192
200
|
if (m.idleReason === "FINISHED")
|
|
193
201
|
return true;
|
|
194
|
-
throw new Error(`the clip stopped: ${m.idleReason}`);
|
|
202
|
+
throw new Error(`the clip stopped: ${m.idleReason}${m.detailedErrorCode === undefined ? "" : ` (detailed error ${m.detailedErrorCode})`}${m.idleReason === "ERROR" ? `; this device may not play ${media.contentType}` : ""}`);
|
|
195
203
|
}
|
|
196
204
|
return undefined;
|
|
197
205
|
}, cancel.signal);
|
|
@@ -216,7 +224,7 @@ export function castActions(ctx) {
|
|
|
216
224
|
.get(id)
|
|
217
225
|
.send("level" in volume ? "level" : "muted", volume, (v) => receiver(live(find(target)), { type: "SET_VOLUME", volume: v }));
|
|
218
226
|
};
|
|
219
|
-
|
|
227
|
+
const actions = {
|
|
220
228
|
async setVolume(target, level) {
|
|
221
229
|
checkLevel(level);
|
|
222
230
|
await setVolume(target, { level });
|
|
@@ -386,5 +394,33 @@ export function castActions(ctx) {
|
|
|
386
394
|
}
|
|
387
395
|
}
|
|
388
396
|
},
|
|
397
|
+
async announceAll(targets, clip, options = {}) {
|
|
398
|
+
// A caller's mistake is one error, not a failure per speaker.
|
|
399
|
+
if (options.volume !== undefined)
|
|
400
|
+
checkLevel(options.volume);
|
|
401
|
+
if (!("url" in clip) && !ctx.serve)
|
|
402
|
+
throw new Error("a clip of bytes needs serve: serveClip from @khalido/home/cast/serve");
|
|
403
|
+
const id = (t) => {
|
|
404
|
+
try {
|
|
405
|
+
return find(t).id;
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
return t; // unknown: announce says so, as this target's own failure
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
const once = targets.filter((t, i) => targets.findIndex((o) => id(o) === id(t)) === i); // one device twice would save the first's volume as the one to restore
|
|
412
|
+
return Promise.all(once.map(async (target) => ({
|
|
413
|
+
target,
|
|
414
|
+
...(await actions
|
|
415
|
+
.announce(target, clip, options)
|
|
416
|
+
.catch((error) => ({
|
|
417
|
+
played: false,
|
|
418
|
+
why: errorText(error),
|
|
419
|
+
restored: [],
|
|
420
|
+
notRestored: [],
|
|
421
|
+
}))),
|
|
422
|
+
})));
|
|
423
|
+
},
|
|
389
424
|
};
|
|
425
|
+
return actions;
|
|
390
426
|
}
|
|
@@ -24,7 +24,7 @@ export type SpotifyEvent = {
|
|
|
24
24
|
/**
|
|
25
25
|
* The events between two reports, for `onChange`'s `before` and `after`: `offline` and `online`; `waiting` (signed out) and
|
|
26
26
|
* `picked` (someone picked the speaker); `started` (from idle), `resumed` (from paused), `paused`, `stopped` (to idle), then
|
|
27
|
-
* `track` for a new track,
|
|
27
|
+
* `track` for a new track, once it has a name, then `stalled` and `recovered` (Soloist issue #5). Buffering counts as playing. No `before`, or one
|
|
28
28
|
* with no state (createHome's placeholder before the first report, as at a restart): nothing, since nothing was seen to change. No volume or queue events: read the state.
|
|
29
29
|
*/
|
|
30
30
|
export declare function spotifyChanges(before: Part<SpotifyState> | undefined, after: Part<SpotifyState> | undefined): SpotifyEvent[];
|
|
@@ -22,7 +22,7 @@ const PLAYING = new Set(["playing", "buffering"]);
|
|
|
22
22
|
/**
|
|
23
23
|
* The events between two reports, for `onChange`'s `before` and `after`: `offline` and `online`; `waiting` (signed out) and
|
|
24
24
|
* `picked` (someone picked the speaker); `started` (from idle), `resumed` (from paused), `paused`, `stopped` (to idle), then
|
|
25
|
-
* `track` for a new track,
|
|
25
|
+
* `track` for a new track, once it has a name, then `stalled` and `recovered` (Soloist issue #5). Buffering counts as playing. No `before`, or one
|
|
26
26
|
* with no state (createHome's placeholder before the first report, as at a restart): nothing, since nothing was seen to change. No volume or queue events: read the state.
|
|
27
27
|
*/
|
|
28
28
|
export function spotifyChanges(before, after) {
|
|
@@ -55,7 +55,9 @@ export function spotifyChanges(before, after) {
|
|
|
55
55
|
add({ type: "paused" });
|
|
56
56
|
if (to === "idle" && from !== undefined && from !== "idle")
|
|
57
57
|
add({ type: "stopped" });
|
|
58
|
-
|
|
58
|
+
// Soloist's first report of a new track can have no name yet (seen live): the event waits for it.
|
|
59
|
+
const named = (p) => !!p?.track?.name;
|
|
60
|
+
if (a?.track && named(a) && (a.track.uri !== b?.track?.uri || !named(b)))
|
|
59
61
|
add({ type: "track", track: a.track });
|
|
60
62
|
if (a?.stalled && !b?.stalled)
|
|
61
63
|
events.push({ type: "stalled", stall: a.stalled });
|
|
@@ -158,7 +158,7 @@ export type SpotifyState = {
|
|
|
158
158
|
name?: string;
|
|
159
159
|
};
|
|
160
160
|
position?: PositionAnchor;
|
|
161
|
-
/** 0–100, as Soloist reports it. */
|
|
161
|
+
/** 0–100, as Soloist reports it, rounded. Until `active` it isn't the level that will play (it reads `--initial-volume`, seen live). */
|
|
162
162
|
volume?: number;
|
|
163
163
|
shuffle?: boolean;
|
|
164
164
|
/** off, context or track. */
|
|
@@ -49,9 +49,11 @@ export type Aside<T> = {
|
|
|
49
49
|
/**
|
|
50
50
|
* The writes, on the object `spotify()` returns. Each resolves once Soloist reports the change it asked for, with the state
|
|
51
51
|
* then (`confirmed: true`), or after `CONFIRM_MS` with the state as it stands (`confirmed: false`). Each rejects with a
|
|
52
|
-
* `ConnectorError` whose `code` is `offline`, `waiting` (nobody has picked the speaker), `refused`, `timeout` or `invalid`.
|
|
52
|
+
* `ConnectorError` whose `code` is `offline`, `waiting` (nobody has picked the speaker), `inactive` (`setVolume` on a speaker that isn't the active device: `activate()` first), `refused`, `timeout` or `invalid`.
|
|
53
53
|
*/
|
|
54
54
|
export type SpotifyActions = {
|
|
55
|
+
/** Make this the account's active Spotify device. Soloist drops volume changes until it is. It makes a new audio stream, so the volume can jump to the level last saved. */
|
|
56
|
+
activate(): Promise<Applied>;
|
|
55
57
|
/** Play a Spotify link or URI (track, album, playlist, artist, episode, show), or resume what is loaded when none is given. */
|
|
56
58
|
play(linkOrUri?: string): Promise<Applied>;
|
|
57
59
|
pause(): Promise<Applied>;
|