@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
|
@@ -99,9 +99,11 @@ export type Aside<T> = { value: T; music: Music; why?: string };
|
|
|
99
99
|
/**
|
|
100
100
|
* The writes, on the object `spotify()` returns. Each resolves once Soloist reports the change it asked for, with the state
|
|
101
101
|
* then (`confirmed: true`), or after `CONFIRM_MS` with the state as it stands (`confirmed: false`). Each rejects with a
|
|
102
|
-
* `ConnectorError` whose `code` is `offline`, `waiting` (nobody has picked the speaker), `refused`, `timeout` or `invalid`.
|
|
102
|
+
* `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`.
|
|
103
103
|
*/
|
|
104
104
|
export type SpotifyActions = {
|
|
105
|
+
/** 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. */
|
|
106
|
+
activate(): Promise<Applied>;
|
|
105
107
|
/** Play a Spotify link or URI (track, album, playlist, artist, episode, show), or resume what is loaded when none is given. */
|
|
106
108
|
play(linkOrUri?: string): Promise<Applied>;
|
|
107
109
|
pause(): Promise<Applied>;
|
|
@@ -137,10 +139,12 @@ const COMMAND_TIMEOUT_MS = 3000;
|
|
|
137
139
|
const QUERY_TIMEOUT_MS = 1000;
|
|
138
140
|
/** A socket that has said nothing this long after opening is closed: Soloist sends `auth_state` at once on connect. */
|
|
139
141
|
const HELLO_MS = 10_000;
|
|
140
|
-
/** After
|
|
142
|
+
/** 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). */
|
|
141
143
|
const CAP_WINDOW_MS = 10_000;
|
|
142
144
|
/** How long a write waits for Soloist to report the change it asked for. Loopback events arrive in milliseconds; a stated guess. */
|
|
143
145
|
const CONFIRM_MS = 2000;
|
|
146
|
+
/** Between the two halves of `setRepeat`: back to back left repeat on `track`, 600 ms apart worked (live, 2026-11-09). */
|
|
147
|
+
const REPEAT_GAP_MS = 600;
|
|
144
148
|
/** Between two commands. Soloist publishes no limit; it is on loopback; a stated guess. */
|
|
145
149
|
const WRITE_GAP_MS = 100;
|
|
146
150
|
/** While playing, a stall check this often: one `get_state`. Soloist issue #5 reports stalls 71–150 s into a track; a stated guess. */
|
|
@@ -149,6 +153,8 @@ const STALL_CHECK_MS = 20_000;
|
|
|
149
153
|
const OVERRUN_MS = 15_000;
|
|
150
154
|
/** After a repair's seek, how long before looking whether the position moves again. */
|
|
151
155
|
const REPAIR_WAIT_MS = 5000;
|
|
156
|
+
/** A reported volume is rounded (phone steps are sixteenths: 43.75 shows as 44), so "it took" allows this much. */
|
|
157
|
+
const VOLUME_SLACK = 1;
|
|
152
158
|
const MIN_BACKOFF_MS = 1000;
|
|
153
159
|
const MAX_BACKOFF_MS = 60_000;
|
|
154
160
|
|
|
@@ -190,15 +196,17 @@ export function spotify(
|
|
|
190
196
|
|
|
191
197
|
let state: SpotifyState = { asOf: asOf(clock) };
|
|
192
198
|
let socket: SocketLike | undefined;
|
|
193
|
-
|
|
194
|
-
let
|
|
195
|
-
|
|
199
|
+
// 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).
|
|
200
|
+
let link: "down" | "open" | "heard" | "whole" = "down";
|
|
201
|
+
const isOpen = () => link !== "down";
|
|
196
202
|
let down: string | undefined; // why the last socket ended, while there is none open
|
|
197
203
|
let statusKnown = false; // Soloist has told this socket its status: only then is idle → playing an edge
|
|
198
204
|
let queueAsked = false; // get_queue sent on this socket
|
|
199
205
|
let signedIn = false; // signed out → in on this socket: the first status after it counts as a start if it is playing
|
|
200
206
|
let capUntil = -Infinity;
|
|
201
|
-
let capped = false; // the cap
|
|
207
|
+
let capped = false; // the cap took in this window
|
|
208
|
+
let capping = false; // a lowering is on its way
|
|
209
|
+
let startBuffering = false; // a start from idle is still buffering
|
|
202
210
|
let capNote: string | undefined; // the last first-play lowering, in `anomaly` until the speaker goes idle
|
|
203
211
|
let frameNote: string | undefined; // a frame the schema rejected, or an error nothing was waiting for
|
|
204
212
|
let note: string | undefined; // a write the integration made itself that failed (the cap, interrupt's resume), in `detail`
|
|
@@ -213,11 +221,10 @@ export function spotify(
|
|
|
213
221
|
const publish = () => {
|
|
214
222
|
let status: Status;
|
|
215
223
|
let detail: string | undefined;
|
|
216
|
-
if (
|
|
224
|
+
if (link === "down")
|
|
217
225
|
((status = reported || down ? "disconnected" : "connecting"),
|
|
218
226
|
(detail = down));
|
|
219
|
-
else if (
|
|
220
|
-
status = "connecting";
|
|
227
|
+
else if (link !== "whole") status = "connecting";
|
|
221
228
|
else
|
|
222
229
|
((status = "connected"),
|
|
223
230
|
(detail = state.loggedIn === false ? WAITING : note));
|
|
@@ -250,7 +257,7 @@ export function spotify(
|
|
|
250
257
|
) =>
|
|
251
258
|
new Promise<void>((resolve, reject) => {
|
|
252
259
|
const s = socket;
|
|
253
|
-
if (!s || !
|
|
260
|
+
if (!s || !isOpen() || s.readyState !== OPEN) return reject(offline());
|
|
254
261
|
if (state.loggedIn === false && !answers)
|
|
255
262
|
return reject(new ConnectorError(WAITING, { code: "waiting" }));
|
|
256
263
|
const stop = new AbortController();
|
|
@@ -290,12 +297,20 @@ export function spotify(
|
|
|
290
297
|
answers?: string;
|
|
291
298
|
then?: { command: string; fields: Record<string, unknown> };
|
|
292
299
|
took?: (now: SpotifyState, before: SpotifyState, at: number) => boolean;
|
|
300
|
+
activeFirst?: boolean; // an inactive speaker goes active before this, in this write's own turn so the last transport write still wins
|
|
293
301
|
};
|
|
294
302
|
const send = async (body: Body) => {
|
|
303
|
+
if (body.activeFirst && inactive()) {
|
|
304
|
+
await command("activate").catch(() => {}); // best effort: the write itself says how it went
|
|
305
|
+
await waitFor(() => state.active === true || !isOpen(), CONFIRM_MS);
|
|
306
|
+
}
|
|
295
307
|
const before = state;
|
|
296
308
|
const at = clock.now();
|
|
297
309
|
await command(body.command, body.fields, body.answers);
|
|
298
|
-
if (body.then)
|
|
310
|
+
if (body.then) {
|
|
311
|
+
await clock.sleep(REPEAT_GAP_MS);
|
|
312
|
+
await command(body.then.command, body.then.fields); // repeat's second half, still this write's turn
|
|
313
|
+
}
|
|
299
314
|
return { before, at };
|
|
300
315
|
};
|
|
301
316
|
let once = 0; // skips and queued tracks never merge: two nexts are two skips
|
|
@@ -308,7 +323,7 @@ export function spotify(
|
|
|
308
323
|
const change = async (slot: string, body: Body): Promise<Applied> => {
|
|
309
324
|
const sent = await write(slot, body);
|
|
310
325
|
const took = () => body.took?.(state, sent.before, sent.at) ?? true;
|
|
311
|
-
await waitFor(() => took() || !
|
|
326
|
+
await waitFor(() => took() || !isOpen(), CONFIRM_MS);
|
|
312
327
|
return { state, confirmed: took() };
|
|
313
328
|
};
|
|
314
329
|
/** 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). */
|
|
@@ -320,12 +335,17 @@ export function spotify(
|
|
|
320
335
|
!!s.track && s.track.uri !== b.track?.uri;
|
|
321
336
|
const invalid = (message: string) =>
|
|
322
337
|
new ConnectorError(message, { code: "invalid" });
|
|
338
|
+
const near = (volume: number | undefined, v: number) =>
|
|
339
|
+
volume !== undefined && Math.abs(volume - v) <= VOLUME_SLACK;
|
|
323
340
|
const volumeTo = (v: number) =>
|
|
324
341
|
change("volume", {
|
|
325
342
|
command: "set_volume",
|
|
326
343
|
fields: { volume: v },
|
|
327
|
-
took: (s) => s.volume
|
|
344
|
+
took: (s) => near(s.volume, v),
|
|
328
345
|
});
|
|
346
|
+
/** Soloist accepts a `set_volume` while it isn't the active device and drops it (three times live, 2026-11-09). */
|
|
347
|
+
const inactive = () =>
|
|
348
|
+
isOpen() && state.loggedIn !== false && state.active === false;
|
|
329
349
|
/** 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. */
|
|
330
350
|
const ANSWERS = {
|
|
331
351
|
get_state: "playback_state",
|
|
@@ -352,27 +372,49 @@ export function spotify(
|
|
|
352
372
|
publish();
|
|
353
373
|
});
|
|
354
374
|
|
|
355
|
-
/**
|
|
375
|
+
/**
|
|
376
|
+
* Within the window after a start signal, a volume above the cap is lowered to it (issue #23). Only while active, since
|
|
377
|
+
* Soloist drops it otherwise, and retried until Soloist reports it. A volume raised after that is someone's choice;
|
|
378
|
+
* one raised before it is reported can't be told from the start's own jump, so it is lowered too.
|
|
379
|
+
*/
|
|
356
380
|
const applyCap = () => {
|
|
357
381
|
if (
|
|
358
382
|
cap === undefined ||
|
|
359
383
|
capped ||
|
|
384
|
+
capping ||
|
|
360
385
|
state.volume === undefined ||
|
|
361
|
-
|
|
362
|
-
state.
|
|
386
|
+
state.volume <= cap + VOLUME_SLACK ||
|
|
387
|
+
state.active === false ||
|
|
388
|
+
clock.now() > capUntil
|
|
363
389
|
)
|
|
364
390
|
return;
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
391
|
+
capping = true;
|
|
392
|
+
const from = state.volume;
|
|
393
|
+
const window = capUntil;
|
|
394
|
+
volumeTo(cap).then(
|
|
395
|
+
({ confirmed }) => {
|
|
396
|
+
capping = false;
|
|
397
|
+
if (capUntil !== window) return applyCap(); // a new start while it went: this lowering doesn't speak for that one
|
|
398
|
+
if (confirmed) {
|
|
399
|
+
capped = true;
|
|
400
|
+
if (state.status === "idle") return;
|
|
401
|
+
capNote = `started at volume ${from}, above maxVolume ${cap}; lowered it (Soloist issue #23)`;
|
|
402
|
+
note = undefined;
|
|
403
|
+
return publish();
|
|
404
|
+
}
|
|
405
|
+
if (clock.now() > capUntil) {
|
|
406
|
+
note = `could not lower the volume to ${cap}: Soloist has not reported it`;
|
|
407
|
+
return publish();
|
|
408
|
+
}
|
|
409
|
+
applyCap();
|
|
410
|
+
},
|
|
411
|
+
(error) => {
|
|
412
|
+
capping = false;
|
|
413
|
+
if (error instanceof ConnectorError && error.code === "offline") return; // the status says it
|
|
414
|
+
capped = true; // refused or unanswered: not again this window
|
|
415
|
+
note = `lowering the volume to ${cap} failed: ${errorText(error)}`;
|
|
416
|
+
publish();
|
|
417
|
+
},
|
|
376
418
|
);
|
|
377
419
|
};
|
|
378
420
|
|
|
@@ -410,11 +452,16 @@ export function spotify(
|
|
|
410
452
|
}
|
|
411
453
|
const before = state.status;
|
|
412
454
|
const wasSignedIn = state.loggedIn;
|
|
455
|
+
const wasActive = link === "open" ? undefined : state.active; // the first frames on a socket are not a change
|
|
413
456
|
state = fold(state, f, asOf(clock));
|
|
457
|
+
if (state.track?.uri !== repairedFor) repairedFor = undefined; // one repair per play of a track
|
|
458
|
+
// Going active makes a new stream, at WirePlumber's saved level (live, 2026-11-09): the likeliest moment for a loud start.
|
|
459
|
+
if (wasActive === false && state.active === true)
|
|
460
|
+
((capUntil = clock.now() + CAP_WINDOW_MS), (capped = false));
|
|
414
461
|
if (f.type === "auth_state") {
|
|
415
462
|
// Signed out there is no playback_state to wait for, and a later sign-in must not send the part back to connecting.
|
|
416
|
-
if (
|
|
417
|
-
|
|
463
|
+
if (link === "open" || (link === "heard" && f.logged_in === false))
|
|
464
|
+
link = f.logged_in === true ? "heard" : "whole";
|
|
418
465
|
if (f.logged_in === false) statusKnown = false;
|
|
419
466
|
// A first sign-in (someone picked the speaker) sends only auth_state; the docs don't promise a playback_state after it.
|
|
420
467
|
else if (f.logged_in === true && wasSignedIn === false)
|
|
@@ -422,17 +469,21 @@ export function spotify(
|
|
|
422
469
|
}
|
|
423
470
|
if (f.type === "playback_state") {
|
|
424
471
|
if (!queueAsked) ((queueAsked = true), query("get_queue", { limit: 10 })); // queue_changed pushes changes only, so ask once per socket
|
|
425
|
-
|
|
472
|
+
link = "whole";
|
|
426
473
|
}
|
|
427
474
|
if (pending?.answers === f.type) pending.finish();
|
|
428
475
|
if (
|
|
429
476
|
(f.type === "playback_state" || f.type === "playback_changed") &&
|
|
430
477
|
f.status !== undefined
|
|
431
478
|
) {
|
|
432
|
-
// A start is idle → playing, or a sign-in whose first status is
|
|
479
|
+
// A start is idle → playing or buffering, or a sign-in whose first status is either: a phone picked the speaker and played.
|
|
480
|
+
// The volume can land just after `playing` (issue #23), so a start through buffering opens the window again then.
|
|
433
481
|
const started =
|
|
434
|
-
f.status === "playing"
|
|
482
|
+
((f.status === "playing" || f.status === "buffering") &&
|
|
483
|
+
(statusKnown ? before === "idle" : signedIn)) ||
|
|
484
|
+
(f.status === "playing" && startBuffering);
|
|
435
485
|
if (started) ((capUntil = clock.now() + CAP_WINDOW_MS), (capped = false));
|
|
486
|
+
startBuffering = f.status === "buffering" && (started || startBuffering);
|
|
436
487
|
signedIn = false;
|
|
437
488
|
if (f.status === "idle") capNote = undefined;
|
|
438
489
|
statusKnown = true;
|
|
@@ -465,13 +516,12 @@ export function spotify(
|
|
|
465
516
|
quiet.abort();
|
|
466
517
|
signal.removeEventListener("abort", stop);
|
|
467
518
|
if (socket === s) socket = undefined;
|
|
468
|
-
const was =
|
|
469
|
-
|
|
470
|
-
heard = false;
|
|
471
|
-
whole = false;
|
|
519
|
+
const was = isOpen();
|
|
520
|
+
link = "down";
|
|
472
521
|
queueAsked = false;
|
|
473
522
|
statusKnown = false;
|
|
474
523
|
signedIn = false;
|
|
524
|
+
startBuffering = false;
|
|
475
525
|
pending?.finish(offline());
|
|
476
526
|
for (const w of [...waiters]) w(); // a write waiting to confirm stops waiting
|
|
477
527
|
seen = undefined;
|
|
@@ -479,21 +529,23 @@ export function spotify(
|
|
|
479
529
|
};
|
|
480
530
|
const stop = () => (end("stopped"), s.close());
|
|
481
531
|
signal.addEventListener("abort", stop, { once: true });
|
|
532
|
+
// An ended socket's late events belong to no session: they must not touch the next socket's state.
|
|
482
533
|
s.onopen = () => {
|
|
483
|
-
|
|
534
|
+
if (ended) return;
|
|
535
|
+
link = "open";
|
|
484
536
|
down = undefined;
|
|
485
537
|
note = undefined; // a new socket: what failed on the last one is history
|
|
486
538
|
void clock.sleep(HELLO_MS, quiet.signal).then(() => {
|
|
487
539
|
if (quiet.signal.aborted) return;
|
|
488
|
-
if (
|
|
540
|
+
if (link === "open")
|
|
489
541
|
return (
|
|
490
542
|
end(`Soloist said nothing in ${HELLO_MS / 1000} s`),
|
|
491
543
|
s.close()
|
|
492
544
|
);
|
|
493
|
-
if (
|
|
545
|
+
if (link === "heard") ((link = "whole"), publish()); // signed in, no playback_state: connected with what there is
|
|
494
546
|
});
|
|
495
547
|
};
|
|
496
|
-
s.onmessage = (ev) => handle(ev.data);
|
|
548
|
+
s.onmessage = (ev) => void (ended || handle(ev.data));
|
|
497
549
|
s.onerror = () => {}; // onclose follows and says it
|
|
498
550
|
s.onclose = () => end("Soloist closed the socket");
|
|
499
551
|
});
|
|
@@ -510,7 +562,7 @@ export function spotify(
|
|
|
510
562
|
|
|
511
563
|
/** Not connected or not playing: the reason `duck` and `interrupt` just run `fn`. */
|
|
512
564
|
const notPlaying = () =>
|
|
513
|
-
!
|
|
565
|
+
!isOpen()
|
|
514
566
|
? "Soloist is not connected"
|
|
515
567
|
: state.status === "playing" || state.status === "buffering"
|
|
516
568
|
? undefined
|
|
@@ -536,7 +588,7 @@ export function spotify(
|
|
|
536
588
|
leave: () => Promise<Left>,
|
|
537
589
|
) => {
|
|
538
590
|
const run = async (): Promise<Aside<T>> => {
|
|
539
|
-
if (
|
|
591
|
+
if (isOpen()) marked(kind);
|
|
540
592
|
try {
|
|
541
593
|
return await within();
|
|
542
594
|
} finally {
|
|
@@ -570,10 +622,20 @@ export function spotify(
|
|
|
570
622
|
};
|
|
571
623
|
|
|
572
624
|
const actions: SpotifyActions = {
|
|
625
|
+
async activate() {
|
|
626
|
+
if (isOpen() && state.loggedIn !== false && state.active === true)
|
|
627
|
+
return { state, confirmed: true }; // a second activate might make a new stream
|
|
628
|
+
return change("activate", {
|
|
629
|
+
command: "activate",
|
|
630
|
+
took: (s) => s.active === true,
|
|
631
|
+
});
|
|
632
|
+
},
|
|
633
|
+
// A `play` while inactive didn't take live; going active first is untested for this. `resume` worked inactive, so it doesn't.
|
|
573
634
|
async play(linkOrUri) {
|
|
574
635
|
if (linkOrUri === undefined)
|
|
575
636
|
return change("transport", {
|
|
576
637
|
command: "play",
|
|
638
|
+
activeFirst: true,
|
|
577
639
|
took: (s) => s.status === "playing",
|
|
578
640
|
});
|
|
579
641
|
const uri = toSpotifyUri(linkOrUri);
|
|
@@ -581,6 +643,7 @@ export function spotify(
|
|
|
581
643
|
return change("transport", {
|
|
582
644
|
command: "play",
|
|
583
645
|
fields: { uri },
|
|
646
|
+
activeFirst: true,
|
|
584
647
|
took: (s) =>
|
|
585
648
|
s.status === "playing" &&
|
|
586
649
|
(s.track?.uri === uri || s.context?.uri === uri),
|
|
@@ -624,6 +687,11 @@ export function spotify(
|
|
|
624
687
|
async setVolume(volume) {
|
|
625
688
|
if (!Number.isFinite(volume) || volume < 0 || volume > 100)
|
|
626
689
|
throw invalid(`volume is 0–100, not ${volume}`);
|
|
690
|
+
if (inactive())
|
|
691
|
+
throw new ConnectorError(
|
|
692
|
+
"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)",
|
|
693
|
+
{ code: "inactive" },
|
|
694
|
+
);
|
|
627
695
|
return volumeTo(
|
|
628
696
|
Math.round(cap === undefined ? volume : Math.min(volume, cap)),
|
|
629
697
|
);
|
|
@@ -704,6 +772,8 @@ export function spotify(
|
|
|
704
772
|
async () => {
|
|
705
773
|
const no = notPlaying();
|
|
706
774
|
if (no) return no;
|
|
775
|
+
if (inactive())
|
|
776
|
+
return "it is not the active device, so its volume can't be changed";
|
|
707
777
|
if (state.volume === undefined) return "its volume is not known";
|
|
708
778
|
if (state.volume <= to) return `it was already at ${state.volume}`;
|
|
709
779
|
earlier = state.volume;
|
|
@@ -716,7 +786,7 @@ export function spotify(
|
|
|
716
786
|
}
|
|
717
787
|
},
|
|
718
788
|
async () => {
|
|
719
|
-
if (state.volume
|
|
789
|
+
if (!near(state.volume, Math.round(to)))
|
|
720
790
|
return {
|
|
721
791
|
music: "kept",
|
|
722
792
|
why: shown
|
|
@@ -758,7 +828,7 @@ export function spotify(
|
|
|
758
828
|
state = rest;
|
|
759
829
|
}
|
|
760
830
|
let seen: { track: string; ms: number; atMs: number } | undefined; // the last check's reported position
|
|
761
|
-
let repairedFor: string | undefined; // at most one repair per track
|
|
831
|
+
let repairedFor: string | undefined; // at most one repair per play of a track
|
|
762
832
|
/** Ask Soloist where it is, waiting for the answer (or the query's timeout). */
|
|
763
833
|
const fresh = () =>
|
|
764
834
|
write("query-get_state", {
|
|
@@ -785,7 +855,7 @@ export function spotify(
|
|
|
785
855
|
a.track === b.track &&
|
|
786
856
|
b.atMs - a.atMs >= gap &&
|
|
787
857
|
Math.abs(b.ms - a.ms) < 1000;
|
|
788
|
-
const playing = () =>
|
|
858
|
+
const playing = () => isOpen() && state.status === "playing" && !state.aside;
|
|
789
859
|
|
|
790
860
|
/** One look while playing: a fresh position, then frozen, overrun, recovered or fine (Soloist issue #5). */
|
|
791
861
|
const checkStall = async () => {
|
|
@@ -829,7 +899,7 @@ export function spotify(
|
|
|
829
899
|
state.stalled && stallAs({ ...state.stalled, tried: what });
|
|
830
900
|
// Still ours to fix: playing this track, stalled, and no duck, interrupt or person in between.
|
|
831
901
|
const ours = () =>
|
|
832
|
-
|
|
902
|
+
isOpen() &&
|
|
833
903
|
state.status === "playing" &&
|
|
834
904
|
state.track?.uri === track &&
|
|
835
905
|
state.stalled?.track === track &&
|
|
@@ -867,9 +937,11 @@ export function spotify(
|
|
|
867
937
|
/** Ask Soloist for its state again (`get_state`, or `get_auth_state` without a session); while it is unreachable, reconnect now. */
|
|
868
938
|
refresh() {
|
|
869
939
|
const s = socket;
|
|
870
|
-
if (s &&
|
|
940
|
+
if (s && isOpen() && s.readyState === OPEN)
|
|
871
941
|
return query(
|
|
872
|
-
state.loggedIn === false ||
|
|
942
|
+
state.loggedIn === false || link === "open"
|
|
943
|
+
? "get_auth_state"
|
|
944
|
+
: "get_state",
|
|
873
945
|
);
|
|
874
946
|
wake?.abort();
|
|
875
947
|
},
|
|
@@ -7,7 +7,7 @@ Reads the household's spending from Up Bank's API: two months of transactions, s
|
|
|
7
7
|
- **`client.ts`**: `upClient({ token, fetch })`. Reads: `ping`, `accounts(filter)`, `transactions(query)` following cursors to `limit` (default 1000) and `pageCap` (20) and returning `{ transactions, truncated }` so a cut-off list never looks complete, `transaction(id)`, `categories`, `tags`, `webhooks`, `webhook(id)`, `webhookLogs(id)`. Writes: `setCategory(id, categoryId | null)`, `addTags(id, labels)`, `removeTags(id, labels)`, each re-reading and returning the transaction as Up now holds it; `createWebhook`, `deleteWebhook`, `pingWebhook`. A transaction is flattened to cents (Up's `valueInBaseUnits`) and ids.
|
|
8
8
|
- **`spend.ts`**, pure, one owner per number. `notSpend(t)` is the rule and says why: held, money in, `transferAccount` set (a movement between your own accounts), or a description Up gives its own movements ("Cover from", "Forward to", "Round Up", "Quick save transfer"). "Transfer to …" is not on the list: paying someone else is spend. `isSpend`, `spendByCategory` (parents and children, excluded movements counted and summed so a total can be checked), `spendByMerchant`, `uncategorised` (spend with no category; money in is categorisable too but left out), `recurring` (a heuristic: three or more months within 20% of the median), `unusual` (over twice the median of three or more earlier purchases at that merchant), `dollars` for display.
|
|
9
9
|
- **`webhook.ts`**: `verifyWebhook(rawBody, signature, secret)`, the SHA-256 HMAC of the raw body in hex, Web Crypto, compared in constant time; `parseWebhookEvent(body)` gives the event type and the transaction id. A consumer's handler verifies, then calls `home.refresh("up")`.
|
|
10
|
-
- **The integration** (`up({ token, account?, timeZone?, fetch })`): every 15 minutes it reads the categories, the accounts the filter names, and 63 days of transactions, then cuts this month and last in the home's time zone. The snapshot holds each month's spend by category and top five merchants, the ids still to categorise, and the latest 20 purchases trimmed to id, time, description, amount, category, status and tags (movements between your own accounts are left out: their descriptions name the accounts). Never kept: raw text, message, note, who spent (`performingCustomer`), account ids, balances. A 401 is `unavailable`; a read that hit its page cap is an `anomaly`, as is an amount missing or not in AUD. It never writes.
|
|
10
|
+
- **The integration** (`up({ token, account?, timeZone?, fetch })`, with `client` on it for a consumer's writes): every 15 minutes it reads the categories, the accounts the filter names, and 63 days of transactions, then cuts this month and last in the home's time zone. The snapshot holds each month's spend by category and top five merchants, the ids still to categorise, and the latest 20 purchases trimmed to id, time, description, amount, category, status and tags (movements between your own accounts are left out: their descriptions name the accounts). Never kept: raw text, message, note, who spent (`performingCustomer`), account ids, balances. A 401 is `unavailable`; a read that hit its page cap is an `anomaly`, as is an amount missing or not in AUD. It never writes.
|
|
11
11
|
- **Config**: `{ connector: "up", key: "UP_TOKEN", account: { ownershipType: "JOINT", accountType: "TRANSACTIONAL" } }`. Accounts are chosen by type and ownership, never by id. Without `account`, every account is read; transfers between them are excluded by `transferAccount`.
|
|
12
12
|
- **CLI**: `home up` (this month, last month, to categorise, top five categories), `home up transactions [--since] [--until] [--category] [--tag] [--status] [--limit] [--json|--csv]`, `categories`, `uncategorised`, `spend [--by category|merchant|recurring]`, and the writes `categorise <id> <category|none>`, `tag <id> <label> [--remove]`, `webhooks [get|logs|ping|create|delete]`. `UP_ACCOUNT` ("JOINT TRANSACTIONAL") is the filter's env form.
|
|
13
13
|
- **Household filter** (`household: { categories, tags }` in the config, `UP_HOUSEHOLD="home tag:home"` in env): the totals, top merchants and recent rows count only purchases in a listed category or its parent, or with a listed tag, so a family dashboard shows groceries and utilities, not personal spending. Tag anything else `home` in the Up app to bring it in. "To categorise" still looks at every purchase, since a category is how one joins. A listed category Up does not have is an `anomaly`, not a silent zero. `inHousehold` is the one owner of the rule; `home up spend` applies it too. `webhooks create` writes the secret Up returns once to `~/.config/home/up-webhook-<id>.secret` (0600); it is never printed.
|
|
@@ -241,13 +241,16 @@ export function toSnapshot(
|
|
|
241
241
|
};
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
-
/** Up as a home integration: two months of transactions now, then every 15 minutes.
|
|
245
|
-
export function up(config: UpConfig): Integration<UpSnapshot, "up"> {
|
|
244
|
+
/** 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. */
|
|
245
|
+
export function up(config: UpConfig): Integration<UpSnapshot, "up"> & {
|
|
246
|
+
client: UpClient;
|
|
247
|
+
} {
|
|
246
248
|
const client = upClient(config);
|
|
247
249
|
const clock = config.clock ?? realClock;
|
|
248
250
|
const poll = poller(clock);
|
|
249
251
|
return {
|
|
250
252
|
name: "up",
|
|
253
|
+
client,
|
|
251
254
|
refresh: poll.nudge,
|
|
252
255
|
async run(signal, update) {
|
|
253
256
|
let last: UpSnapshot | undefined;
|
|
@@ -68,7 +68,7 @@ Plain `fetch` cannot read `ftp://` (Bun 1.4.2: "protocol must be http:, https: o
|
|
|
68
68
|
|
|
69
69
|
**Sun** (`sun.ts`). `sunNow` with [suncalc](https://github.com/mourner/suncalc) 2.1.1, which bundles its own types (degrees, azimuth clockwise from north; `@types/suncalc` describes v1 and is wrong for it). Times are for the local solar day containing `at`. On 2026-10-13 it matched BOM's own Sydney sunrise and sunset to the second. Dawn and dusk are civil (6° below the horizon), `nightEnd` and `night` astronomical (18°), `goldenHour` starts the evening's and `goldenHourEnd` ends the morning's (6° above). `rising` is before solar noon, which in suncalc's solar day means from solar midnight; `daylight` is altitude above 0°. **`sunNow(latitude, longitude, now)` is the owner**: the snapshot's `sun` is recomputed every 5 minutes, so a consumer showing `rising`, `daylight`, `next` or the position calls `sunNow` with its own clock, as `home status` does. `next` holds each of dawn, sunrise, sunset and dusk at its next occurrence after `at` (today's while still ahead, else tomorrow's), missing on a day that has none. The computed sun is the owner: Google's daily `sunEvents` are parsed only to check it, and a sunrise or sunset more than 2 minutes from the computed one (`sunDisagrees`, comparing within the solar day containing Google's time) goes into Google's `anomaly`, never into the sun.
|
|
70
70
|
|
|
71
|
-
Every request has a 10 s timeout and the user agent `home (+https://github.com/khalido/home)`: BOM's app API gives Bun's default `Bun/1.4.2` no answer at all, so nothing here relies on the default.
|
|
71
|
+
Every request has a 10 s timeout and the user agent `home (+https://github.com/khalido/home.ts)`: BOM's app API gives Bun's default `Bun/1.4.2` no answer at all, so nothing here relies on the default.
|
|
72
72
|
|
|
73
73
|
## Not built, on purpose
|
|
74
74
|
|
|
@@ -246,19 +246,35 @@ export function weather(
|
|
|
246
246
|
/** "20:20" in the home's time zone (the runtime's when unset), or "?". */
|
|
247
247
|
const hhmmIn = (iso: string | undefined, timeZone?: string) =>
|
|
248
248
|
iso ? localTime(Date.parse(iso), timeZone) : "?";
|
|
249
|
-
const day = (d?: ForecastDay) =>
|
|
249
|
+
const day = (d?: ForecastDay, rainLeft?: number) =>
|
|
250
250
|
d
|
|
251
251
|
? [
|
|
252
252
|
d.minC === undefined && d.maxC === undefined
|
|
253
253
|
? ""
|
|
254
254
|
: `${d.minC ?? "?"}–${d.maxC ?? "?"} °C`,
|
|
255
255
|
d.text?.replace(/\.$/, "").toLowerCase() ?? "",
|
|
256
|
-
d.rainChance === undefined
|
|
256
|
+
d.rainChance === undefined
|
|
257
|
+
? ""
|
|
258
|
+
: rainLeft !== undefined && rainLeft < d.rainChance
|
|
259
|
+
? `${rainLeft}% chance of rain the rest of the day`
|
|
260
|
+
: `${d.rainChance}% chance of rain`,
|
|
257
261
|
]
|
|
258
262
|
.filter(Boolean)
|
|
259
263
|
.join(" ")
|
|
260
264
|
: "?";
|
|
261
265
|
|
|
266
|
+
/** The highest hourly chance left today, all 24 hours of it, when the hourly forecast is the same source and day as the forecast: sources are never mixed. */
|
|
267
|
+
const rainLeftToday = (
|
|
268
|
+
w: Pick<Slices, "forecast" | "hourly"> & { timeZone?: string },
|
|
269
|
+
at: number,
|
|
270
|
+
) => {
|
|
271
|
+
if (!w.hourly || w.hourly.source !== w.forecast?.source) return undefined;
|
|
272
|
+
const rest = dayBlocks(w.hourly, at, w.timeZone, [
|
|
273
|
+
{ name: "rest", from: 0, to: 24 },
|
|
274
|
+
])?.today[0];
|
|
275
|
+
return rest?.date === w.forecast.today?.date ? rest?.rainChance : undefined;
|
|
276
|
+
};
|
|
277
|
+
|
|
262
278
|
/** "rain in 30 min, ends in 55 min, 70%, 0.8 mm, moderate (google)", "raining, ends in 25 min", or "no rain for 2 h"; nothing when the forecast covers none of the window. */
|
|
263
279
|
export function rainText(
|
|
264
280
|
rain: Slices["rain"],
|
|
@@ -310,7 +326,7 @@ export function weatherText(
|
|
|
310
326
|
: "";
|
|
311
327
|
const f = w.forecast;
|
|
312
328
|
const forecast = f
|
|
313
|
-
? `today ${day(f.today)}, tomorrow ${day(f.tomorrow)} (${f.source}${f.modelled ? " modelled" : ""})`
|
|
329
|
+
? `today ${day(f.today, rainLeftToday(w, Date.parse(sun.at)))}, tomorrow ${day(f.tomorrow)} (${f.source}${f.modelled ? " modelled" : ""})`
|
|
314
330
|
: "";
|
|
315
331
|
const k = w.sky;
|
|
316
332
|
const skyParts = k
|
package/src/discover.ts
CHANGED
|
@@ -307,6 +307,7 @@ export type CachedAddress = {
|
|
|
307
307
|
host: string;
|
|
308
308
|
port?: number;
|
|
309
309
|
name?: string;
|
|
310
|
+
model?: string; // Cast: what kind of device, without a discovery
|
|
310
311
|
seenAt: string;
|
|
311
312
|
};
|
|
312
313
|
|
|
@@ -345,7 +346,7 @@ export function addressBook(
|
|
|
345
346
|
seen(
|
|
346
347
|
connector: Connector,
|
|
347
348
|
id: string | undefined,
|
|
348
|
-
address: { host: string; port?: number; name?: string },
|
|
349
|
+
address: { host: string; port?: number; name?: string; model?: string },
|
|
349
350
|
): void {
|
|
350
351
|
if (!cache || id === undefined) return;
|
|
351
352
|
const key = addressKey(connector, id);
|
|
@@ -355,14 +356,17 @@ export function addressBook(
|
|
|
355
356
|
c.host === address.host &&
|
|
356
357
|
c.port === address.port &&
|
|
357
358
|
isFresh(c) &&
|
|
358
|
-
(!address.name || address.name === c.name)
|
|
359
|
+
(!address.name || address.name === c.name) &&
|
|
360
|
+
(!address.model || address.model === c.model)
|
|
359
361
|
)
|
|
360
362
|
return;
|
|
361
363
|
const name = address.name || c?.name;
|
|
364
|
+
const model = address.model || c?.model;
|
|
362
365
|
entries[key] = {
|
|
363
366
|
host: address.host,
|
|
364
367
|
...(address.port === undefined ? {} : { port: address.port }),
|
|
365
368
|
...(name ? { name } : {}),
|
|
369
|
+
...(model ? { model } : {}),
|
|
366
370
|
seenAt: new Date(now()).toISOString(),
|
|
367
371
|
};
|
|
368
372
|
cache.write({ ...entries });
|
|
@@ -374,7 +378,8 @@ export function addressBook(
|
|
|
374
378
|
/**
|
|
375
379
|
* Run one discovery `passes` times, `gapMs` apart, and return the union (first answer wins). `onPass` sees each pass's
|
|
376
380
|
* additions as they come; `done` ends it early once what was looked for has answered. A pass that throws adds nothing;
|
|
377
|
-
* when every pass threw, discovery never ran, so it throws the last error rather than reporting nothing found.
|
|
381
|
+
* when every pass threw, discovery never ran, so it throws the last error rather than reporting nothing found. An error
|
|
382
|
+
* `stopOn` accepts is thrown at once: no later pass could do better.
|
|
378
383
|
*/
|
|
379
384
|
export async function discover<T = Found>(
|
|
380
385
|
pass: () => Promise<T[]>,
|
|
@@ -384,6 +389,7 @@ export async function discover<T = Found>(
|
|
|
384
389
|
gapMs?: number;
|
|
385
390
|
key?: (t: T) => string;
|
|
386
391
|
done?: (all: T[]) => boolean;
|
|
392
|
+
stopOn?: (error: unknown) => boolean;
|
|
387
393
|
onPass?: (n: number, added: T[]) => void;
|
|
388
394
|
signal?: AbortSignal;
|
|
389
395
|
},
|
|
@@ -401,9 +407,11 @@ export async function discover<T = Found>(
|
|
|
401
407
|
);
|
|
402
408
|
if (options.signal?.aborted) break;
|
|
403
409
|
ran++;
|
|
404
|
-
const answers = await pass().catch(
|
|
405
|
-
|
|
406
|
-
|
|
410
|
+
const answers = await pass().catch((error) => {
|
|
411
|
+
if (options.stopOn?.(error)) throw error;
|
|
412
|
+
errors.push(error);
|
|
413
|
+
return [] as T[];
|
|
414
|
+
});
|
|
407
415
|
const added = answers.filter((t) => !all.has(key(t)) && all.set(key(t), t));
|
|
408
416
|
options.onPass?.(n, added);
|
|
409
417
|
if (options.done?.([...all.values()])) break;
|
package/src/energy.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AmberState, PriceInterval } from "./connectors/amber/client.ts";
|
|
2
2
|
import type { EnphaseState } from "./connectors/enphase/client.ts";
|
|
3
3
|
import type { EnphaseSnapshot } from "./connectors/enphase/index.ts";
|
|
4
|
+
import { kw } from "./manifest.ts";
|
|
4
5
|
|
|
5
6
|
/** The home's energy right now: power from Enphase, prices from Amber, and what the grid flow costs per hour. */
|
|
6
7
|
export type EnergyNow = {
|
|
@@ -112,7 +113,6 @@ export function solarLevel(
|
|
|
112
113
|
* "balanced (-10 W)", or "" with no grid reading. Rounded for reading only; the numbers above stay as read.
|
|
113
114
|
*/
|
|
114
115
|
export function gridText(now: EnergyNow): string {
|
|
115
|
-
const kw = (w: number) => `${(w / 1000).toFixed(1)} kW`;
|
|
116
116
|
const rate = (c: number | undefined, positive: string, negative: string) =>
|
|
117
117
|
c === undefined
|
|
118
118
|
? ""
|
package/src/finders.ts
CHANGED
|
@@ -31,6 +31,9 @@ import {
|
|
|
31
31
|
// does not slow a scan. Without the tool the mDNS finders throw, naming it, rather than "nothing answered".
|
|
32
32
|
// `fileCache` keeps the addresses discovery found in a file between runs, so the next run connects straight away.
|
|
33
33
|
|
|
34
|
+
/** The system's mDNS tool is not installed: discovery can't run until it is. */
|
|
35
|
+
export class MissingTool extends Error {}
|
|
36
|
+
|
|
34
37
|
/**
|
|
35
38
|
* Run a long-lived `dns-sd` query (or `avahi-browse`, by `command`) and return what it printed. It never exits by itself, so it is stopped once `done`
|
|
36
39
|
* says the answer is in, or `quietMs` after the last output, or at `maxMs`. The system's mDNS cache usually answers in
|
|
@@ -68,11 +71,11 @@ export function dnsSd(
|
|
|
68
71
|
proc.on("error", (error: NodeJS.ErrnoException) => {
|
|
69
72
|
(clearTimeout(timer), clearTimeout(quiet));
|
|
70
73
|
reject(
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
74
|
+
error.code === "ENOENT"
|
|
75
|
+
? new MissingTool(
|
|
76
|
+
`${command} not found; mDNS discovery needs it${install ? ` (${install})` : ""}`,
|
|
77
|
+
)
|
|
78
|
+
: new Error(`${command}: ${error.message}`),
|
|
76
79
|
);
|
|
77
80
|
});
|
|
78
81
|
proc.on("close", finish);
|