@khalido/home 0.1.1 → 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 +28 -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 +7 -1
- package/dist/connectors/cast/schemas.d.ts +2 -0
- package/dist/connectors/cast/schemas.js +1 -0
- package/dist/connectors/cast/state.d.ts +4 -0
- package/dist/connectors/cast/state.js +12 -0
- 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 +104 -44
- package/dist/connectors/up/index.d.ts +4 -2
- package/dist/connectors/up/index.js +2 -1
- package/dist/discover.d.ts +2 -0
- package/dist/discover.js +4 -1
- package/dist/lane.js +24 -20
- package/docs/cast.md +1 -1
- package/docs/cli.md +2 -2
- package/docs/decisions.md +1 -0
- package/package.json +1 -1
- package/src/cli/cast.ts +19 -11
- package/src/cli/spotify.ts +2 -1
- package/src/config.ts +3 -1
- package/src/connectors/cast/README.md +4 -3
- package/src/connectors/cast/index.ts +17 -4
- package/src/connectors/cast/schemas.ts +1 -0
- package/src/connectors/cast/state.ts +12 -0
- package/src/connectors/cast/writes.ts +58 -9
- package/src/connectors/spotify/README.md +20 -8
- package/src/connectors/spotify/changes.ts +4 -2
- package/src/connectors/spotify/client.ts +1 -1
- package/src/connectors/spotify/index.ts +111 -47
- package/src/connectors/up/README.md +1 -1
- package/src/connectors/up/index.ts +5 -2
- package/src/discover.ts +6 -2
- package/src/lane.ts +23 -20
|
@@ -33,10 +33,12 @@ const COMMAND_TIMEOUT_MS = 3000;
|
|
|
33
33
|
const QUERY_TIMEOUT_MS = 1000;
|
|
34
34
|
/** A socket that has said nothing this long after opening is closed: Soloist sends `auth_state` at once on connect. */
|
|
35
35
|
const HELLO_MS = 10_000;
|
|
36
|
-
/** After
|
|
36
|
+
/** After a start signal, a volume above the cap is lowered for this long: the volume can come before or after the status (issue #23). */
|
|
37
37
|
const CAP_WINDOW_MS = 10_000;
|
|
38
38
|
/** How long a write waits for Soloist to report the change it asked for. Loopback events arrive in milliseconds; a stated guess. */
|
|
39
39
|
const CONFIRM_MS = 2000;
|
|
40
|
+
/** Between the two halves of `setRepeat`: back to back left repeat on `track`, 600 ms apart worked (live, 2026-11-09). */
|
|
41
|
+
const REPEAT_GAP_MS = 600;
|
|
40
42
|
/** Between two commands. Soloist publishes no limit; it is on loopback; a stated guess. */
|
|
41
43
|
const WRITE_GAP_MS = 100;
|
|
42
44
|
/** While playing, a stall check this often: one `get_state`. Soloist issue #5 reports stalls 71–150 s into a track; a stated guess. */
|
|
@@ -45,6 +47,8 @@ const STALL_CHECK_MS = 20_000;
|
|
|
45
47
|
const OVERRUN_MS = 15_000;
|
|
46
48
|
/** After a repair's seek, how long before looking whether the position moves again. */
|
|
47
49
|
const REPAIR_WAIT_MS = 5000;
|
|
50
|
+
/** A reported volume is rounded (phone steps are sixteenths: 43.75 shows as 44), so "it took" allows this much. */
|
|
51
|
+
const VOLUME_SLACK = 1;
|
|
48
52
|
const MIN_BACKOFF_MS = 1000;
|
|
49
53
|
const MAX_BACKOFF_MS = 60_000;
|
|
50
54
|
/** Signed out is Soloist's normal state until someone picks the speaker (seen live 2026-11-08); a bad key looks the same (issue #18). */
|
|
@@ -78,15 +82,16 @@ export function spotify(config = {}) {
|
|
|
78
82
|
const checkMs = config.stallCheckMs ?? STALL_CHECK_MS;
|
|
79
83
|
let state = { asOf: asOf(clock) };
|
|
80
84
|
let socket;
|
|
81
|
-
|
|
82
|
-
let
|
|
83
|
-
|
|
85
|
+
// This socket's progress. "heard": signed in, the first playback_state still to come. "whole": a one-shot read sees the track (or HELLO_MS passed).
|
|
86
|
+
let link = "down";
|
|
87
|
+
const isOpen = () => link !== "down";
|
|
84
88
|
let down; // why the last socket ended, while there is none open
|
|
85
89
|
let statusKnown = false; // Soloist has told this socket its status: only then is idle → playing an edge
|
|
86
90
|
let queueAsked = false; // get_queue sent on this socket
|
|
87
91
|
let signedIn = false; // signed out → in on this socket: the first status after it counts as a start if it is playing
|
|
88
92
|
let capUntil = -Infinity;
|
|
89
|
-
let capped = false; // the cap
|
|
93
|
+
let capped = false; // the cap took in this window
|
|
94
|
+
let capping = false; // a lowering is on its way
|
|
90
95
|
let startBuffering = false; // a start from idle is still buffering
|
|
91
96
|
let capNote; // the last first-play lowering, in `anomaly` until the speaker goes idle
|
|
92
97
|
let frameNote; // a frame the schema rejected, or an error nothing was waiting for
|
|
@@ -99,10 +104,10 @@ export function spotify(config = {}) {
|
|
|
99
104
|
const publish = () => {
|
|
100
105
|
let status;
|
|
101
106
|
let detail;
|
|
102
|
-
if (
|
|
107
|
+
if (link === "down")
|
|
103
108
|
((status = reported || down ? "disconnected" : "connecting"),
|
|
104
109
|
(detail = down));
|
|
105
|
-
else if (
|
|
110
|
+
else if (link !== "whole")
|
|
106
111
|
status = "connecting";
|
|
107
112
|
else
|
|
108
113
|
((status = "connected"),
|
|
@@ -128,7 +133,7 @@ export function spotify(config = {}) {
|
|
|
128
133
|
*/
|
|
129
134
|
const command = (name, fields = {}, answers) => new Promise((resolve, reject) => {
|
|
130
135
|
const s = socket;
|
|
131
|
-
if (!s || !
|
|
136
|
+
if (!s || !isOpen() || s.readyState !== OPEN)
|
|
132
137
|
return reject(offline());
|
|
133
138
|
if (state.loggedIn === false && !answers)
|
|
134
139
|
return reject(new ConnectorError(WAITING, { code: "waiting" }));
|
|
@@ -157,11 +162,17 @@ export function spotify(config = {}) {
|
|
|
157
162
|
p.finish(new ConnectorError(`Soloist did not answer ${name} in ${ms / 1000} s`, { code: "timeout" })));
|
|
158
163
|
});
|
|
159
164
|
const send = async (body) => {
|
|
165
|
+
if (body.activeFirst && inactive()) {
|
|
166
|
+
await command("activate").catch(() => { }); // best effort: the write itself says how it went
|
|
167
|
+
await waitFor(() => state.active === true || !isOpen(), CONFIRM_MS);
|
|
168
|
+
}
|
|
160
169
|
const before = state;
|
|
161
170
|
const at = clock.now();
|
|
162
171
|
await command(body.command, body.fields, body.answers);
|
|
163
|
-
if (body.then)
|
|
172
|
+
if (body.then) {
|
|
173
|
+
await clock.sleep(REPEAT_GAP_MS);
|
|
164
174
|
await command(body.then.command, body.then.fields); // repeat's second half, still this write's turn
|
|
175
|
+
}
|
|
165
176
|
return { before, at };
|
|
166
177
|
};
|
|
167
178
|
let once = 0; // skips and queued tracks never merge: two nexts are two skips
|
|
@@ -174,7 +185,7 @@ export function spotify(config = {}) {
|
|
|
174
185
|
const change = async (slot, body) => {
|
|
175
186
|
const sent = await write(slot, body);
|
|
176
187
|
const took = () => body.took?.(state, sent.before, sent.at) ?? true;
|
|
177
|
-
await waitFor(() => took() || !
|
|
188
|
+
await waitFor(() => took() || !isOpen(), CONFIRM_MS);
|
|
178
189
|
return { state, confirmed: took() };
|
|
179
190
|
};
|
|
180
191
|
/** 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). */
|
|
@@ -183,11 +194,14 @@ export function spotify(config = {}) {
|
|
|
183
194
|
(positionMs(b, at) ?? 0) >= 5000;
|
|
184
195
|
const newTrack = (s, b) => !!s.track && s.track.uri !== b.track?.uri;
|
|
185
196
|
const invalid = (message) => new ConnectorError(message, { code: "invalid" });
|
|
197
|
+
const near = (volume, v) => volume !== undefined && Math.abs(volume - v) <= VOLUME_SLACK;
|
|
186
198
|
const volumeTo = (v) => change("volume", {
|
|
187
199
|
command: "set_volume",
|
|
188
200
|
fields: { volume: v },
|
|
189
|
-
took: (s) => s.volume
|
|
201
|
+
took: (s) => near(s.volume, v),
|
|
190
202
|
});
|
|
203
|
+
/** Soloist accepts a `set_volume` while it isn't the active device and drops it (three times live, 2026-11-09). */
|
|
204
|
+
const inactive = () => isOpen() && state.loggedIn !== false && state.active === false;
|
|
191
205
|
/** 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. */
|
|
192
206
|
const ANSWERS = {
|
|
193
207
|
get_state: "playback_state",
|
|
@@ -207,22 +221,48 @@ export function spotify(config = {}) {
|
|
|
207
221
|
frameNote = `${name} failed: ${errorText(error)}`;
|
|
208
222
|
publish();
|
|
209
223
|
});
|
|
210
|
-
/**
|
|
224
|
+
/**
|
|
225
|
+
* Within the window after a start signal, a volume above the cap is lowered to it (issue #23). Only while active, since
|
|
226
|
+
* Soloist drops it otherwise, and retried until Soloist reports it. A volume raised after that is someone's choice;
|
|
227
|
+
* one raised before it is reported can't be told from the start's own jump, so it is lowered too.
|
|
228
|
+
*/
|
|
211
229
|
const applyCap = () => {
|
|
212
230
|
if (cap === undefined ||
|
|
213
231
|
capped ||
|
|
232
|
+
capping ||
|
|
214
233
|
state.volume === undefined ||
|
|
215
|
-
|
|
216
|
-
state.
|
|
234
|
+
state.volume <= cap + VOLUME_SLACK ||
|
|
235
|
+
state.active === false ||
|
|
236
|
+
clock.now() > capUntil)
|
|
217
237
|
return;
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
238
|
+
capping = true;
|
|
239
|
+
const from = state.volume;
|
|
240
|
+
const window = capUntil;
|
|
241
|
+
volumeTo(cap).then(({ confirmed }) => {
|
|
242
|
+
capping = false;
|
|
243
|
+
if (capUntil !== window)
|
|
244
|
+
return applyCap(); // a new start while it went: this lowering doesn't speak for that one
|
|
245
|
+
if (confirmed) {
|
|
246
|
+
capped = true;
|
|
247
|
+
if (state.status === "idle")
|
|
248
|
+
return;
|
|
249
|
+
capNote = `started at volume ${from}, above maxVolume ${cap}; lowered it (Soloist issue #23)`;
|
|
250
|
+
note = undefined;
|
|
251
|
+
return publish();
|
|
252
|
+
}
|
|
253
|
+
if (clock.now() > capUntil) {
|
|
254
|
+
note = `could not lower the volume to ${cap}: Soloist has not reported it`;
|
|
255
|
+
return publish();
|
|
256
|
+
}
|
|
257
|
+
applyCap();
|
|
258
|
+
}, (error) => {
|
|
259
|
+
capping = false;
|
|
260
|
+
if (error instanceof ConnectorError && error.code === "offline")
|
|
261
|
+
return; // the status says it
|
|
262
|
+
capped = true; // refused or unanswered: not again this window
|
|
263
|
+
note = `lowering the volume to ${cap} failed: ${errorText(error)}`;
|
|
264
|
+
publish();
|
|
265
|
+
});
|
|
226
266
|
};
|
|
227
267
|
const handle = (data) => {
|
|
228
268
|
let raw;
|
|
@@ -254,12 +294,17 @@ export function spotify(config = {}) {
|
|
|
254
294
|
}
|
|
255
295
|
const before = state.status;
|
|
256
296
|
const wasSignedIn = state.loggedIn;
|
|
297
|
+
const wasActive = link === "open" ? undefined : state.active; // the first frames on a socket are not a change
|
|
257
298
|
state = fold(state, f, asOf(clock));
|
|
299
|
+
if (state.track?.uri !== repairedFor)
|
|
300
|
+
repairedFor = undefined; // one repair per play of a track
|
|
301
|
+
// Going active makes a new stream, at WirePlumber's saved level (live, 2026-11-09): the likeliest moment for a loud start.
|
|
302
|
+
if (wasActive === false && state.active === true)
|
|
303
|
+
((capUntil = clock.now() + CAP_WINDOW_MS), (capped = false));
|
|
258
304
|
if (f.type === "auth_state") {
|
|
259
305
|
// Signed out there is no playback_state to wait for, and a later sign-in must not send the part back to connecting.
|
|
260
|
-
if (
|
|
261
|
-
|
|
262
|
-
heard = true;
|
|
306
|
+
if (link === "open" || (link === "heard" && f.logged_in === false))
|
|
307
|
+
link = f.logged_in === true ? "heard" : "whole";
|
|
263
308
|
if (f.logged_in === false)
|
|
264
309
|
statusKnown = false;
|
|
265
310
|
// A first sign-in (someone picked the speaker) sends only auth_state; the docs don't promise a playback_state after it.
|
|
@@ -269,14 +314,14 @@ export function spotify(config = {}) {
|
|
|
269
314
|
if (f.type === "playback_state") {
|
|
270
315
|
if (!queueAsked)
|
|
271
316
|
((queueAsked = true), query("get_queue", { limit: 10 })); // queue_changed pushes changes only, so ask once per socket
|
|
272
|
-
|
|
317
|
+
link = "whole";
|
|
273
318
|
}
|
|
274
319
|
if (pending?.answers === f.type)
|
|
275
320
|
pending.finish();
|
|
276
321
|
if ((f.type === "playback_state" || f.type === "playback_changed") &&
|
|
277
322
|
f.status !== undefined) {
|
|
278
|
-
// A start is idle → playing or buffering, or a sign-in whose first status is either: a phone picked the speaker and played
|
|
279
|
-
//
|
|
323
|
+
// A start is idle → playing or buffering, or a sign-in whose first status is either: a phone picked the speaker and played.
|
|
324
|
+
// The volume can land just after `playing` (issue #23), so a start through buffering opens the window again then.
|
|
280
325
|
const started = ((f.status === "playing" || f.status === "buffering") &&
|
|
281
326
|
(statusKnown ? before === "idle" : signedIn)) ||
|
|
282
327
|
(f.status === "playing" && startBuffering);
|
|
@@ -319,10 +364,8 @@ export function spotify(config = {}) {
|
|
|
319
364
|
signal.removeEventListener("abort", stop);
|
|
320
365
|
if (socket === s)
|
|
321
366
|
socket = undefined;
|
|
322
|
-
const was =
|
|
323
|
-
|
|
324
|
-
heard = false;
|
|
325
|
-
whole = false;
|
|
367
|
+
const was = isOpen();
|
|
368
|
+
link = "down";
|
|
326
369
|
queueAsked = false;
|
|
327
370
|
statusKnown = false;
|
|
328
371
|
signedIn = false;
|
|
@@ -339,17 +382,17 @@ export function spotify(config = {}) {
|
|
|
339
382
|
s.onopen = () => {
|
|
340
383
|
if (ended)
|
|
341
384
|
return;
|
|
342
|
-
|
|
385
|
+
link = "open";
|
|
343
386
|
down = undefined;
|
|
344
387
|
note = undefined; // a new socket: what failed on the last one is history
|
|
345
388
|
void clock.sleep(HELLO_MS, quiet.signal).then(() => {
|
|
346
389
|
if (quiet.signal.aborted)
|
|
347
390
|
return;
|
|
348
|
-
if (
|
|
391
|
+
if (link === "open")
|
|
349
392
|
return (end(`Soloist said nothing in ${HELLO_MS / 1000} s`),
|
|
350
393
|
s.close());
|
|
351
|
-
if (
|
|
352
|
-
((
|
|
394
|
+
if (link === "heard")
|
|
395
|
+
((link = "whole"), publish()); // signed in, no playback_state: connected with what there is
|
|
353
396
|
});
|
|
354
397
|
};
|
|
355
398
|
s.onmessage = (ev) => void (ended || handle(ev.data));
|
|
@@ -367,7 +410,7 @@ export function spotify(config = {}) {
|
|
|
367
410
|
waiters.delete(check);
|
|
368
411
|
};
|
|
369
412
|
/** Not connected or not playing: the reason `duck` and `interrupt` just run `fn`. */
|
|
370
|
-
const notPlaying = () => !
|
|
413
|
+
const notPlaying = () => !isOpen()
|
|
371
414
|
? "Soloist is not connected"
|
|
372
415
|
: state.status === "playing" || state.status === "buffering"
|
|
373
416
|
? undefined
|
|
@@ -388,7 +431,7 @@ export function spotify(config = {}) {
|
|
|
388
431
|
*/
|
|
389
432
|
const aside = (kind, fn, enter, leave) => {
|
|
390
433
|
const run = async () => {
|
|
391
|
-
if (
|
|
434
|
+
if (isOpen())
|
|
392
435
|
marked(kind);
|
|
393
436
|
try {
|
|
394
437
|
return await within();
|
|
@@ -423,10 +466,20 @@ export function spotify(config = {}) {
|
|
|
423
466
|
why: failed(`sent the ${what}; Soloist has not reported it`),
|
|
424
467
|
};
|
|
425
468
|
const actions = {
|
|
469
|
+
async activate() {
|
|
470
|
+
if (isOpen() && state.loggedIn !== false && state.active === true)
|
|
471
|
+
return { state, confirmed: true }; // a second activate might make a new stream
|
|
472
|
+
return change("activate", {
|
|
473
|
+
command: "activate",
|
|
474
|
+
took: (s) => s.active === true,
|
|
475
|
+
});
|
|
476
|
+
},
|
|
477
|
+
// A `play` while inactive didn't take live; going active first is untested for this. `resume` worked inactive, so it doesn't.
|
|
426
478
|
async play(linkOrUri) {
|
|
427
479
|
if (linkOrUri === undefined)
|
|
428
480
|
return change("transport", {
|
|
429
481
|
command: "play",
|
|
482
|
+
activeFirst: true,
|
|
430
483
|
took: (s) => s.status === "playing",
|
|
431
484
|
});
|
|
432
485
|
const uri = toSpotifyUri(linkOrUri);
|
|
@@ -435,6 +488,7 @@ export function spotify(config = {}) {
|
|
|
435
488
|
return change("transport", {
|
|
436
489
|
command: "play",
|
|
437
490
|
fields: { uri },
|
|
491
|
+
activeFirst: true,
|
|
438
492
|
took: (s) => s.status === "playing" &&
|
|
439
493
|
(s.track?.uri === uri || s.context?.uri === uri),
|
|
440
494
|
});
|
|
@@ -471,6 +525,8 @@ export function spotify(config = {}) {
|
|
|
471
525
|
async setVolume(volume) {
|
|
472
526
|
if (!Number.isFinite(volume) || volume < 0 || volume > 100)
|
|
473
527
|
throw invalid(`volume is 0–100, not ${volume}`);
|
|
528
|
+
if (inactive())
|
|
529
|
+
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" });
|
|
474
530
|
return volumeTo(Math.round(cap === undefined ? volume : Math.min(volume, cap)));
|
|
475
531
|
},
|
|
476
532
|
async queue(track) {
|
|
@@ -544,6 +600,8 @@ export function spotify(config = {}) {
|
|
|
544
600
|
const no = notPlaying();
|
|
545
601
|
if (no)
|
|
546
602
|
return no;
|
|
603
|
+
if (inactive())
|
|
604
|
+
return "it is not the active device, so its volume can't be changed";
|
|
547
605
|
if (state.volume === undefined)
|
|
548
606
|
return "its volume is not known";
|
|
549
607
|
if (state.volume <= to)
|
|
@@ -556,7 +614,7 @@ export function spotify(config = {}) {
|
|
|
556
614
|
return failed(`lowering the volume for duck failed: ${errorText(error)}`);
|
|
557
615
|
}
|
|
558
616
|
}, async () => {
|
|
559
|
-
if (state.volume
|
|
617
|
+
if (!near(state.volume, Math.round(to)))
|
|
560
618
|
return {
|
|
561
619
|
music: "kept",
|
|
562
620
|
why: shown
|
|
@@ -593,7 +651,7 @@ export function spotify(config = {}) {
|
|
|
593
651
|
state = rest;
|
|
594
652
|
}
|
|
595
653
|
let seen; // the last check's reported position
|
|
596
|
-
let repairedFor; // at most one repair per track
|
|
654
|
+
let repairedFor; // at most one repair per play of a track
|
|
597
655
|
/** Ask Soloist where it is, waiting for the answer (or the query's timeout). */
|
|
598
656
|
const fresh = () => write("query-get_state", {
|
|
599
657
|
command: "get_state",
|
|
@@ -614,7 +672,7 @@ export function spotify(config = {}) {
|
|
|
614
672
|
a.track === b.track &&
|
|
615
673
|
b.atMs - a.atMs >= gap &&
|
|
616
674
|
Math.abs(b.ms - a.ms) < 1000;
|
|
617
|
-
const playing = () =>
|
|
675
|
+
const playing = () => isOpen() && state.status === "playing" && !state.aside;
|
|
618
676
|
/** One look while playing: a fresh position, then frozen, overrun, recovered or fine (Soloist issue #5). */
|
|
619
677
|
const checkStall = async () => {
|
|
620
678
|
if (!playing())
|
|
@@ -654,7 +712,7 @@ export function spotify(config = {}) {
|
|
|
654
712
|
repairedFor = track;
|
|
655
713
|
const tried = (what) => state.stalled && stallAs({ ...state.stalled, tried: what });
|
|
656
714
|
// Still ours to fix: playing this track, stalled, and no duck, interrupt or person in between.
|
|
657
|
-
const ours = () =>
|
|
715
|
+
const ours = () => isOpen() &&
|
|
658
716
|
state.status === "playing" &&
|
|
659
717
|
state.track?.uri === track &&
|
|
660
718
|
state.stalled?.track === track &&
|
|
@@ -698,8 +756,10 @@ export function spotify(config = {}) {
|
|
|
698
756
|
/** Ask Soloist for its state again (`get_state`, or `get_auth_state` without a session); while it is unreachable, reconnect now. */
|
|
699
757
|
refresh() {
|
|
700
758
|
const s = socket;
|
|
701
|
-
if (s &&
|
|
702
|
-
return query(state.loggedIn === false ||
|
|
759
|
+
if (s && isOpen() && s.readyState === OPEN)
|
|
760
|
+
return query(state.loggedIn === false || link === "open"
|
|
761
|
+
? "get_auth_state"
|
|
762
|
+
: "get_state");
|
|
703
763
|
wake?.abort();
|
|
704
764
|
},
|
|
705
765
|
async run(signal, update) {
|
|
@@ -82,8 +82,10 @@ export declare function toSnapshot(txs: Transaction[], categories: Category[], a
|
|
|
82
82
|
truncated: boolean;
|
|
83
83
|
household?: Household;
|
|
84
84
|
}): UpSnapshot;
|
|
85
|
-
/** Up as a home integration: two months of transactions now, then every 15 minutes.
|
|
86
|
-
export declare function up(config: UpConfig): Integration<UpSnapshot, "up"
|
|
85
|
+
/** Up as a home integration: two months of transactions now, then every 15 minutes. The integration only reads; `client` is there for a consumer's writes. */
|
|
86
|
+
export declare function up(config: UpConfig): Integration<UpSnapshot, "up"> & {
|
|
87
|
+
client: UpClient;
|
|
88
|
+
};
|
|
87
89
|
/** "Oct so far $2,140 · Sep $1,980 · 3 to categorise": this month's spend against last month's. */
|
|
88
90
|
export declare function upText(s: UpSnapshot): string;
|
|
89
91
|
/** One line per parent category this month, largest five: "home $1,030: groceries $620, utilities $410". */
|
|
@@ -97,13 +97,14 @@ export function toSnapshot(txs, categories, at) {
|
|
|
97
97
|
truncated: at.truncated,
|
|
98
98
|
};
|
|
99
99
|
}
|
|
100
|
-
/** Up as a home integration: two months of transactions now, then every 15 minutes.
|
|
100
|
+
/** Up as a home integration: two months of transactions now, then every 15 minutes. The integration only reads; `client` is there for a consumer's writes. */
|
|
101
101
|
export function up(config) {
|
|
102
102
|
const client = upClient(config);
|
|
103
103
|
const clock = config.clock ?? realClock;
|
|
104
104
|
const poll = poller(clock);
|
|
105
105
|
return {
|
|
106
106
|
name: "up",
|
|
107
|
+
client,
|
|
107
108
|
refresh: poll.nudge,
|
|
108
109
|
async run(signal, update) {
|
|
109
110
|
let last;
|
package/dist/discover.d.ts
CHANGED
|
@@ -85,6 +85,7 @@ export type CachedAddress = {
|
|
|
85
85
|
host: string;
|
|
86
86
|
port?: number;
|
|
87
87
|
name?: string;
|
|
88
|
+
model?: string;
|
|
88
89
|
seenAt: string;
|
|
89
90
|
};
|
|
90
91
|
/**
|
|
@@ -111,6 +112,7 @@ export declare function addressBook(cache: AddressCache | undefined, options?: {
|
|
|
111
112
|
host: string;
|
|
112
113
|
port?: number;
|
|
113
114
|
name?: string;
|
|
115
|
+
model?: string;
|
|
114
116
|
}): void;
|
|
115
117
|
entries: () => {
|
|
116
118
|
[x: string]: CachedAddress;
|
package/dist/discover.js
CHANGED
|
@@ -241,13 +241,16 @@ export function addressBook(cache, options = {}) {
|
|
|
241
241
|
c.host === address.host &&
|
|
242
242
|
c.port === address.port &&
|
|
243
243
|
isFresh(c) &&
|
|
244
|
-
(!address.name || address.name === c.name)
|
|
244
|
+
(!address.name || address.name === c.name) &&
|
|
245
|
+
(!address.model || address.model === c.model))
|
|
245
246
|
return;
|
|
246
247
|
const name = address.name || c?.name;
|
|
248
|
+
const model = address.model || c?.model;
|
|
247
249
|
entries[key] = {
|
|
248
250
|
host: address.host,
|
|
249
251
|
...(address.port === undefined ? {} : { port: address.port }),
|
|
250
252
|
...(name ? { name } : {}),
|
|
253
|
+
...(model ? { model } : {}),
|
|
251
254
|
seenAt: new Date(now()).toISOString(),
|
|
252
255
|
};
|
|
253
256
|
cache.write({ ...entries });
|
package/dist/lane.js
CHANGED
|
@@ -24,28 +24,32 @@ export function writeLane(options) {
|
|
|
24
24
|
}
|
|
25
25
|
async function run() {
|
|
26
26
|
running = true;
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
w.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
w.
|
|
27
|
+
try {
|
|
28
|
+
while (queue.length) {
|
|
29
|
+
const now = clock.now();
|
|
30
|
+
const next = queue.find((p) => readyAt(p.slot) <= now);
|
|
31
|
+
if (!next) {
|
|
32
|
+
wake = new AbortController();
|
|
33
|
+
await clock.sleep(Math.min(...queue.map((p) => readyAt(p.slot))) - now, wake.signal);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
queue.splice(queue.indexOf(next), 1);
|
|
37
|
+
lastAt = now;
|
|
38
|
+
sent.set(next.slot, [...recent(next.slot), now]);
|
|
39
|
+
try {
|
|
40
|
+
const result = await next.write(next.body);
|
|
41
|
+
for (const w of next.waiters)
|
|
42
|
+
w.resolve(result);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
for (const w of next.waiters)
|
|
46
|
+
w.reject(error);
|
|
47
|
+
}
|
|
46
48
|
}
|
|
47
49
|
}
|
|
48
|
-
|
|
50
|
+
finally {
|
|
51
|
+
running = false; // a throw here must not leave the lane stuck
|
|
52
|
+
}
|
|
49
53
|
}
|
|
50
54
|
return {
|
|
51
55
|
send(slot, body, write, merge = (_, newer) => newer) {
|
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
|
|
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.
|
|
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
|
@@ -15,11 +15,11 @@ home config check [path] # validate a home config: what it would set up, se
|
|
|
15
15
|
home discover --write [path] # with a config (the path, else --config or HOME_CONFIG): match what answers by identity, add new devices, update moved hosts and renamed Cast names in place
|
|
16
16
|
home probe # read-only, no keys: Hue's public config, Daikin by UDP broadcast, the IQ Gateway's /info, each weather source, each Cast device's and printer's status
|
|
17
17
|
home locate "<street address>" [--write [path]] # latitude and longitude from OpenStreetMap's Nominatim; --write sets them in the config's location (and timezone, when absent, to this machine's)
|
|
18
|
-
home cast announce --to <name> (--say "<text>" | --file <path>) [--volume 0-100] # play a clip, then put back what was playing and the volume
|
|
18
|
+
home cast announce --to <name> [--to <name>…] (--say "<text>" | --file <path>) [--volume 0-100] # exit 1 if any didn't play; play a clip, then put back what was playing and the volume
|
|
19
19
|
home cast volume <name> <0-100>
|
|
20
20
|
home cast mute <name> on|off
|
|
21
21
|
home cast stop <name> # end the running app; the device returns to its idle screen
|
|
22
|
-
home spotify pause | play [link] | next | volume <0-100> # the Soloist speaker at SOLOIST_WS; volume held to SOLOIST_MAX_VOLUME; prints what Soloist then reported
|
|
22
|
+
home spotify activate | pause | play [link] | next | volume <0-100> # the Soloist speaker at SOLOIST_WS; volume held to SOLOIST_MAX_VOLUME; prints what Soloist then reported
|
|
23
23
|
```
|
|
24
24
|
|
|
25
25
|
## Output
|
package/docs/decisions.md
CHANGED
|
@@ -148,3 +148,4 @@ KO's decisions on this package, newest block last. They override every other doc
|
|
|
148
148
|
- **Signed out reads as waiting**, everywhere: `speakerState` says `waiting`, the detail says "waiting for someone to pick it", the write error's code is `waiting`.
|
|
149
149
|
- **Stalls (Soloist issue #5) are noticed by default and repaired only when asked** (`repairStalls: true`): the repair is the library's first write nobody asked for, so a consumer opts in. Two signals, since the issue doesn't say whether the reported position freezes: a frozen position across fresh reports, and still playing well past the track's end, the symptom actually reported. One repair per track, never a loop. The nudges (seek, then pause and play; a skip past the end) are guesses until a stall is seen live.
|
|
150
150
|
- **The project is home.ts; the npm package stays `@khalido/home`** (KO, 2026-11-08), so imports stay short (`@khalido/home/spotify`). It is home automation for TypeScript, starting with the devices in KO's house, and is meant to grow into more of the basics.
|
|
151
|
+
- **Spotify: inactive is refused, and activating is the caller's choice** (KO, 2026-11-09). Soloist accepts and drops `set_volume` while it isn't the active device. `setVolume` refuses with code `inactive`, and `activate()` is separate, because activating makes a new stream and can jump the volume to the level last saved. `duck` runs `fn` anyway, with `music: "untouched"`, because the announcement matters more. `play` goes active itself, since someone asking for music wants it to start; `resume` doesn't, since it worked inactive. The `maxVolume` cap says "lowered" only once Soloist reports it.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@khalido/home",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.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/src/cli/cast.ts
CHANGED
|
@@ -34,6 +34,7 @@ export function castNow() {
|
|
|
34
34
|
{
|
|
35
35
|
castId: key.slice(5),
|
|
36
36
|
name: c.name ?? key.slice(5),
|
|
37
|
+
model: c.model,
|
|
37
38
|
host: c.host,
|
|
38
39
|
port: c.port,
|
|
39
40
|
},
|
|
@@ -71,7 +72,7 @@ async function withCast<T>(
|
|
|
71
72
|
}
|
|
72
73
|
|
|
73
74
|
export const CAST_USAGE =
|
|
74
|
-
'usage: home cast announce --to <name> (--say "<text>" | --file <path>) [--volume 0-100] | volume <name> <0-100> | mute <name> on|off | stop <name>';
|
|
75
|
+
'usage: home cast announce --to <name> [--to <name>…] (--say "<text>" | --file <path>) [--volume 0-100] | volume <name> <0-100> | mute <name> on|off | stop <name>';
|
|
75
76
|
|
|
76
77
|
/** A 0–100 argument as the 0–1 a Cast device takes. */
|
|
77
78
|
function percentLevel(value: string | undefined): number {
|
|
@@ -96,7 +97,7 @@ const AUDIO_TYPES: Record<string, string> = {
|
|
|
96
97
|
export const CAST_OPTIONS: [string, string, { type: unknown[] }?][] = [
|
|
97
98
|
[
|
|
98
99
|
"--to <name>",
|
|
99
|
-
"announce: the device or group, by name or castId",
|
|
100
|
+
"announce: the device or group, by name or castId; repeat for several",
|
|
100
101
|
{ type: [String] },
|
|
101
102
|
],
|
|
102
103
|
["--say <text>", "announce: words spoken by macOS `say`", { type: [String] }],
|
|
@@ -119,7 +120,8 @@ export const castVerbs: Verbs = {
|
|
|
119
120
|
await withCast((c) => c.stop(name!));
|
|
120
121
|
},
|
|
121
122
|
async announce(_, o) {
|
|
122
|
-
const [
|
|
123
|
+
const [say, file] = [text(o.say), text(o.file)];
|
|
124
|
+
const to = [o.to].flat().filter(Boolean).map(String); // --to can repeat
|
|
123
125
|
// cac gives "true" for a flag with no value: `--say` alone must not speak the word "true", so each flag needs a value after it.
|
|
124
126
|
const bare = ["--to", "--say", "--file"].some((f) =>
|
|
125
127
|
process.argv.some(
|
|
@@ -130,7 +132,7 @@ export const castVerbs: Verbs = {
|
|
|
130
132
|
),
|
|
131
133
|
);
|
|
132
134
|
if (bare) usage(CAST_USAGE);
|
|
133
|
-
if (!to || !say === !file) usage(CAST_USAGE);
|
|
135
|
+
if (!to.length || !say === !file) usage(CAST_USAGE);
|
|
134
136
|
const volume =
|
|
135
137
|
o.volume === undefined ? undefined : percentLevel(text(o.volume));
|
|
136
138
|
let type: string;
|
|
@@ -160,13 +162,19 @@ export const castVerbs: Verbs = {
|
|
|
160
162
|
}
|
|
161
163
|
type = "audio/mp4";
|
|
162
164
|
}
|
|
163
|
-
const
|
|
164
|
-
c.
|
|
165
|
+
const results = await withCast((c) =>
|
|
166
|
+
c.announceAll(to, { bytes, type }, { volume }),
|
|
165
167
|
);
|
|
166
|
-
if (
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
for (const
|
|
170
|
-
|
|
168
|
+
if (results.some((r) => !r.played)) process.exitCode = 1; // docs/cli.md: 1 is a failure, so a script can tell
|
|
169
|
+
if (o.json) return printJson(to.length === 1 ? results[0] : results);
|
|
170
|
+
const named = results.length > 1;
|
|
171
|
+
for (const r of results) {
|
|
172
|
+
const who = named ? `${r.target}: ` : "";
|
|
173
|
+
console.log(`${who}${r.played ? "played" : `not played: ${r.why}`}`);
|
|
174
|
+
if (r.restored.length)
|
|
175
|
+
console.log(`${who}restored: ${r.restored.join(", ")}`);
|
|
176
|
+
for (const n of r.notRestored)
|
|
177
|
+
console.log(`${who}not restored: ${n.name}: ${n.why}`);
|
|
178
|
+
}
|
|
171
179
|
},
|
|
172
180
|
};
|
package/src/cli/spotify.ts
CHANGED
|
@@ -31,7 +31,7 @@ export const spotifyFromEnv = () =>
|
|
|
31
31
|
});
|
|
32
32
|
|
|
33
33
|
export const SPOTIFY_USAGE =
|
|
34
|
-
"usage: home spotify pause | play [link] | next | volume <0-100>";
|
|
34
|
+
"usage: home spotify activate | pause | play [link] | next | volume <0-100>";
|
|
35
35
|
|
|
36
36
|
async function withSpotify<T>(
|
|
37
37
|
act: (s: ReturnType<typeof spotify>) => Promise<T>,
|
|
@@ -64,6 +64,7 @@ const said = ({ state, confirmed }: Applied) =>
|
|
|
64
64
|
);
|
|
65
65
|
|
|
66
66
|
export const spotifyVerbs: Verbs = {
|
|
67
|
+
activate: () => withSpotify((s) => s.activate()).then(said),
|
|
67
68
|
pause: () => withSpotify((s) => s.pause()).then(said),
|
|
68
69
|
next: () => withSpotify((s) => s.next()).then(said),
|
|
69
70
|
play: ([link]) => withSpotify((s) => s.play(link)).then(said),
|
package/src/config.ts
CHANGED
|
@@ -509,7 +509,9 @@ export function loadHome(
|
|
|
509
509
|
{
|
|
510
510
|
castId: x.castId,
|
|
511
511
|
name: x.name,
|
|
512
|
-
...(c?.port === undefined
|
|
512
|
+
...(c?.port === undefined
|
|
513
|
+
? {}
|
|
514
|
+
: { host: c.host, port: c.port, model: c.model }),
|
|
513
515
|
},
|
|
514
516
|
];
|
|
515
517
|
});
|