@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +28 -1
  2. package/README.md +11 -11
  3. package/dist/config.js +3 -1
  4. package/dist/connectors/cast/index.d.ts +4 -1
  5. package/dist/connectors/cast/index.js +7 -1
  6. package/dist/connectors/cast/schemas.d.ts +2 -0
  7. package/dist/connectors/cast/schemas.js +1 -0
  8. package/dist/connectors/cast/state.d.ts +4 -0
  9. package/dist/connectors/cast/state.js +12 -0
  10. package/dist/connectors/cast/writes.d.ts +9 -0
  11. package/dist/connectors/cast/writes.js +42 -6
  12. package/dist/connectors/spotify/changes.d.ts +1 -1
  13. package/dist/connectors/spotify/changes.js +4 -2
  14. package/dist/connectors/spotify/client.d.ts +1 -1
  15. package/dist/connectors/spotify/index.d.ts +3 -1
  16. package/dist/connectors/spotify/index.js +104 -44
  17. package/dist/connectors/up/index.d.ts +4 -2
  18. package/dist/connectors/up/index.js +2 -1
  19. package/dist/discover.d.ts +2 -0
  20. package/dist/discover.js +4 -1
  21. package/dist/lane.js +24 -20
  22. package/docs/cast.md +1 -1
  23. package/docs/cli.md +2 -2
  24. package/docs/decisions.md +1 -0
  25. package/package.json +1 -1
  26. package/src/cli/cast.ts +19 -11
  27. package/src/cli/spotify.ts +2 -1
  28. package/src/config.ts +3 -1
  29. package/src/connectors/cast/README.md +4 -3
  30. package/src/connectors/cast/index.ts +17 -4
  31. package/src/connectors/cast/schemas.ts +1 -0
  32. package/src/connectors/cast/state.ts +12 -0
  33. package/src/connectors/cast/writes.ts +58 -9
  34. package/src/connectors/spotify/README.md +20 -8
  35. package/src/connectors/spotify/changes.ts +4 -2
  36. package/src/connectors/spotify/client.ts +1 -1
  37. package/src/connectors/spotify/index.ts +111 -47
  38. package/src/connectors/up/README.md +1 -1
  39. package/src/connectors/up/index.ts +5 -2
  40. package/src/discover.ts +6 -2
  41. 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 an idle → playing change, a volume above the cap is lowered for this long: the volume can come before or after the status (issue #23). */
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
- let open = false;
194
- let heard = false; // an auth_state on this socket: until then the part stays connecting
195
- let whole = false; // signed in, the first playback_state has come too (or HELLO_MS passed): a one-shot read sees the track
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 has been sent in this window
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 (!open)
224
+ if (link === "down")
218
225
  ((status = reported || down ? "disconnected" : "connecting"),
219
226
  (detail = down));
220
- else if (!heard || (state.loggedIn === true && !whole))
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 || !open || s.readyState !== OPEN) return reject(offline());
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) await command(body.then.command, body.then.fields); // repeat's second half, still this write's turn
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() || !open, CONFIRM_MS);
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 === v,
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
- /** Within the window after a start from idle, a volume above the cap is lowered to it, once (issue #23). */
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
- clock.now() > capUntil ||
363
- state.volume <= cap
386
+ state.volume <= cap + VOLUME_SLACK ||
387
+ state.active === false ||
388
+ clock.now() > capUntil
364
389
  )
365
390
  return;
366
- capped = true;
367
- capNote = `started at volume ${state.volume}, above maxVolume ${cap}; lowered it (Soloist issue #23)`;
368
- change("volume", {
369
- command: "set_volume",
370
- fields: { volume: cap },
371
- took: (s) => s.volume === cap,
372
- }).catch(
373
- (error) => (
374
- (note = `lowering the volume to ${cap} failed: ${errorText(error)}`),
375
- publish()
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 (!heard && f.logged_in !== true) whole = true;
418
- heard = true;
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
- whole = true;
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 (issue #23).
434
- // Soloist's jump to 100 comes about 100 ms after `playing` (issue #23), so a start through buffering opens the window again then.
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 = open;
474
- open = false;
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
- open = true;
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 (!heard)
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 (!whole) ((whole = true), publish()); // signed in, no playback_state: connected with what there is
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
- !open
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 (open) marked(kind);
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 !== Math.round(to))
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 = () => open && state.status === "playing" && !state.aside;
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
- open &&
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 && open && s.readyState === OPEN)
940
+ if (s && isOpen() && s.readyState === OPEN)
879
941
  return query(
880
- state.loggedIn === false || !heard ? "get_auth_state" : "get_state",
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. Reads only; it never writes. */
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
- while (queue.length) {
58
- const now = clock.now();
59
- const next = queue.find((p) => readyAt(p.slot) <= now);
60
- if (!next) {
61
- wake = new AbortController();
62
- await clock.sleep(
63
- Math.min(...queue.map((p) => readyAt(p.slot))) - now,
64
- wake.signal,
65
- );
66
- continue;
67
- }
68
- queue.splice(queue.indexOf(next), 1);
69
- lastAt = now;
70
- sent.set(next.slot, [...recent(next.slot), now]);
71
- try {
72
- const result = await next.write(next.body);
73
- for (const w of next.waiters) w.resolve(result);
74
- } catch (error) {
75
- for (const w of next.waiters) w.reject(error);
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 {