@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
|
@@ -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,16 @@ 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
|
|
202
209
|
let startBuffering = false; // a start from idle is still buffering
|
|
203
210
|
let capNote: string | undefined; // the last first-play lowering, in `anomaly` until the speaker goes idle
|
|
204
211
|
let frameNote: string | undefined; // a frame the schema rejected, or an error nothing was waiting for
|
|
@@ -214,11 +221,10 @@ export function spotify(
|
|
|
214
221
|
const publish = () => {
|
|
215
222
|
let status: Status;
|
|
216
223
|
let detail: string | undefined;
|
|
217
|
-
if (
|
|
224
|
+
if (link === "down")
|
|
218
225
|
((status = reported || down ? "disconnected" : "connecting"),
|
|
219
226
|
(detail = down));
|
|
220
|
-
else if (
|
|
221
|
-
status = "connecting";
|
|
227
|
+
else if (link !== "whole") status = "connecting";
|
|
222
228
|
else
|
|
223
229
|
((status = "connected"),
|
|
224
230
|
(detail = state.loggedIn === false ? WAITING : note));
|
|
@@ -251,7 +257,7 @@ export function spotify(
|
|
|
251
257
|
) =>
|
|
252
258
|
new Promise<void>((resolve, reject) => {
|
|
253
259
|
const s = socket;
|
|
254
|
-
if (!s || !
|
|
260
|
+
if (!s || !isOpen() || s.readyState !== OPEN) return reject(offline());
|
|
255
261
|
if (state.loggedIn === false && !answers)
|
|
256
262
|
return reject(new ConnectorError(WAITING, { code: "waiting" }));
|
|
257
263
|
const stop = new AbortController();
|
|
@@ -291,12 +297,20 @@ export function spotify(
|
|
|
291
297
|
answers?: string;
|
|
292
298
|
then?: { command: string; fields: Record<string, unknown> };
|
|
293
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
|
|
294
301
|
};
|
|
295
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
|
+
}
|
|
296
307
|
const before = state;
|
|
297
308
|
const at = clock.now();
|
|
298
309
|
await command(body.command, body.fields, body.answers);
|
|
299
|
-
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
|
+
}
|
|
300
314
|
return { before, at };
|
|
301
315
|
};
|
|
302
316
|
let once = 0; // skips and queued tracks never merge: two nexts are two skips
|
|
@@ -309,7 +323,7 @@ export function spotify(
|
|
|
309
323
|
const change = async (slot: string, body: Body): Promise<Applied> => {
|
|
310
324
|
const sent = await write(slot, body);
|
|
311
325
|
const took = () => body.took?.(state, sent.before, sent.at) ?? true;
|
|
312
|
-
await waitFor(() => took() || !
|
|
326
|
+
await waitFor(() => took() || !isOpen(), CONFIRM_MS);
|
|
313
327
|
return { state, confirmed: took() };
|
|
314
328
|
};
|
|
315
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). */
|
|
@@ -321,12 +335,17 @@ export function spotify(
|
|
|
321
335
|
!!s.track && s.track.uri !== b.track?.uri;
|
|
322
336
|
const invalid = (message: string) =>
|
|
323
337
|
new ConnectorError(message, { code: "invalid" });
|
|
338
|
+
const near = (volume: number | undefined, v: number) =>
|
|
339
|
+
volume !== undefined && Math.abs(volume - v) <= VOLUME_SLACK;
|
|
324
340
|
const volumeTo = (v: number) =>
|
|
325
341
|
change("volume", {
|
|
326
342
|
command: "set_volume",
|
|
327
343
|
fields: { volume: v },
|
|
328
|
-
took: (s) => s.volume
|
|
344
|
+
took: (s) => near(s.volume, v),
|
|
329
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;
|
|
330
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. */
|
|
331
350
|
const ANSWERS = {
|
|
332
351
|
get_state: "playback_state",
|
|
@@ -353,27 +372,49 @@ export function spotify(
|
|
|
353
372
|
publish();
|
|
354
373
|
});
|
|
355
374
|
|
|
356
|
-
/**
|
|
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
|
+
*/
|
|
357
380
|
const applyCap = () => {
|
|
358
381
|
if (
|
|
359
382
|
cap === undefined ||
|
|
360
383
|
capped ||
|
|
384
|
+
capping ||
|
|
361
385
|
state.volume === undefined ||
|
|
362
|
-
|
|
363
|
-
state.
|
|
386
|
+
state.volume <= cap + VOLUME_SLACK ||
|
|
387
|
+
state.active === false ||
|
|
388
|
+
clock.now() > capUntil
|
|
364
389
|
)
|
|
365
390
|
return;
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
+
},
|
|
377
418
|
);
|
|
378
419
|
};
|
|
379
420
|
|
|
@@ -411,11 +452,16 @@ export function spotify(
|
|
|
411
452
|
}
|
|
412
453
|
const before = state.status;
|
|
413
454
|
const wasSignedIn = state.loggedIn;
|
|
455
|
+
const wasActive = link === "open" ? undefined : state.active; // the first frames on a socket are not a change
|
|
414
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));
|
|
415
461
|
if (f.type === "auth_state") {
|
|
416
462
|
// Signed out there is no playback_state to wait for, and a later sign-in must not send the part back to connecting.
|
|
417
|
-
if (
|
|
418
|
-
|
|
463
|
+
if (link === "open" || (link === "heard" && f.logged_in === false))
|
|
464
|
+
link = f.logged_in === true ? "heard" : "whole";
|
|
419
465
|
if (f.logged_in === false) statusKnown = false;
|
|
420
466
|
// A first sign-in (someone picked the speaker) sends only auth_state; the docs don't promise a playback_state after it.
|
|
421
467
|
else if (f.logged_in === true && wasSignedIn === false)
|
|
@@ -423,15 +469,15 @@ export function spotify(
|
|
|
423
469
|
}
|
|
424
470
|
if (f.type === "playback_state") {
|
|
425
471
|
if (!queueAsked) ((queueAsked = true), query("get_queue", { limit: 10 })); // queue_changed pushes changes only, so ask once per socket
|
|
426
|
-
|
|
472
|
+
link = "whole";
|
|
427
473
|
}
|
|
428
474
|
if (pending?.answers === f.type) pending.finish();
|
|
429
475
|
if (
|
|
430
476
|
(f.type === "playback_state" || f.type === "playback_changed") &&
|
|
431
477
|
f.status !== undefined
|
|
432
478
|
) {
|
|
433
|
-
// A start is idle → playing or buffering, or a sign-in whose first status is either: a phone picked the speaker and played
|
|
434
|
-
//
|
|
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.
|
|
435
481
|
const started =
|
|
436
482
|
((f.status === "playing" || f.status === "buffering") &&
|
|
437
483
|
(statusKnown ? before === "idle" : signedIn)) ||
|
|
@@ -470,10 +516,8 @@ export function spotify(
|
|
|
470
516
|
quiet.abort();
|
|
471
517
|
signal.removeEventListener("abort", stop);
|
|
472
518
|
if (socket === s) socket = undefined;
|
|
473
|
-
const was =
|
|
474
|
-
|
|
475
|
-
heard = false;
|
|
476
|
-
whole = false;
|
|
519
|
+
const was = isOpen();
|
|
520
|
+
link = "down";
|
|
477
521
|
queueAsked = false;
|
|
478
522
|
statusKnown = false;
|
|
479
523
|
signedIn = false;
|
|
@@ -488,17 +532,17 @@ export function spotify(
|
|
|
488
532
|
// An ended socket's late events belong to no session: they must not touch the next socket's state.
|
|
489
533
|
s.onopen = () => {
|
|
490
534
|
if (ended) return;
|
|
491
|
-
|
|
535
|
+
link = "open";
|
|
492
536
|
down = undefined;
|
|
493
537
|
note = undefined; // a new socket: what failed on the last one is history
|
|
494
538
|
void clock.sleep(HELLO_MS, quiet.signal).then(() => {
|
|
495
539
|
if (quiet.signal.aborted) return;
|
|
496
|
-
if (
|
|
540
|
+
if (link === "open")
|
|
497
541
|
return (
|
|
498
542
|
end(`Soloist said nothing in ${HELLO_MS / 1000} s`),
|
|
499
543
|
s.close()
|
|
500
544
|
);
|
|
501
|
-
if (
|
|
545
|
+
if (link === "heard") ((link = "whole"), publish()); // signed in, no playback_state: connected with what there is
|
|
502
546
|
});
|
|
503
547
|
};
|
|
504
548
|
s.onmessage = (ev) => void (ended || handle(ev.data));
|
|
@@ -518,7 +562,7 @@ export function spotify(
|
|
|
518
562
|
|
|
519
563
|
/** Not connected or not playing: the reason `duck` and `interrupt` just run `fn`. */
|
|
520
564
|
const notPlaying = () =>
|
|
521
|
-
!
|
|
565
|
+
!isOpen()
|
|
522
566
|
? "Soloist is not connected"
|
|
523
567
|
: state.status === "playing" || state.status === "buffering"
|
|
524
568
|
? undefined
|
|
@@ -544,7 +588,7 @@ export function spotify(
|
|
|
544
588
|
leave: () => Promise<Left>,
|
|
545
589
|
) => {
|
|
546
590
|
const run = async (): Promise<Aside<T>> => {
|
|
547
|
-
if (
|
|
591
|
+
if (isOpen()) marked(kind);
|
|
548
592
|
try {
|
|
549
593
|
return await within();
|
|
550
594
|
} finally {
|
|
@@ -578,10 +622,20 @@ export function spotify(
|
|
|
578
622
|
};
|
|
579
623
|
|
|
580
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.
|
|
581
634
|
async play(linkOrUri) {
|
|
582
635
|
if (linkOrUri === undefined)
|
|
583
636
|
return change("transport", {
|
|
584
637
|
command: "play",
|
|
638
|
+
activeFirst: true,
|
|
585
639
|
took: (s) => s.status === "playing",
|
|
586
640
|
});
|
|
587
641
|
const uri = toSpotifyUri(linkOrUri);
|
|
@@ -589,6 +643,7 @@ export function spotify(
|
|
|
589
643
|
return change("transport", {
|
|
590
644
|
command: "play",
|
|
591
645
|
fields: { uri },
|
|
646
|
+
activeFirst: true,
|
|
592
647
|
took: (s) =>
|
|
593
648
|
s.status === "playing" &&
|
|
594
649
|
(s.track?.uri === uri || s.context?.uri === uri),
|
|
@@ -632,6 +687,11 @@ export function spotify(
|
|
|
632
687
|
async setVolume(volume) {
|
|
633
688
|
if (!Number.isFinite(volume) || volume < 0 || volume > 100)
|
|
634
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
|
+
);
|
|
635
695
|
return volumeTo(
|
|
636
696
|
Math.round(cap === undefined ? volume : Math.min(volume, cap)),
|
|
637
697
|
);
|
|
@@ -712,6 +772,8 @@ export function spotify(
|
|
|
712
772
|
async () => {
|
|
713
773
|
const no = notPlaying();
|
|
714
774
|
if (no) return no;
|
|
775
|
+
if (inactive())
|
|
776
|
+
return "it is not the active device, so its volume can't be changed";
|
|
715
777
|
if (state.volume === undefined) return "its volume is not known";
|
|
716
778
|
if (state.volume <= to) return `it was already at ${state.volume}`;
|
|
717
779
|
earlier = state.volume;
|
|
@@ -724,7 +786,7 @@ export function spotify(
|
|
|
724
786
|
}
|
|
725
787
|
},
|
|
726
788
|
async () => {
|
|
727
|
-
if (state.volume
|
|
789
|
+
if (!near(state.volume, Math.round(to)))
|
|
728
790
|
return {
|
|
729
791
|
music: "kept",
|
|
730
792
|
why: shown
|
|
@@ -766,7 +828,7 @@ export function spotify(
|
|
|
766
828
|
state = rest;
|
|
767
829
|
}
|
|
768
830
|
let seen: { track: string; ms: number; atMs: number } | undefined; // the last check's reported position
|
|
769
|
-
let repairedFor: string | undefined; // at most one repair per track
|
|
831
|
+
let repairedFor: string | undefined; // at most one repair per play of a track
|
|
770
832
|
/** Ask Soloist where it is, waiting for the answer (or the query's timeout). */
|
|
771
833
|
const fresh = () =>
|
|
772
834
|
write("query-get_state", {
|
|
@@ -793,7 +855,7 @@ export function spotify(
|
|
|
793
855
|
a.track === b.track &&
|
|
794
856
|
b.atMs - a.atMs >= gap &&
|
|
795
857
|
Math.abs(b.ms - a.ms) < 1000;
|
|
796
|
-
const playing = () =>
|
|
858
|
+
const playing = () => isOpen() && state.status === "playing" && !state.aside;
|
|
797
859
|
|
|
798
860
|
/** One look while playing: a fresh position, then frozen, overrun, recovered or fine (Soloist issue #5). */
|
|
799
861
|
const checkStall = async () => {
|
|
@@ -837,7 +899,7 @@ export function spotify(
|
|
|
837
899
|
state.stalled && stallAs({ ...state.stalled, tried: what });
|
|
838
900
|
// Still ours to fix: playing this track, stalled, and no duck, interrupt or person in between.
|
|
839
901
|
const ours = () =>
|
|
840
|
-
|
|
902
|
+
isOpen() &&
|
|
841
903
|
state.status === "playing" &&
|
|
842
904
|
state.track?.uri === track &&
|
|
843
905
|
state.stalled?.track === track &&
|
|
@@ -875,9 +937,11 @@ export function spotify(
|
|
|
875
937
|
/** Ask Soloist for its state again (`get_state`, or `get_auth_state` without a session); while it is unreachable, reconnect now. */
|
|
876
938
|
refresh() {
|
|
877
939
|
const s = socket;
|
|
878
|
-
if (s &&
|
|
940
|
+
if (s && isOpen() && s.readyState === OPEN)
|
|
879
941
|
return query(
|
|
880
|
-
state.loggedIn === false ||
|
|
942
|
+
state.loggedIn === false || link === "open"
|
|
943
|
+
? "get_auth_state"
|
|
944
|
+
: "get_state",
|
|
881
945
|
);
|
|
882
946
|
wake?.abort();
|
|
883
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;
|
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 });
|
package/src/lane.ts
CHANGED
|
@@ -54,28 +54,31 @@ export function writeLane(options: LaneOptions) {
|
|
|
54
54
|
|
|
55
55
|
async function run() {
|
|
56
56
|
running = true;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
57
|
+
try {
|
|
58
|
+
while (queue.length) {
|
|
59
|
+
const now = clock.now();
|
|
60
|
+
const next = queue.find((p) => readyAt(p.slot) <= now);
|
|
61
|
+
if (!next) {
|
|
62
|
+
wake = new AbortController();
|
|
63
|
+
await clock.sleep(
|
|
64
|
+
Math.min(...queue.map((p) => readyAt(p.slot))) - now,
|
|
65
|
+
wake.signal,
|
|
66
|
+
);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
queue.splice(queue.indexOf(next), 1);
|
|
70
|
+
lastAt = now;
|
|
71
|
+
sent.set(next.slot, [...recent(next.slot), now]);
|
|
72
|
+
try {
|
|
73
|
+
const result = await next.write(next.body);
|
|
74
|
+
for (const w of next.waiters) w.resolve(result);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
for (const w of next.waiters) w.reject(error);
|
|
77
|
+
}
|
|
76
78
|
}
|
|
79
|
+
} finally {
|
|
80
|
+
running = false; // a throw here must not leave the lane stuck
|
|
77
81
|
}
|
|
78
|
-
running = false;
|
|
79
82
|
}
|
|
80
83
|
|
|
81
84
|
return {
|