@khalido/home 0.1.3 → 0.2.0
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/.env.example +2 -2
- package/CHANGELOG.md +41 -0
- package/README.md +7 -2
- package/dist/config.d.ts +5 -3
- package/dist/config.js +5 -1
- package/dist/connectors/cast/index.d.ts +1 -1
- package/dist/connectors/cast/index.js +1 -1
- package/dist/connectors/cast/state.d.ts +17 -3
- package/dist/connectors/cast/state.js +26 -7
- package/dist/connectors/daikin/client.d.ts +45 -2
- package/dist/connectors/daikin/client.js +67 -2
- package/dist/connectors/daikin/index.d.ts +2 -2
- package/dist/connectors/daikin/index.js +4 -3
- package/dist/connectors/hue/ca.d.ts +2 -0
- package/dist/connectors/hue/ca.js +38 -0
- package/dist/connectors/hue/client.d.ts +81 -6
- package/dist/connectors/hue/client.js +217 -6
- package/dist/connectors/hue/color.d.ts +30 -0
- package/dist/connectors/hue/color.js +92 -0
- package/dist/connectors/hue/index.d.ts +15 -4
- package/dist/connectors/hue/index.js +25 -8
- package/dist/connectors/hue/schemas.d.ts +27 -0
- package/dist/connectors/hue/schemas.js +26 -0
- package/dist/connectors/hue/state.d.ts +10 -1
- package/dist/connectors/hue/state.js +20 -1
- package/dist/connectors/spotify/changes.d.ts +19 -5
- package/dist/connectors/spotify/changes.js +29 -7
- package/dist/connectors/spotify/index.d.ts +1 -1
- package/dist/connectors/spotify/index.js +8 -2
- package/dist/devices.d.ts +24 -0
- package/dist/devices.js +56 -2
- package/dist/discover.js +1 -1
- package/dist/finders-entry.d.ts +1 -1
- package/dist/finders-entry.js +1 -1
- package/dist/finders.d.ts +2 -1
- package/dist/finders.js +16 -5
- package/dist/home.d.ts +11 -2
- package/dist/home.js +98 -34
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/node-fetch.d.ts +2 -2
- package/dist/node-fetch.js +3 -2
- package/docs/cli.md +13 -7
- package/docs/config.md +11 -3
- package/docs/decisions.md +12 -0
- package/docs/hue.md +161 -0
- package/docs/using.md +98 -3
- package/examples/home.jsonc +2 -1
- package/home.schema.json +10 -3
- package/llms.txt +19 -6
- package/package.json +1 -1
- package/skills/draw-home/SKILL.md +1 -1
- package/skills/use-home/SKILL.md +3 -3
- package/src/cli/cast.ts +51 -5
- package/src/cli/context.ts +2 -0
- package/src/cli/daikin.ts +71 -0
- package/src/cli/hue.ts +147 -59
- package/src/cli/main.ts +35 -10
- package/src/cli/probe.ts +21 -17
- package/src/cli/status.ts +61 -10
- package/src/config.ts +8 -2
- package/src/connectors/cast/README.md +6 -3
- package/src/connectors/cast/index.ts +7 -1
- package/src/connectors/cast/state.ts +32 -5
- package/src/connectors/daikin/README.md +16 -4
- package/src/connectors/daikin/client.ts +104 -3
- package/src/connectors/daikin/index.ts +8 -2
- package/src/connectors/hue/README.md +62 -17
- package/src/connectors/hue/ca.ts +39 -0
- package/src/connectors/hue/client.ts +362 -15
- package/src/connectors/hue/color.ts +123 -0
- package/src/connectors/hue/index.ts +44 -11
- package/src/connectors/hue/schemas.ts +26 -0
- package/src/connectors/hue/state.ts +27 -1
- package/src/connectors/spotify/README.md +8 -2
- package/src/connectors/spotify/changes.ts +38 -9
- package/src/connectors/spotify/index.ts +9 -1
- package/src/devices.ts +83 -3
- package/src/discover.ts +1 -2
- package/src/finders-entry.ts +1 -1
- package/src/finders.ts +18 -6
- package/src/home.ts +99 -39
- package/src/index.ts +7 -1
- package/src/node-fetch.ts +6 -2
|
@@ -6,6 +6,7 @@ import { z } from "zod";
|
|
|
6
6
|
// all are optional and unknown fields pass through, so a schema change degrades, never throws.
|
|
7
7
|
|
|
8
8
|
const ref = z.object({ rid: z.string(), rtype: z.string() });
|
|
9
|
+
const xy = z.looseObject({ x: z.number(), y: z.number() });
|
|
9
10
|
|
|
10
11
|
/** The resource kinds this library reads. */
|
|
11
12
|
export const KINDS = [
|
|
@@ -27,6 +28,31 @@ export const Resource = z.looseObject({
|
|
|
27
28
|
metadata: z.looseObject({ name: z.string().optional() }).optional(),
|
|
28
29
|
on: z.looseObject({ on: z.boolean() }).optional(),
|
|
29
30
|
dimming: z.looseObject({ brightness: z.number() }).optional(),
|
|
31
|
+
// A light's colour and the triangle of colours it can show; absent on a white light. A shape this doesn't expect
|
|
32
|
+
// reads as absent, so one odd light never fails the whole read.
|
|
33
|
+
color: z
|
|
34
|
+
.looseObject({
|
|
35
|
+
xy: xy.optional().catch(undefined),
|
|
36
|
+
gamut: z
|
|
37
|
+
.looseObject({ red: xy, green: xy, blue: xy })
|
|
38
|
+
.optional()
|
|
39
|
+
.catch(undefined),
|
|
40
|
+
})
|
|
41
|
+
.optional()
|
|
42
|
+
.catch(undefined),
|
|
43
|
+
// mirek is null while the light shows a colour; the schema is this light's own range.
|
|
44
|
+
color_temperature: z
|
|
45
|
+
.looseObject({
|
|
46
|
+
mirek: z.number().nullable().optional(),
|
|
47
|
+
mirek_schema: z
|
|
48
|
+
.looseObject({
|
|
49
|
+
mirek_minimum: z.number().optional(),
|
|
50
|
+
mirek_maximum: z.number().optional(),
|
|
51
|
+
})
|
|
52
|
+
.optional(),
|
|
53
|
+
})
|
|
54
|
+
.optional()
|
|
55
|
+
.catch(undefined),
|
|
30
56
|
children: z.array(ref).optional(),
|
|
31
57
|
services: z.array(ref).optional(),
|
|
32
58
|
group: ref.optional(), // scene: the room or zone it belongs to
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type Gamut, mirekToKelvin, type XY, xyToHex } from "./color.ts";
|
|
1
2
|
import type { HueEvent, Resource } from "./schemas.ts";
|
|
2
3
|
|
|
3
4
|
/** Every resource on the bridge by id, from the last full read plus every event since. `asOf` is local time it was last known fresh. */
|
|
@@ -56,9 +57,17 @@ export type LightView = {
|
|
|
56
57
|
on?: boolean;
|
|
57
58
|
brightness?: number;
|
|
58
59
|
reachable?: boolean;
|
|
60
|
+
/** White temperature in kelvin, while the light shows a white; its range is `kelvinRange` (warmest, coolest). */
|
|
61
|
+
kelvin?: number;
|
|
62
|
+
kelvinRange?: [number, number];
|
|
63
|
+
/** The colour it shows: the bridge's `xy`, and `hex` for a screen (the hue at full brightness). Colour lights only. */
|
|
64
|
+
xy?: XY;
|
|
65
|
+
hex?: string;
|
|
66
|
+
/** The colours it can show. Present on a colour light, so its presence says "takes a colour". */
|
|
67
|
+
gamut?: Gamut;
|
|
59
68
|
};
|
|
60
69
|
|
|
61
|
-
/** Every light, with on, brightness and
|
|
70
|
+
/** Every light, with on, brightness, reachability, and its white temperature and colour where it has them. */
|
|
62
71
|
export function lights(s: HueSnapshot): LightView[] {
|
|
63
72
|
const zigbee = ofType(s, "zigbee_connectivity");
|
|
64
73
|
return ofType(s, "light").map((l) => {
|
|
@@ -69,10 +78,27 @@ export function lights(s: HueSnapshot): LightView[] {
|
|
|
69
78
|
on: l.on?.on,
|
|
70
79
|
brightness: l.dimming?.brightness,
|
|
71
80
|
reachable: link ? link.status === "connected" : undefined,
|
|
81
|
+
...colorOf(l),
|
|
72
82
|
};
|
|
73
83
|
});
|
|
74
84
|
}
|
|
75
85
|
|
|
86
|
+
/** A light's temperature and colour fields, only those the bridge reported. */
|
|
87
|
+
function colorOf(l: Resource): Partial<LightView> {
|
|
88
|
+
const out: Partial<LightView> = {};
|
|
89
|
+
const ct = l.color_temperature;
|
|
90
|
+
if (typeof ct?.mirek === "number") out.kelvin = mirekToKelvin(ct.mirek);
|
|
91
|
+
const { mirek_minimum: min, mirek_maximum: max } = ct?.mirek_schema ?? {};
|
|
92
|
+
if (min !== undefined && max !== undefined)
|
|
93
|
+
out.kelvinRange = [mirekToKelvin(max), mirekToKelvin(min)];
|
|
94
|
+
if (l.color?.xy) {
|
|
95
|
+
out.xy = l.color.xy;
|
|
96
|
+
out.hex = xyToHex(l.color.xy);
|
|
97
|
+
}
|
|
98
|
+
if (l.color?.gamut) out.gamut = l.color.gamut;
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
76
102
|
/** A room or zone as a row shows it, with its grouped light's state. */
|
|
77
103
|
export type GroupView = {
|
|
78
104
|
id: string;
|
|
@@ -35,7 +35,7 @@ Soloist is Spotify's own headless Spotify Connect client (Linux arm64, armv7, x8
|
|
|
35
35
|
|
|
36
36
|
The restore runs even if `fn` throws: the error is `fn`'s, and a failed restore goes to `detail`. `duck` restores the earlier volume even above `maxVolume`. One aside at a time: never call one inside another's `fn` (it waits forever), and put two announcements in one `fn`. With Cast: `speaker.duck(() => cast.announce("Kitchen", clip), { to: 20 })`. Cast's own `announce` restores what a Cast device was playing; these are for Soloist's output.
|
|
37
37
|
|
|
38
|
-
**Reading it.** `speakerState` takes the Part; `spotifyText` and `positionMs` take its `state`. `speakerState(part)`: `offline`, `connecting`, `waiting`, then Soloist's status (`idle`, `playing`, `paused`, `buffering`, or its own string); signed in with no status yet is `idle`. `spotifyChanges(before, after)`, for `onChange`, gives events in this order: `offline`, `online`, `waiting`, `picked`, `started` (from idle), `resumed` (from paused), `paused`, `stopped`, `track`, `stalled`, `recovered`. Buffering counts as playing. Nothing on the first report, so a restart announces nothing; no volume or queue events. Playback events during an aside carry `own: true`; connection, sign-in and stall events never do. `track` waits for the track's name, which Soloist can send a few seconds after the track (seen live); one that never gets a name never fires. One press on a phone can fire track, resumed, stopped, started and track over 3 s, sometimes with a 0.8 s `waiting`, and the order varies: anything announcing these should wait a few seconds. `state.volume` isn't the level that will play until `active` (it reads `--initial-volume`).
|
|
38
|
+
**Reading it.** `speakerState` takes the Part; `spotifyText` and `positionMs` take its `state`. `speakerState(part)`: `offline`, `connecting`, `waiting`, `elsewhere` (Soloist says playing but isn't the active device: the music is on another Spotify device and this one is silent; `playingElsewhere(state)` is the test), then Soloist's status (`idle`, `playing`, `paused`, `buffering`, or its own string); signed in with no status yet is `idle`. `spotifyChanges(before, after)`, for `onChange`, gives events in this order: `offline`, `online`, `waiting`, `picked`, `started` (from idle), `resumed` (from paused), `paused`, `stopped`, `track`, `stalled`, `recovered`. Buffering counts as playing. Music elsewhere is silence here: leaving is `stopped`, arriving is `started` then `track`, and its tracks, pauses and stops meanwhile fire nothing (a resume there after a pause there fires one `stopped`: paused while inactive reads as Soloist says, so the resume looks like music leaving). Soloist doesn't say where it is; `spotifyElsewhere(snapshot)` from `@khalido/home` names the Cast devices playing that track in Spotify, and `homeDevices` puts them in the speaker's line (matched by name, built snapshots only, untried live), and is empty for a phone. Nothing on the first report, so a restart announces nothing; no volume or queue events. Playback events during an aside carry `own: true`; connection, sign-in and stall events never do. `track` waits for the track's name, which Soloist can send a few seconds after the track (seen live); one that never gets a name never fires. One press on a phone can fire track, resumed, stopped, started and track over 3 s, sometimes with a 0.8 s `waiting`, and the order varies: anything announcing these should wait a few seconds. `state.volume` isn't the level that will play until `active` (it reads `--initial-volume`).
|
|
39
39
|
|
|
40
40
|
**Stalls** ([#5](https://github.com/spotify/soloist/issues/5): playback stops mid-track, often the first after idle, while Soloist still says `playing`). While playing, one `get_state` every 20 s (`stallCheckMs`, code only; 0 turns it off).
|
|
41
41
|
|
|
@@ -68,12 +68,18 @@ Writes through this connector, and what a live read hasn't shown. In particular:
|
|
|
68
68
|
|
|
69
69
|
## Live findings
|
|
70
70
|
|
|
71
|
+
**2026-11-11, Raspberry Pi 5, Bun 1.4.4, on 0.1.3** (chota-bot's session; `maxVolume: 40` watching, writes from the CLI in another process). Two gaps in the cap, not fixed:
|
|
72
|
+
|
|
73
|
+
- From inactive and paused at 60, `play`: lowered to 40 within 2 ms of going active, `playing` at 40, then 60 again 90 ms after `playing`, and it played at 60. The cap takes its first confirmed lowering as done, so nothing lowered it again, and `anomaly` still said "lowered it". Soloist puts its own level back after `playing`.
|
|
74
|
+
- The connector started while the speaker was already active and paused at 60: `play` resumed at 60, nothing lowered it, no anomaly. This is the documented "not on resuming from pause while active".
|
|
75
|
+
- A volume set by hand (35) held through pause and play, so the put-back belongs to activation, not to pause.
|
|
76
|
+
|
|
71
77
|
**2026-11-10, Raspberry Pi 5, Bun 1.4.4, on 0.1.2, through this connector** (chota-bot's session, KO listening).
|
|
72
78
|
|
|
73
79
|
- Confirmed with `maxVolume: 60`, the speaker active and playing: `activate`, `setVolume(50)`, `setVolume(90)` (came back 60), `seek`, `setShuffle`, `setRepeat("context")` and `("off")` (about 2.1 s each), `queue` (first in `upNext`), `play` of a playlist URI. `duck(fn, { to: 20 })` around a Cast announce: 20 inside, `music: "restored"`, 60 after, 5.2 s in all.
|
|
74
80
|
- **The level can jump above the cap on a pause, not only on a start.** One-shot CLI, cap 60: `volume 45` read 45, `play` 45, then `pause` read 62 and the next `play` 62 (62 was the level before the test). Nothing lowered it: a pause is not a start signal, and each CLI command is a new process.
|
|
75
81
|
- **That jump did not repeat, and it is not WirePlumber.** A second pass the same day, the speaker already active: 60, `volume 45`, `pause`, `play`, `volume 60`. Soloist's number and the stream's live level (`wpctl get-volume`) matched at every step (0.60, 0.45, 0.45, 0.45, 0.60), WirePlumber 0.5.8 never wrote a saved entry for the stream in `stream-properties`, and the stream kept its id across pause and play. The run that jumped had just been made active with `activate`, minutes after the account was casting from a phone, which points at the account side. One case each way.
|
|
76
|
-
- **`active: false` with `status: "playing"`:** the account playing on another speaker. `speakerState`
|
|
82
|
+
- **`active: false` with `status: "playing"`:** the account playing on another speaker. `speakerState` said `playing`, `spotifyText` printed it as this speaker's, and `spotifyChanges` fired `resumed`, with the box silent. Since 2026-11-11 all three read it as `elsewhere`, and the stall watch skips it; not yet seen live.
|
|
77
83
|
- Through chota-bot's wrapper, later the same day: `play` of a playlist confirmed in 241 ms, `next` in 134 ms, `pause` in 17 ms; `duck` to 15 and `interrupt`, 2.5 s each, both `restored`. `play()` with the music paused on a Cast speaker: confirmed in 1.3 s, playing on the box at the same position.
|
|
78
84
|
- **`interrupt` paused music that was on another speaker** (found in chota's switch): it checked `playing` and not `active`. Fixed after 0.1.2.
|
|
79
85
|
- `positionMs` around a duck and an interrupt read 2 s, 2 s, 3 s, 5 s, then 4 s after a pause: it can step back a second.
|
|
@@ -14,11 +14,24 @@ export function stallText(s: Stall | undefined): string | undefined {
|
|
|
14
14
|
return s.tried ? `${what}; tried ${s.tried}` : what;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
const PLAYING = new Set(["playing", "buffering"]);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Soloist says playing while it is not the account's active device: the music is on another Spotify device (a phone,
|
|
21
|
+
* another speaker) and this one is silent (live, 2026-11-10). Soloist doesn't say which. Unknown `active` is not elsewhere.
|
|
22
|
+
*/
|
|
23
|
+
export const playingElsewhere = (s: SpotifyState | undefined): boolean =>
|
|
24
|
+
s?.active === false && PLAYING.has(s.status ?? "");
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The speaker in one word, to switch on. `offline`: not reached (or no part); `waiting`: running, nobody has picked it;
|
|
28
|
+
* `elsewhere`: the account's music is playing on another Spotify device, not this one. A status Soloist sends beyond its four passes through.
|
|
29
|
+
*/
|
|
18
30
|
export type SpeakerState =
|
|
19
31
|
| "offline"
|
|
20
32
|
| "connecting"
|
|
21
33
|
| "waiting"
|
|
34
|
+
| "elsewhere"
|
|
22
35
|
| "idle"
|
|
23
36
|
| "playing"
|
|
24
37
|
| "paused"
|
|
@@ -32,6 +45,7 @@ export function speakerState(
|
|
|
32
45
|
return "offline";
|
|
33
46
|
if (part.status === "connecting") return "connecting";
|
|
34
47
|
if (part.state?.loggedIn === false) return "waiting";
|
|
48
|
+
if (playingElsewhere(part.state)) return "elsewhere";
|
|
35
49
|
return part.state?.status ?? "idle"; // signed in, nothing reported yet: nothing is playing
|
|
36
50
|
}
|
|
37
51
|
|
|
@@ -57,12 +71,12 @@ export type SpotifyEvent = { own?: true } & (
|
|
|
57
71
|
| { type: "track"; track: Track }
|
|
58
72
|
);
|
|
59
73
|
|
|
60
|
-
const PLAYING = new Set(["playing", "buffering"]);
|
|
61
|
-
|
|
62
74
|
/**
|
|
63
75
|
* The events between two reports, for `onChange`'s `before` and `after`: `offline` and `online`; `waiting` (signed out) and
|
|
64
76
|
* `picked` (someone picked the speaker); `started` (from idle), `resumed` (from paused), `paused`, `stopped` (to idle), then
|
|
65
|
-
* `track` for a new track, once it has a name, then `stalled` and `recovered` (Soloist issue #5). Buffering counts as playing.
|
|
77
|
+
* `track` for a new track, once it has a name, then `stalled` and `recovered` (Soloist issue #5). Buffering counts as playing.
|
|
78
|
+
* Music on another Spotify device (`playingElsewhere`) is not playing here: it moving away is `stopped`, it arriving is `started`
|
|
79
|
+
* then `track`, and nothing fires for its tracks meanwhile. No `before`, or one
|
|
66
80
|
* 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.
|
|
67
81
|
*/
|
|
68
82
|
export function spotifyChanges(
|
|
@@ -87,8 +101,11 @@ export function spotifyChanges(
|
|
|
87
101
|
events.push({ type: "waiting" });
|
|
88
102
|
if (b?.loggedIn === false && a?.loggedIn === true)
|
|
89
103
|
events.push({ type: "picked" });
|
|
90
|
-
|
|
91
|
-
const
|
|
104
|
+
// What this speaker itself is doing: music on another device is silence here.
|
|
105
|
+
const here = (s?: SpotifyState) => (playingElsewhere(s) ? "idle" : s?.status);
|
|
106
|
+
const from = here(b);
|
|
107
|
+
// Still inactive after playing elsewhere: a pause or stop there is the other device's, not this one's.
|
|
108
|
+
const to = playingElsewhere(b) && a?.active === false ? "idle" : here(a);
|
|
92
109
|
const playingNow = to !== undefined && PLAYING.has(to);
|
|
93
110
|
const playingThen = from !== undefined && PLAYING.has(from);
|
|
94
111
|
if (playingNow && !playingThen)
|
|
@@ -98,7 +115,13 @@ export function spotifyChanges(
|
|
|
98
115
|
add({ type: "stopped" });
|
|
99
116
|
// Soloist's first report of a new track can have no name yet (seen live): the event waits for it.
|
|
100
117
|
const named = (p?: SpotifyState) => !!p?.track?.name;
|
|
101
|
-
|
|
118
|
+
const heard = playingElsewhere(b) && playingNow ? undefined : b; // a track that was on another device is new here once it plays here
|
|
119
|
+
if (
|
|
120
|
+
a?.track &&
|
|
121
|
+
named(a) &&
|
|
122
|
+
!playingElsewhere(a) &&
|
|
123
|
+
(a.track.uri !== heard?.track?.uri || !named(heard))
|
|
124
|
+
)
|
|
102
125
|
add({ type: "track", track: a.track });
|
|
103
126
|
if (a?.stalled && !b?.stalled)
|
|
104
127
|
events.push({ type: "stalled", stall: a.stalled });
|
|
@@ -112,14 +135,20 @@ export function spotifyChanges(
|
|
|
112
135
|
return events;
|
|
113
136
|
}
|
|
114
137
|
|
|
115
|
-
/**
|
|
116
|
-
|
|
138
|
+
/**
|
|
139
|
+
* The speaker in a few words: `Kitchen speaker: playing "Song" by Band · 35%`, `…: waiting for someone to pick it`, or,
|
|
140
|
+
* for music on another Spotify device, `…: silent · "Song" by Band is playing on another Spotify device` (no volume: it isn't this speaker's playing level).
|
|
141
|
+
* `on` names where, when the caller knows (`spotifyElsewhere`): `… is playing on Front room speaker`.
|
|
142
|
+
*/
|
|
143
|
+
export function spotifyText(s: SpotifyState, on: string[] = []): string {
|
|
117
144
|
const who = s.deviceName ?? "Soloist";
|
|
118
145
|
if (s.loggedIn === false) return `${who}: waiting for someone to pick it`;
|
|
119
146
|
const t = s.track;
|
|
120
147
|
const what = t
|
|
121
148
|
? `${t.name ? ` "${t.name}"` : ` ${t.uri}`}${t.artists.length ? ` by ${t.artists.join(", ")}` : ""}`
|
|
122
149
|
: "";
|
|
150
|
+
if (playingElsewhere(s))
|
|
151
|
+
return `${who}: silent ·${what || " music"} is playing on ${on.length ? on.join(", ") : "another Spotify device"}`;
|
|
123
152
|
const parts = [
|
|
124
153
|
`${s.status ?? "status unknown"}${what}`,
|
|
125
154
|
s.volume === undefined ? "" : `${s.volume}%`,
|
|
@@ -43,6 +43,7 @@ export {
|
|
|
43
43
|
} from "./client.ts";
|
|
44
44
|
export {
|
|
45
45
|
type SpeakerState,
|
|
46
|
+
playingElsewhere,
|
|
46
47
|
speakerState,
|
|
47
48
|
type SpotifyEvent,
|
|
48
49
|
spotifyChanges,
|
|
@@ -836,6 +837,7 @@ export function spotify(
|
|
|
836
837
|
!st ||
|
|
837
838
|
state.aside === "repair" ||
|
|
838
839
|
(state.track?.uri === st.track &&
|
|
840
|
+
state.active !== false && // moved to another device: no longer this speaker's stall
|
|
839
841
|
(state.status === "playing" || state.status === "buffering"))
|
|
840
842
|
)
|
|
841
843
|
return;
|
|
@@ -870,7 +872,12 @@ export function spotify(
|
|
|
870
872
|
a.track === b.track &&
|
|
871
873
|
b.atMs - a.atMs >= gap &&
|
|
872
874
|
Math.abs(b.ms - a.ms) < 1000;
|
|
873
|
-
|
|
875
|
+
// Not while inactive: "playing" then is another device's music, and a repair's seek or pause would land on it.
|
|
876
|
+
const playing = () =>
|
|
877
|
+
isOpen() &&
|
|
878
|
+
state.status === "playing" &&
|
|
879
|
+
state.active !== false &&
|
|
880
|
+
!state.aside;
|
|
874
881
|
|
|
875
882
|
/** One look while playing: a fresh position, then frozen, overrun, recovered or fine (Soloist issue #5). */
|
|
876
883
|
const checkStall = async () => {
|
|
@@ -916,6 +923,7 @@ export function spotify(
|
|
|
916
923
|
const ours = () =>
|
|
917
924
|
isOpen() &&
|
|
918
925
|
state.status === "playing" &&
|
|
926
|
+
state.active !== false &&
|
|
919
927
|
state.track?.uri === track &&
|
|
920
928
|
state.stalled?.track === track &&
|
|
921
929
|
(!state.aside || state.aside === "repair");
|
package/src/devices.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type CastHomeDevice,
|
|
3
|
+
type CastSnapshot,
|
|
3
4
|
castHomeDevices,
|
|
4
5
|
} from "./connectors/cast/index.ts";
|
|
5
6
|
import {
|
|
@@ -16,8 +17,11 @@ import {
|
|
|
16
17
|
printersHomeDevices,
|
|
17
18
|
} from "./connectors/printers/index.ts";
|
|
18
19
|
import {
|
|
20
|
+
playingElsewhere,
|
|
19
21
|
type SpotifyHomeDevice,
|
|
22
|
+
type SpotifyState,
|
|
20
23
|
spotifyHomeDevices,
|
|
24
|
+
spotifyText,
|
|
21
25
|
} from "./connectors/spotify/index.ts";
|
|
22
26
|
import type { HomeConfig } from "./config.ts";
|
|
23
27
|
import { IDENTITY, sameId } from "./discover.ts";
|
|
@@ -59,9 +63,17 @@ export function homeDevices(
|
|
|
59
63
|
snapshot: Record<string, Part | undefined>,
|
|
60
64
|
config?: Pick<HomeConfig, "devices" | "rooms">,
|
|
61
65
|
): HomeDevice[] {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
)
|
|
66
|
+
// Soloist doesn't say where its music went; a Cast device playing that track does, so the speaker's line names it.
|
|
67
|
+
const on = spotifyElsewhere(snapshot);
|
|
68
|
+
const all = Object.entries(DEVICES)
|
|
69
|
+
.flatMap(([name, devices]): HomeDevice[] =>
|
|
70
|
+
devices(snapshot[name] as never),
|
|
71
|
+
)
|
|
72
|
+
.map((d) =>
|
|
73
|
+
d.connector === "spotify" && on.length
|
|
74
|
+
? { ...d, text: spotifyText(d.detail, on) }
|
|
75
|
+
: d,
|
|
76
|
+
);
|
|
65
77
|
if (!config) return all;
|
|
66
78
|
const entries = config.devices ?? [];
|
|
67
79
|
const hueRooms = new Map<string, string>(); // a Hue group's or light's id → the config room showing it
|
|
@@ -100,3 +112,71 @@ export function homeDevices(
|
|
|
100
112
|
return { ...d, name, text, ...(room ? { room } : {}) };
|
|
101
113
|
});
|
|
102
114
|
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Where the account's Spotify is playing when the Soloist speaker says it is on another device: the names of the Cast
|
|
118
|
+
* devices and groups playing a track of that name in Spotify. A group is named, not its members. Empty when the music is
|
|
119
|
+
* on the speaker itself, nothing is playing, or it is on something Cast can't see (a phone, a laptop). Matched by the
|
|
120
|
+
* track's name, since neither side names the other.
|
|
121
|
+
*/
|
|
122
|
+
export function spotifyElsewhere(
|
|
123
|
+
snapshot: Record<string, Part | undefined>,
|
|
124
|
+
): string[] {
|
|
125
|
+
const s = snapshot.spotify?.state as SpotifyState | undefined;
|
|
126
|
+
const cast = snapshot.cast?.state as CastSnapshot | undefined;
|
|
127
|
+
const name = s?.track?.name;
|
|
128
|
+
if (!playingElsewhere(s) || !name || !cast) return [];
|
|
129
|
+
return Object.values(cast.devices)
|
|
130
|
+
.filter(
|
|
131
|
+
(d) =>
|
|
132
|
+
d.status === "connected" &&
|
|
133
|
+
!d.playingVia && // its group's music: the group is named
|
|
134
|
+
d.app === "Spotify" &&
|
|
135
|
+
d.title === name &&
|
|
136
|
+
(d.playerState === "PLAYING" || d.playerState === "BUFFERING"),
|
|
137
|
+
)
|
|
138
|
+
.map((d) => d.name);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A room of the home config with the devices placed in it. `noData`: device connectors the config places here that listed nothing in it (never reached, not set up, or a `hueRoom` the bridge doesn't have), so the room isn't empty, it is unread. Per connector, not per device. */
|
|
142
|
+
export type RoomDevices = {
|
|
143
|
+
id: string;
|
|
144
|
+
name: string;
|
|
145
|
+
floor?: string;
|
|
146
|
+
devices: HomeDevice[];
|
|
147
|
+
noData: string[];
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* `homeDevices` room by room, in the config's order, for a map of the home. A connector that is down but has read before
|
|
152
|
+
* still lists its devices, `available: false`; `noData` names one the config places in the room (a device's `room`, or
|
|
153
|
+
* `hueRoom`) that has nothing to list. `unplaced`: devices with no room, or one the config doesn't have.
|
|
154
|
+
*/
|
|
155
|
+
export function byRoom(
|
|
156
|
+
snapshot: Record<string, Part | undefined>,
|
|
157
|
+
config: Pick<HomeConfig, "devices" | "rooms">,
|
|
158
|
+
): { rooms: RoomDevices[]; unplaced: HomeDevice[] } {
|
|
159
|
+
const all = homeDevices(snapshot, config);
|
|
160
|
+
const ids = new Set((config.rooms ?? []).map((r) => r.id));
|
|
161
|
+
return {
|
|
162
|
+
rooms: (config.rooms ?? []).map((r) => {
|
|
163
|
+
const devices = all.filter((d) => d.room === r.id);
|
|
164
|
+
const expected = new Set([
|
|
165
|
+
...(config.devices ?? [])
|
|
166
|
+
.filter((d) => d.room === r.id)
|
|
167
|
+
.map((d) => d.connector),
|
|
168
|
+
...(r.hueRoom === undefined ? [] : ["hue"]),
|
|
169
|
+
]);
|
|
170
|
+
return {
|
|
171
|
+
id: r.id,
|
|
172
|
+
name: r.name,
|
|
173
|
+
...(r.floor === undefined ? {} : { floor: r.floor }),
|
|
174
|
+
devices,
|
|
175
|
+
noData: [...expected].filter(
|
|
176
|
+
(c) => c in DEVICES && !devices.some((d) => d.connector === c),
|
|
177
|
+
),
|
|
178
|
+
};
|
|
179
|
+
}),
|
|
180
|
+
unplaced: all.filter((d) => !d.room || !ids.has(d.room)),
|
|
181
|
+
};
|
|
182
|
+
}
|
package/src/discover.ts
CHANGED
|
@@ -134,8 +134,7 @@ function newEntry(f: Found): Record<string, string | number> {
|
|
|
134
134
|
host: f.host,
|
|
135
135
|
[IDENTITY[f.connector]]: f.id,
|
|
136
136
|
};
|
|
137
|
-
if (f.connector === "hue")
|
|
138
|
-
return { ...base, key: "HUE_APPLICATION_KEY", ca: "hue-root-ca.pem" };
|
|
137
|
+
if (f.connector === "hue") return { ...base, key: "HUE_APPLICATION_KEY" };
|
|
139
138
|
if (f.connector === "enphase")
|
|
140
139
|
return { ...base, token: "~/.config/home/enphase-token.json" };
|
|
141
140
|
return base;
|
package/src/finders-entry.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// `@khalido/home/finders`: what a Node consumer passes to `loadHome`. The rest of finders.ts is the CLI's.
|
|
2
|
-
export { FINDERS, fileCache } from "./finders.ts";
|
|
2
|
+
export { FINDERS, fileCache, MissingTool } from "./finders.ts";
|
package/src/finders.ts
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
// `fileCache` keeps the addresses discovery found in a file between runs, so the next run connects straight away.
|
|
33
33
|
|
|
34
34
|
/** The system's mDNS tool is not installed: discovery can't run until it is. */
|
|
35
|
+
/** The discovery tool isn't installed (`dns-sd` on macOS, `avahi-browse` from avahi-utils on Linux): the message names it. */
|
|
35
36
|
export class MissingTool extends Error {}
|
|
36
37
|
|
|
37
38
|
/**
|
|
@@ -153,21 +154,32 @@ function decoded(v: string): string {
|
|
|
153
154
|
}
|
|
154
155
|
}
|
|
155
156
|
|
|
156
|
-
/** Broadcast pydaikin's discovery packet (UDP 30000 → 30050) and collect who answers
|
|
157
|
+
/** Broadcast pydaikin's discovery packet (UDP 30000 → 30050), three times across `ms`, and collect who answers, each unit once. */
|
|
157
158
|
export function findDaikins(
|
|
158
159
|
ms = 1500,
|
|
159
160
|
): Promise<{ ip: string; fields: Record<string, string> }[]> {
|
|
160
161
|
return new Promise((resolve) => {
|
|
161
162
|
const found: { ip: string; fields: Record<string, string> }[] = [];
|
|
162
163
|
const sock = createSocket({ type: "udp4", reuseAddr: true });
|
|
163
|
-
sock.on("message", (msg, from) =>
|
|
164
|
-
found.
|
|
164
|
+
sock.on("message", (msg, from) => {
|
|
165
|
+
if (!found.some((f) => f.ip === from.address))
|
|
166
|
+
found.push({ ip: from.address, fields: daikinFields(msg.toString()) });
|
|
167
|
+
});
|
|
168
|
+
const asks: ReturnType<typeof setTimeout>[] = [];
|
|
169
|
+
const done = () => (
|
|
170
|
+
asks.forEach(clearTimeout),
|
|
171
|
+
sock.close(),
|
|
172
|
+
resolve(found)
|
|
165
173
|
);
|
|
166
|
-
sock.on("error",
|
|
174
|
+
sock.on("error", done);
|
|
167
175
|
sock.bind(30000, () => {
|
|
168
176
|
sock.setBroadcast(true);
|
|
169
|
-
|
|
170
|
-
|
|
177
|
+
// UDP drops packets, more so while mDNS is busy beside it: one ask missed KO's unit in 2 runs of 3 (2026-11-11). Ask three times.
|
|
178
|
+
const ask = () =>
|
|
179
|
+
sock.send("DAIKIN_UDP/common/basic_info", 30050, "255.255.255.255");
|
|
180
|
+
ask();
|
|
181
|
+
asks.push(setTimeout(ask, ms / 3), setTimeout(ask, (2 * ms) / 3));
|
|
182
|
+
setTimeout(done, ms);
|
|
171
183
|
});
|
|
172
184
|
});
|
|
173
185
|
}
|
package/src/home.ts
CHANGED
|
@@ -140,7 +140,7 @@ function stamped(part: Part, before: Part | undefined, now: string): Part {
|
|
|
140
140
|
|
|
141
141
|
/**
|
|
142
142
|
* Load a home: run each integration, keep one snapshot, call `onChange` with it and what changed after every report.
|
|
143
|
-
* `clock` stamps each part's `lastGoodAt` and `downSince`.
|
|
143
|
+
* `clock` stamps each part's `lastGoodAt` and `downSince`. `add` and `remove` change the set while it runs.
|
|
144
144
|
*/
|
|
145
145
|
export function createHome<I extends Integration<any, string>>(
|
|
146
146
|
integrations: readonly I[],
|
|
@@ -148,14 +148,34 @@ export function createHome<I extends Integration<any, string>>(
|
|
|
148
148
|
options: { clock?: Clock } = {},
|
|
149
149
|
) {
|
|
150
150
|
type Name = I["name"];
|
|
151
|
+
type Run = { stop: AbortController; done: Promise<void> };
|
|
151
152
|
const clock = options.clock ?? realClock;
|
|
153
|
+
const list: I[] = [...integrations]; // the integrations now: `add` and `remove` change it
|
|
154
|
+
const parts = () => snapshot as Record<string, Part>;
|
|
152
155
|
let snapshot = Object.fromEntries(
|
|
153
|
-
|
|
156
|
+
list.map((i) => [i.name, { status: "connecting" }]),
|
|
154
157
|
) as HomeSnapshot<I>;
|
|
155
158
|
const waiting = new Map<string, (() => void)[]>(); // refresh() callers, resolved by the integration's next report
|
|
156
|
-
const
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
+
const runs = new Map<I, Run>(); // the ones running: before start or after its run ended, a refresh resolves at once, nothing will report
|
|
160
|
+
const latest = new WeakMap<I, Run>(); // each integration's newest run: a report from an older run of the same object is dropped
|
|
161
|
+
let started: AbortSignal | undefined; // start()'s signal, while it runs
|
|
162
|
+
let changing: Promise<unknown> = Promise.resolve(); // add and remove, one at a time: two on one name must not overlap
|
|
163
|
+
let changes = 0; // those under way or queued: start outlasts a replace of its only integration
|
|
164
|
+
const change = <T>(fn: () => Promise<T>): Promise<T> => {
|
|
165
|
+
changes++;
|
|
166
|
+
const done = changing.then(fn).finally(() => changes--);
|
|
167
|
+
changing = done.catch(() => {});
|
|
168
|
+
return done;
|
|
169
|
+
};
|
|
170
|
+
const heard = (name: string) => {
|
|
171
|
+
for (const resolve of waiting.get(name) ?? []) resolve();
|
|
172
|
+
waiting.delete(name);
|
|
173
|
+
};
|
|
174
|
+
// A report counts only from the integration now listed under its name: one replaced or removed may still be finishing.
|
|
175
|
+
const set = (i: I, run: Run, reported: Part) => {
|
|
176
|
+
const name: Name = i.name;
|
|
177
|
+
const before = parts()[name];
|
|
178
|
+
if (latest.get(i) !== run || !list.includes(i) || !before) return;
|
|
159
179
|
const part = stamped(reported, before, asOf(clock));
|
|
160
180
|
snapshot = { ...snapshot, [name]: part };
|
|
161
181
|
Promise.try(() =>
|
|
@@ -163,11 +183,45 @@ export function createHome<I extends Integration<any, string>>(
|
|
|
163
183
|
).catch((error) =>
|
|
164
184
|
console.error(`onChange threw on ${name}: ${errorText(error)}`),
|
|
165
185
|
); // the caller's bug, thrown or async, not the device's: the integration keeps running
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
186
|
+
heard(name);
|
|
187
|
+
};
|
|
188
|
+
/** Run one integration until the home's signal or its own `stop` aborts. One failing marks itself unavailable. */
|
|
189
|
+
const launch = (i: I, signal: AbortSignal) => {
|
|
190
|
+
const stop = new AbortController();
|
|
191
|
+
const run: Run = { stop, done: Promise.resolve() };
|
|
192
|
+
runs.set(i, run);
|
|
193
|
+
latest.set(i, run);
|
|
194
|
+
run.done = Promise.try(() =>
|
|
195
|
+
i.run(AbortSignal.any([signal, stop.signal]), (part) =>
|
|
196
|
+
set(i, run, part),
|
|
197
|
+
),
|
|
198
|
+
)
|
|
199
|
+
.catch((error) =>
|
|
200
|
+
set(i, run, {
|
|
201
|
+
status: "unavailable",
|
|
202
|
+
reason: "crashed",
|
|
203
|
+
detail: errorText(error),
|
|
204
|
+
state: parts()[i.name]?.state, // the last good state stays, as it does through a failed read
|
|
205
|
+
}),
|
|
206
|
+
)
|
|
207
|
+
.finally(() => {
|
|
208
|
+
if (runs.get(i) !== run) return;
|
|
209
|
+
runs.delete(i);
|
|
210
|
+
if (list.includes(i)) heard(i.name); // nothing more will report
|
|
211
|
+
});
|
|
212
|
+
};
|
|
213
|
+
/** Take one out of the list and the snapshot, and wait for its run to end. */
|
|
214
|
+
const drop = async (name: string) => {
|
|
215
|
+
const at = list.findIndex((i) => i.name === name);
|
|
216
|
+
if (at < 0) return false;
|
|
217
|
+
const [old] = list.splice(at, 1);
|
|
218
|
+
const { [name]: _, ...rest } = parts();
|
|
219
|
+
snapshot = rest as HomeSnapshot<I>;
|
|
220
|
+
heard(name);
|
|
221
|
+
const run = runs.get(old!);
|
|
222
|
+
run?.stop.abort();
|
|
223
|
+
await run?.done;
|
|
224
|
+
return true;
|
|
171
225
|
};
|
|
172
226
|
return {
|
|
173
227
|
/** The latest snapshot. Never touches a device. */
|
|
@@ -178,61 +232,67 @@ export function createHome<I extends Integration<any, string>>(
|
|
|
178
232
|
integration: <N extends Name>(
|
|
179
233
|
name: N,
|
|
180
234
|
): Extract<I, { name: N }> | undefined =>
|
|
181
|
-
|
|
182
|
-
Extract<I, { name: N }> | undefined,
|
|
235
|
+
list.find((i) => i.name === name) as Extract<I, { name: N }> | undefined,
|
|
183
236
|
/**
|
|
184
237
|
* Read now: one integration by name, or every one. Results arrive through the snapshot and `onChange` as usual; the
|
|
185
238
|
* promise resolves once each target has reported (its next update, good or bad), or at once for one that isn't running.
|
|
186
239
|
*/
|
|
187
240
|
refresh(name?: Name): Promise<void> {
|
|
188
241
|
const targets =
|
|
189
|
-
name === undefined
|
|
190
|
-
|
|
191
|
-
: integrations.filter((i) => i.name === name);
|
|
192
|
-
if (!targets.length)
|
|
242
|
+
name === undefined ? [...list] : list.filter((i) => i.name === name);
|
|
243
|
+
if (!targets.length && name !== undefined)
|
|
193
244
|
throw new Error(
|
|
194
|
-
`no integration "${name}"; have ${
|
|
245
|
+
`no integration "${name}"; have ${list.map((i) => i.name).join(", ")}`,
|
|
195
246
|
);
|
|
196
247
|
const reported = targets.map(
|
|
197
248
|
(i) =>
|
|
198
249
|
new Promise<void>((resolve) => {
|
|
199
|
-
if (
|
|
250
|
+
if (!runs.has(i)) return resolve();
|
|
200
251
|
waiting.set(i.name, [...(waiting.get(i.name) ?? []), resolve]);
|
|
201
252
|
}),
|
|
202
253
|
);
|
|
203
254
|
for (const i of targets) i.refresh();
|
|
204
255
|
return Promise.all(reported).then(() => undefined);
|
|
205
256
|
},
|
|
206
|
-
/**
|
|
257
|
+
/**
|
|
258
|
+
* Add an integration while the home runs, or before it starts: a bridge paired just now, a key that arrived late. One
|
|
259
|
+
* already there under that name is stopped first and its part dropped, so the new one starts from `connecting` and
|
|
260
|
+
* the two never run together. Resolves once the new one is running; its reports arrive as any other's. Calls on
|
|
261
|
+
* one home run one at a time. Once `start`'s signal has aborted, an added one waits for the next `start`.
|
|
262
|
+
*/
|
|
263
|
+
add: (integration: I): Promise<void> =>
|
|
264
|
+
change(async () => {
|
|
265
|
+
await drop(integration.name);
|
|
266
|
+
list.push(integration);
|
|
267
|
+
const live = started && !started.aborted ? started : undefined;
|
|
268
|
+
snapshot = {
|
|
269
|
+
...snapshot,
|
|
270
|
+
[integration.name]: {
|
|
271
|
+
status: "connecting",
|
|
272
|
+
...(live ? { downSince: asOf(clock) } : {}),
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
if (live) launch(integration, live);
|
|
276
|
+
}),
|
|
277
|
+
/** Stop one integration and drop its part from the snapshot. Resolves once its run has ended; false if there was none by that name. */
|
|
278
|
+
remove: (name: Name): Promise<boolean> => change(() => drop(name)),
|
|
279
|
+
/** Run every integration, and any added meanwhile, until `signal` aborts. One failing marks itself unavailable; the rest carry on. */
|
|
207
280
|
async start(signal: AbortSignal): Promise<void> {
|
|
208
|
-
|
|
281
|
+
started = signal;
|
|
209
282
|
const now = asOf(clock); // the wait for a first report counts from here, not from createHome
|
|
210
283
|
snapshot = Object.fromEntries(
|
|
211
|
-
Object.entries(
|
|
284
|
+
Object.entries(parts()).map(([name, p]) => [
|
|
212
285
|
name,
|
|
213
286
|
p.status === "connecting"
|
|
214
287
|
? { ...p, downSince: p.downSince ?? now }
|
|
215
288
|
: p,
|
|
216
289
|
]),
|
|
217
290
|
) as HomeSnapshot<I>;
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
status: "unavailable",
|
|
224
|
-
reason: "crashed",
|
|
225
|
-
detail: errorText(error),
|
|
226
|
-
state: (snapshot as Record<string, Part>)[i.name]!.state, // the last good state stays, as it does through a failed read
|
|
227
|
-
}),
|
|
228
|
-
)
|
|
229
|
-
.finally(() => {
|
|
230
|
-
finished.add(i.name);
|
|
231
|
-
for (const resolve of waiting.get(i.name) ?? []) resolve(); // nothing more will report
|
|
232
|
-
waiting.delete(i.name);
|
|
233
|
-
}),
|
|
234
|
-
),
|
|
235
|
-
);
|
|
291
|
+
for (const i of list) launch(i, signal); // a second start, after the first has stopped, runs them all again
|
|
292
|
+
// Until every run has ended, those added meanwhile too.
|
|
293
|
+
while (runs.size || changes)
|
|
294
|
+
await Promise.all([...[...runs.values()].map((r) => r.done), changing]);
|
|
295
|
+
// `started` stays: one added after every run has ended still runs, until the signal aborts.
|
|
236
296
|
},
|
|
237
297
|
};
|
|
238
298
|
}
|
package/src/index.ts
CHANGED
|
@@ -22,7 +22,13 @@ export {
|
|
|
22
22
|
PUSH_DOWN_MS,
|
|
23
23
|
STALE_POLLS,
|
|
24
24
|
} from "./health.ts";
|
|
25
|
-
export {
|
|
25
|
+
export {
|
|
26
|
+
byRoom,
|
|
27
|
+
type HomeDevice,
|
|
28
|
+
homeDevices,
|
|
29
|
+
type RoomDevices,
|
|
30
|
+
spotifyElsewhere,
|
|
31
|
+
} from "./devices.ts";
|
|
26
32
|
export { asOf, type Clock, realClock } from "./clock.ts";
|
|
27
33
|
export { type ConnectorCode, ConnectorError } from "./connector.ts";
|
|
28
34
|
export {
|