@khalido/home 0.2.0 → 0.2.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 (76) hide show
  1. package/CHANGELOG.md +24 -2
  2. package/dist/connector.d.ts +2 -0
  3. package/dist/connector.js +3 -0
  4. package/dist/connectors/amber/client.d.ts +1 -1
  5. package/dist/connectors/amber/client.js +1 -1
  6. package/dist/connectors/amber/index.js +2 -2
  7. package/dist/connectors/cast/index.d.ts +2 -1
  8. package/dist/connectors/cast/index.js +1 -0
  9. package/dist/connectors/cast/wav.d.ts +31 -0
  10. package/dist/connectors/cast/wav.js +149 -0
  11. package/dist/connectors/cast/writes.d.ts +25 -6
  12. package/dist/connectors/cast/writes.js +155 -62
  13. package/dist/connectors/enphase/client.d.ts +1 -1
  14. package/dist/connectors/enphase/client.js +1 -1
  15. package/dist/connectors/enphase/index.js +2 -1
  16. package/dist/connectors/hue/events.d.ts +0 -2
  17. package/dist/connectors/hue/events.js +1 -2
  18. package/dist/connectors/hue/index.js +2 -2
  19. package/dist/connectors/printers/client.d.ts +41 -6
  20. package/dist/connectors/printers/client.js +81 -8
  21. package/dist/connectors/printers/codec.d.ts +17 -3
  22. package/dist/connectors/printers/codec.js +32 -8
  23. package/dist/connectors/printers/index.d.ts +2 -0
  24. package/dist/connectors/printers/index.js +4 -0
  25. package/dist/connectors/printers/raster.d.ts +30 -0
  26. package/dist/connectors/printers/raster.js +175 -0
  27. package/dist/connectors/printers/state.d.ts +2 -0
  28. package/dist/connectors/spotify/changes.d.ts +1 -1
  29. package/dist/connectors/spotify/changes.js +1 -1
  30. package/dist/connectors/spotify/index.d.ts +28 -14
  31. package/dist/connectors/spotify/index.js +89 -48
  32. package/dist/connectors/up/client.d.ts +1 -1
  33. package/dist/connectors/up/client.js +1 -1
  34. package/dist/connectors/up/index.js +2 -2
  35. package/dist/connectors/weather/google.js +2 -2
  36. package/docs/cast.md +1 -1
  37. package/docs/cli.md +3 -1
  38. package/docs/decisions.md +7 -0
  39. package/docs/ideas.md +2 -0
  40. package/docs/using.md +1 -1
  41. package/llms.txt +2 -2
  42. package/package.json +1 -1
  43. package/skills/draw-home/README.md +2 -0
  44. package/skills/use-home/SKILL.md +1 -1
  45. package/src/cli/cast.ts +41 -11
  46. package/src/cli/context.ts +1 -1
  47. package/src/cli/enphase.ts +31 -5
  48. package/src/cli/main.ts +14 -5
  49. package/src/cli/printers.ts +254 -3
  50. package/src/cli/spotify.ts +16 -12
  51. package/src/connector.ts +5 -0
  52. package/src/connectors/amber/README.md +1 -1
  53. package/src/connectors/amber/client.ts +1 -1
  54. package/src/connectors/amber/index.ts +2 -2
  55. package/src/connectors/cast/README.md +2 -0
  56. package/src/connectors/cast/index.ts +4 -0
  57. package/src/connectors/cast/wav.ts +181 -0
  58. package/src/connectors/cast/writes.ts +211 -71
  59. package/src/connectors/enphase/README.md +3 -1
  60. package/src/connectors/enphase/client.ts +1 -1
  61. package/src/connectors/enphase/index.ts +2 -1
  62. package/src/connectors/hue/events.ts +1 -4
  63. package/src/connectors/hue/index.ts +2 -2
  64. package/src/connectors/printers/README.md +9 -4
  65. package/src/connectors/printers/client.ts +186 -12
  66. package/src/connectors/printers/codec.ts +44 -10
  67. package/src/connectors/printers/index.ts +17 -0
  68. package/src/connectors/printers/raster.ts +215 -0
  69. package/src/connectors/printers/state.ts +2 -0
  70. package/src/connectors/spotify/README.md +5 -1
  71. package/src/connectors/spotify/changes.ts +1 -1
  72. package/src/connectors/spotify/index.ts +233 -106
  73. package/src/connectors/up/README.md +1 -1
  74. package/src/connectors/up/client.ts +1 -1
  75. package/src/connectors/up/index.ts +2 -2
  76. package/src/connectors/weather/google.ts +6 -2
@@ -26,6 +26,8 @@ Soloist is Spotify's own headless Spotify Connect client (Linux arm64, armv7, x8
26
26
 
27
27
  **`maxVolume`** holds requests (rounded to a whole percent). For 10 s after a start signal, a reported volume above it is lowered: a start from idle (to playing or buffering), the first such status after a sign-in, `playing` after a start through buffering, or the speaker going active. Event order differs between runs, so no one pattern is relied on. It waits until the speaker is active, retries until Soloist reports the lower volume, and only then says so in `anomaly`, until the speaker goes idle; if it never shows or Soloist refuses it, `detail` says so. A reported volume within 1 of the cap is left alone. Not on resuming from pause while active, not on connecting to a song already playing, and a person turning it up after the lowering keeps it (before Soloist has reported the lowering, a person's raise looks like the start's own jump and is lowered too). `--initial-volume` is ignored on PipeWire ([#23](https://github.com/spotify/soloist/issues/23)).
28
28
 
29
+ **Dry run.** Every write takes `{ dryRun: true }` last: the checks run (connected, signed in, the input, `inactive` for `setVolume`, `maxVolume`), nothing is sent, and it resolves `confirmed: false` with `wouldSend`, the commands in order (an inactive speaker's `play` lists `activate` first). `duck` and `interrupt` with `dryRun` run `fn`, touch nothing, and say in `why` what they would have done; they don't wait behind a real `duck` or `interrupt`. Tested on a fake socket only.
30
+
29
31
  **`duck(fn, { to })` and `interrupt(fn)`** turn the music down, or pause it, around `fn`, then put it back. Both resolve `{ value, music, why? }`, `music` one of:
30
32
 
31
33
  - `untouched`: not playing or buffering, not connected, not the active device, (duck) the volume unknown or already at `to` or below, or the lowering or pause failed, so `fn` ran over it.
@@ -58,6 +60,8 @@ Not built: `deactivate`, `available_actions`. An ad (`entity_type: "ad"`) shows
58
60
 
59
61
  ## Unverified
60
62
 
63
+ What Soloist does that this connector works around, and the upstream issues to watch: [khalido/home.ts#1](https://github.com/khalido/home.ts/issues/1).
64
+
61
65
  Writes through this connector, and what a live read hasn't shown. In particular:
62
66
 
63
67
  - **Stalls:** whether the reported position freezes in one (if not, only an overrun is caught), whether `get_state` re-anchors the position, and whether a seek or a pause and play restarts it.
@@ -70,7 +74,7 @@ Writes through this connector, and what a live read hasn't shown. In particular:
70
74
 
71
75
  **2026-11-11, Raspberry Pi 5, Bun 1.4.4, on 0.1.3** (chota-bot's session; `maxVolume: 40` watching, writes from the CLI in another process). Two gaps in the cap, not fixed:
72
76
 
73
- - From inactive and paused at 60, `play`: lowered to 40 within 2 ms of going active, `playing` at 40, then 60 again 90 ms after `playing`, and it played at 60. The cap takes its first confirmed lowering as done, so nothing lowered it again, and `anomaly` still said "lowered it". Soloist puts its own level back after `playing`.
77
+ - From inactive and paused at 60, `play`: lowered to 40 within 2 ms of going active, `playing` at 40, then 60 again 90 ms after `playing`, and it played at 60. The cap takes its first confirmed lowering as done, so nothing lowered it again, and `anomaly` still said "lowered it". After 0.2.0 the note adds "it is back at 60"; the level is left alone, since it is the one the speaker was playing at. Soloist puts its own level back after `playing`.
74
78
  - The connector started while the speaker was already active and paused at 60: `play` resumed at 60, nothing lowered it, no anomaly. This is the documented "not on resuming from pause while active".
75
79
  - A volume set by hand (35) held through pause and play, so the put-back belongs to activation, not to pause.
76
80
 
@@ -138,7 +138,7 @@ export function spotifyChanges(
138
138
  /**
139
139
  * The speaker in a few words: `Kitchen speaker: playing "Song" by Band · 35%`, `…: waiting for someone to pick it`, or,
140
140
  * for music on another Spotify device, `…: silent · "Song" by Band is playing on another Spotify device` (no volume: it isn't this speaker's playing level).
141
- * `on` names where, when the caller knows (`spotifyElsewhere`): `… is playing on Front room speaker`.
141
+ * `on` names where, when the caller knows (`spotifyElsewhere`): `… is playing on Den speaker`.
142
142
  */
143
143
  export function spotifyText(s: SpotifyState, on: string[] = []): string {
144
144
  const who = s.deviceName ?? "Soloist";
@@ -96,8 +96,23 @@ export type SpotifyConfig = {
96
96
  /** Soloist's repeat modes. */
97
97
  export type Repeat = "off" | "context" | "track";
98
98
 
99
- /** What a write left: the state once Soloist reported the change (`confirmed`), or the state as it stood when `CONFIRM_MS` ran out without it. */
100
- export type Applied = { state: SpotifyState; confirmed: boolean };
99
+ /** One WebSocket command, as a dry run reports it: Soloist's command name and its fields. */
100
+ export type SpotifyRequest = {
101
+ command: string;
102
+ fields?: Record<string, unknown>;
103
+ };
104
+ /** `dryRun`: run every check a real write runs (connected, signed in, the input), send nothing, and say what would go. */
105
+ export type SpotifyWriteOptions = { dryRun?: boolean };
106
+
107
+ /**
108
+ * What a write left: the state once Soloist reported the change (`confirmed`), or the state as it stood when `CONFIRM_MS`
109
+ * ran out without it. A dry run resolves `confirmed: false` with `wouldSend`, the commands in order; an `activate` already done resolves `confirmed: true` with `wouldSend: []`.
110
+ */
111
+ export type Applied = {
112
+ state: SpotifyState;
113
+ confirmed: boolean;
114
+ wouldSend?: SpotifyRequest[];
115
+ };
101
116
 
102
117
  /**
103
118
  * What happened to the music around `duck` or `interrupt`'s `fn`: `untouched` (it was not playing, not connected, already
@@ -115,34 +130,41 @@ export type Aside<T> = { value: T; music: Music; why?: string };
115
130
  */
116
131
  export type SpotifyActions = {
117
132
  /** 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. */
118
- activate(): Promise<Applied>;
133
+ activate(options?: SpotifyWriteOptions): Promise<Applied>;
119
134
  /** Play a Spotify link or URI (track, album, playlist, artist, episode, show), or resume what is loaded when none is given. */
120
- play(linkOrUri?: string): Promise<Applied>;
121
- pause(): Promise<Applied>;
122
- resume(): Promise<Applied>;
123
- next(): Promise<Applied>;
124
- prev(): Promise<Applied>;
135
+ play(linkOrUri?: string, options?: SpotifyWriteOptions): Promise<Applied>;
136
+ pause(options?: SpotifyWriteOptions): Promise<Applied>;
137
+ resume(options?: SpotifyWriteOptions): Promise<Applied>;
138
+ next(options?: SpotifyWriteOptions): Promise<Applied>;
139
+ prev(options?: SpotifyWriteOptions): Promise<Applied>;
125
140
  /** Jump to a position in the current track, in ms. */
126
- seek(ms: number): Promise<Applied>;
141
+ seek(ms: number, options?: SpotifyWriteOptions): Promise<Applied>;
127
142
  /** Set the volume 0–100, held to `maxVolume` and rounded to a whole percent: `state.volume` is what Soloist then reports. */
128
- setVolume(volume: number): Promise<Applied>;
143
+ setVolume(volume: number, options?: SpotifyWriteOptions): Promise<Applied>;
129
144
  /** Add a track to the queue (Soloist queues tracks only); confirmed when it shows in `upNext`. */
130
- queue(track: string): Promise<Applied>;
131
- setShuffle(on: boolean): Promise<Applied>;
145
+ queue(track: string, options?: SpotifyWriteOptions): Promise<Applied>;
146
+ setShuffle(on: boolean, options?: SpotifyWriteOptions): Promise<Applied>;
132
147
  /** Repeat off, the context (playlist, album) or the track: two commands, in the order Soloist's docs give, as one write. */
133
- setRepeat(mode: Repeat): Promise<Applied>;
148
+ setRepeat(mode: Repeat, options?: SpotifyWriteOptions): Promise<Applied>;
134
149
  /**
135
150
  * Pause if playing, run `fn`, then resume, unless someone changed it meanwhile (resumed, stopped or skipped it from a
136
151
  * phone): then it is theirs and stays. The resume runs even if `fn` throws. Not playing or not connected: just `fn`.
137
152
  * One `interrupt` or `duck` at a time; a second waits for the first, so never call either from inside `fn` (it would wait
138
153
  * forever). Two announcements back to back belong in one `fn`, or the music comes back between them.
139
154
  */
140
- interrupt<T>(fn: () => Promise<T> | T): Promise<Aside<T>>;
155
+ interrupt<T>(
156
+ fn: () => Promise<T> | T,
157
+ options?: SpotifyWriteOptions,
158
+ ): Promise<Aside<T>>;
141
159
  /**
142
160
  * Lower the volume to `to` while `fn` runs, then put the earlier volume back, unless someone set the volume meanwhile.
143
161
  * For a short announcement over the music. Not playing, not connected or already at `to` or below: just `fn`.
162
+ * With `dryRun`, `interrupt` and `duck` run `fn`, touch nothing, and say in `why` what they would have done.
144
163
  */
145
- duck<T>(fn: () => Promise<T> | T, options: { to: number }): Promise<Aside<T>>;
164
+ duck<T>(
165
+ fn: () => Promise<T> | T,
166
+ options: { to: number } & SpotifyWriteOptions,
167
+ ): Promise<Aside<T>>;
146
168
  };
147
169
 
148
170
  /** How long a command waits for Soloist's answer. Loopback answers in milliseconds; a stated guess. */
@@ -332,12 +354,34 @@ export function spotify(
332
354
  * Soloist accepted the command that carried it (a merged write's may be another's: a pause merged into a later play never
333
355
  * confirms). A drop ends the wait early, unconfirmed.
334
356
  */
335
- const change = async (slot: string, body: Body): Promise<Applied> => {
357
+ const change = async (
358
+ slot: string,
359
+ body: Body,
360
+ options: SpotifyWriteOptions = {},
361
+ ): Promise<Applied> => {
362
+ if (options.dryRun) return dry(body);
336
363
  const sent = await write(slot, body);
337
364
  const took = () => body.took?.(state, sent.before, sent.at) ?? true;
338
365
  await waitFor(() => took() || !isOpen(), CONFIRM_MS);
339
366
  return { state, confirmed: took() };
340
367
  };
368
+ /** A dry run: `command`'s own checks (connected, signed in), then the commands `send` would send, in order. */
369
+ const dry = (body: Body): Applied => {
370
+ if (!socket || !isOpen() || socket.readyState !== OPEN) throw offline();
371
+ if (state.loggedIn === false)
372
+ throw new ConnectorError(WAITING, { code: "waiting" });
373
+ const one = (command: string, fields?: Record<string, unknown>) =>
374
+ fields ? { command, fields } : { command };
375
+ return {
376
+ state,
377
+ confirmed: false,
378
+ wouldSend: [
379
+ ...(body.activeFirst && inactive() ? [one("activate")] : []),
380
+ one(body.command, body.fields),
381
+ ...(body.then ? [one(body.then.command, body.then.fields)] : []),
382
+ ],
383
+ };
384
+ };
341
385
  /** 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). */
342
386
  const restarted = (s: SpotifyState, b: SpotifyState, at: number) =>
343
387
  s.position !== b.position &&
@@ -349,12 +393,16 @@ export function spotify(
349
393
  new ConnectorError(message, { code: "invalid" });
350
394
  const near = (volume: number | undefined, v: number) =>
351
395
  volume !== undefined && Math.abs(volume - v) <= VOLUME_SLACK;
352
- const volumeTo = (v: number) =>
353
- change("volume", {
354
- command: "set_volume",
355
- fields: { volume: v },
356
- took: (s) => near(s.volume, v),
357
- });
396
+ const volumeTo = (v: number, options?: SpotifyWriteOptions) =>
397
+ change(
398
+ "volume",
399
+ {
400
+ command: "set_volume",
401
+ fields: { volume: v },
402
+ took: (s) => near(s.volume, v),
403
+ },
404
+ options,
405
+ );
358
406
  /** Soloist accepts a `set_volume` while it isn't the active device and drops it (three times live, 2026-11-09). */
359
407
  const inactive = () =>
360
408
  isOpen() && state.loggedIn !== false && state.active === false;
@@ -390,6 +438,17 @@ export function spotify(
390
438
  * one raised before it is reported can't be told from the start's own jump, so it is lowered too.
391
439
  */
392
440
  const applyCap = () => {
441
+ // Lowered, then above the cap again in the same window: Soloist putting its own level back (seen live) or a person.
442
+ // The note says where it is now, not only that it was lowered.
443
+ if (capped && capNote && cap !== undefined && state.volume !== undefined) {
444
+ const base = capNote.replace(/; it is back at \d+$/, "");
445
+ const note =
446
+ state.volume > cap + VOLUME_SLACK
447
+ ? `${base}; it is back at ${state.volume}`
448
+ : base;
449
+ if (note !== capNote) ((capNote = note), publish());
450
+ return;
451
+ }
393
452
  if (
394
453
  cap === undefined ||
395
454
  capped ||
@@ -410,7 +469,7 @@ export function spotify(
410
469
  if (confirmed) {
411
470
  capped = true;
412
471
  if (state.status === "idle") return;
413
- capNote = `started at volume ${from}, above maxVolume ${cap}; lowered it (Soloist issue #23)`;
472
+ capNote = `started at volume ${from}, above maxVolume ${cap}; lowered it to ${cap} (Soloist issue #23)`;
414
473
  note = undefined;
415
474
  return publish();
416
475
  }
@@ -626,6 +685,33 @@ export function spotify(
626
685
  return p;
627
686
  };
628
687
  type Left = Omit<Aside<never>, "value">;
688
+ /** Why `interrupt` would leave the music alone, if it would. */
689
+ const interruptSkip = () =>
690
+ notPlaying() ??
691
+ // "playing" while inactive is the account playing on another speaker (live, 2026-11-10): a pause would stop that one.
692
+ (inactive()
693
+ ? "it is not the active device, so the music playing is on another speaker"
694
+ : undefined);
695
+ /** Why `duck` would leave the volume alone, if it would. */
696
+ const duckSkip = (to: number) =>
697
+ notPlaying() ??
698
+ (inactive()
699
+ ? "it is not the active device, so its volume can't be changed"
700
+ : state.volume === undefined
701
+ ? "its volume is not known"
702
+ : state.volume <= to
703
+ ? `it was already at ${state.volume}`
704
+ : undefined);
705
+ /** A dry `interrupt` or `duck`: `fn` runs, the music is not touched, and `why` says what would have happened to it. */
706
+ const dryAside = async <T>(
707
+ fn: () => Promise<T> | T,
708
+ skip: string | undefined,
709
+ would: string,
710
+ ): Promise<Aside<T>> => ({
711
+ value: await fn(),
712
+ music: "untouched",
713
+ why: `dry run: ${skip ? `${skip}, so the music would be left alone` : `would ${would}`}`,
714
+ });
629
715
  const putBack = (r: Applied, what: string): Left =>
630
716
  r.confirmed
631
717
  ? { music: "restored" }
@@ -635,69 +721,96 @@ export function spotify(
635
721
  };
636
722
 
637
723
  const actions: SpotifyActions = {
638
- async activate() {
724
+ async activate(options) {
639
725
  if (isOpen() && state.loggedIn !== false && state.active === true)
640
- return { state, confirmed: true }; // a second activate might make a new stream
641
- return change("activate", {
642
- command: "activate",
643
- took: (s) => s.active === true,
644
- });
726
+ return {
727
+ state,
728
+ confirmed: true,
729
+ ...(options?.dryRun ? { wouldSend: [] } : {}),
730
+ }; // a second activate might make a new stream
731
+ return change(
732
+ "activate",
733
+ { command: "activate", took: (s) => s.active === true },
734
+ options,
735
+ );
645
736
  },
646
737
  // A `play` while inactive didn't take live; going active first is untested for this. `resume` worked inactive, so it doesn't.
647
- async play(linkOrUri) {
738
+ async play(linkOrUri, options) {
648
739
  if (linkOrUri === undefined)
649
- return change("transport", {
650
- command: "play",
651
- activeFirst: true,
652
- took: (s) => s.status === "playing",
653
- });
740
+ return change(
741
+ "transport",
742
+ {
743
+ command: "play",
744
+ activeFirst: true,
745
+ took: (s) => s.status === "playing",
746
+ },
747
+ options,
748
+ );
654
749
  const uri = toSpotifyUri(linkOrUri);
655
750
  if (!uri) throw invalid(`not a Spotify link or URI: ${linkOrUri}`);
656
- return change("transport", {
657
- command: "play",
658
- fields: { uri },
659
- activeFirst: true,
660
- took: (s) =>
661
- s.status === "playing" &&
662
- (s.track?.uri === uri || s.context?.uri === uri),
663
- });
751
+ return change(
752
+ "transport",
753
+ {
754
+ command: "play",
755
+ fields: { uri },
756
+ activeFirst: true,
757
+ took: (s) =>
758
+ s.status === "playing" &&
759
+ (s.track?.uri === uri || s.context?.uri === uri),
760
+ },
761
+ options,
762
+ );
664
763
  },
665
- pause: () =>
666
- change("transport", {
667
- command: "pause",
668
- took: (s) => s.status === "paused",
669
- }),
670
- resume: () =>
671
- change("transport", {
672
- command: "play",
673
- took: (s) => s.status === "playing",
674
- }),
764
+ pause: (options) =>
765
+ change(
766
+ "transport",
767
+ { command: "pause", took: (s) => s.status === "paused" },
768
+ options,
769
+ ),
770
+ resume: (options) =>
771
+ change(
772
+ "transport",
773
+ { command: "play", took: (s) => s.status === "playing" },
774
+ options,
775
+ ),
675
776
  // The end of the queue goes idle; repeat-track restarts it.
676
- next: () =>
677
- change(`once-${++once}`, {
678
- command: "skip_next",
679
- took: (s, b, at) =>
680
- newTrack(s, b) ||
681
- (s.status === "idle" && b.status !== "idle") ||
682
- restarted(s, b, at),
683
- }),
684
- prev: () =>
685
- change(`once-${++once}`, {
686
- command: "skip_prev",
687
- took: (s, b, at) => newTrack(s, b) || restarted(s, b, at),
688
- }),
689
- async seek(ms) {
777
+ next: (options) =>
778
+ change(
779
+ `once-${++once}`,
780
+ {
781
+ command: "skip_next",
782
+ took: (s, b, at) =>
783
+ newTrack(s, b) ||
784
+ (s.status === "idle" && b.status !== "idle") ||
785
+ restarted(s, b, at),
786
+ },
787
+ options,
788
+ ),
789
+ prev: (options) =>
790
+ change(
791
+ `once-${++once}`,
792
+ {
793
+ command: "skip_prev",
794
+ took: (s, b, at) => newTrack(s, b) || restarted(s, b, at),
795
+ },
796
+ options,
797
+ ),
798
+ async seek(ms, options) {
690
799
  if (!Number.isFinite(ms) || ms < 0)
691
800
  throw invalid(`a position in ms, 0 or more, not ${ms}`);
692
- return change("seek", {
693
- command: "seek",
694
- fields: { position_ms: ms },
695
- took: (s, b) =>
696
- s.position !== b.position &&
697
- Math.abs((s.position?.ms ?? -Infinity) - ms) < 2000,
698
- });
801
+ return change(
802
+ "seek",
803
+ {
804
+ command: "seek",
805
+ fields: { position_ms: ms },
806
+ took: (s, b) =>
807
+ s.position !== b.position &&
808
+ Math.abs((s.position?.ms ?? -Infinity) - ms) < 2000,
809
+ },
810
+ options,
811
+ );
699
812
  },
700
- async setVolume(volume) {
813
+ async setVolume(volume, options) {
701
814
  if (!Number.isFinite(volume) || volume < 0 || volume > 100)
702
815
  throw invalid(`volume is 0–100, not ${volume}`);
703
816
  if (inactive())
@@ -707,48 +820,60 @@ export function spotify(
707
820
  );
708
821
  return volumeTo(
709
822
  Math.round(cap === undefined ? volume : Math.min(volume, cap)),
823
+ options,
710
824
  );
711
825
  },
712
- async queue(track) {
826
+ async queue(track, options) {
713
827
  const uri = toSpotifyUri(track);
714
828
  if (!uri?.startsWith("spotify:track:"))
715
829
  throw invalid(`only a Spotify track can be queued, not ${track}`);
716
- return change(`once-${++once}`, {
717
- command: "add_to_queue",
718
- fields: { uri },
719
- took: (s) => !!s.upNext?.some((t) => t.uri === uri),
720
- });
830
+ return change(
831
+ `once-${++once}`,
832
+ {
833
+ command: "add_to_queue",
834
+ fields: { uri },
835
+ took: (s) => !!s.upNext?.some((t) => t.uri === uri),
836
+ },
837
+ options,
838
+ );
721
839
  },
722
- setShuffle: (on) =>
723
- change("shuffle", {
724
- command: "set_shuffle",
725
- fields: { enabled: on },
726
- took: (s) => s.shuffle === on,
727
- }),
728
- async setRepeat(mode) {
840
+ setShuffle: (on, options) =>
841
+ change(
842
+ "shuffle",
843
+ {
844
+ command: "set_shuffle",
845
+ fields: { enabled: on },
846
+ took: (s) => s.shuffle === on,
847
+ },
848
+ options,
849
+ ),
850
+ async setRepeat(mode, options) {
729
851
  // developer.spotify.com/documentation/soloist/reference/websocket-api: clear the other mode first, then set this one.
730
852
  if (!Object.hasOwn(REPEAT_ORDER, mode))
731
853
  throw invalid(`repeat is off, context or track, not ${mode}`);
732
854
  const [first, second] = REPEAT_ORDER[mode];
733
- return change("repeat", {
734
- command: first[0],
735
- fields: { enabled: first[1] },
736
- then: { command: second[0], fields: { enabled: second[1] } },
737
- took: (s) => s.repeat === mode,
738
- });
855
+ return change(
856
+ "repeat",
857
+ {
858
+ command: first[0],
859
+ fields: { enabled: first[1] },
860
+ then: { command: second[0], fields: { enabled: second[1] } },
861
+ took: (s) => s.repeat === mode,
862
+ },
863
+ options,
864
+ );
739
865
  },
740
- interrupt(fn) {
866
+ interrupt(fn, options) {
867
+ if (options?.dryRun)
868
+ return dryAside(fn, interruptSkip(), "pause the music, then resume it");
741
869
  let track: string | undefined;
742
870
  let shown = false; // the pause showed
743
871
  return aside(
744
872
  "interrupt",
745
873
  fn,
746
874
  async () => {
747
- const no = notPlaying();
875
+ const no = interruptSkip();
748
876
  if (no) return no;
749
- // "playing" while inactive is the account playing on another speaker (live, 2026-11-10): a pause would stop that one.
750
- if (inactive())
751
- return "it is not the active device, so the music playing is on another speaker";
752
877
  track = state.track?.uri;
753
878
  try {
754
879
  shown = (await actions.pause()).confirmed; // a pause that never shows still runs fn: what interrupts matters more
@@ -777,22 +902,24 @@ export function spotify(
777
902
  },
778
903
  );
779
904
  },
780
- duck(fn, { to }) {
905
+ duck(fn, { to, dryRun }) {
781
906
  if (!Number.isFinite(to) || to < 0 || to > 100)
782
907
  return Promise.reject(invalid(`duck's volume is 0–100, not ${to}`));
908
+ if (dryRun)
909
+ return dryAside(
910
+ fn,
911
+ duckSkip(to),
912
+ `lower the volume from ${state.volume} to ${Math.round(to)}, then put it back`,
913
+ );
783
914
  let earlier = 0;
784
915
  let shown = false; // the lowered volume showed
785
916
  return aside(
786
917
  "duck",
787
918
  fn,
788
919
  async () => {
789
- const no = notPlaying();
920
+ const no = duckSkip(to);
790
921
  if (no) return no;
791
- if (inactive())
792
- return "it is not the active device, so its volume can't be changed";
793
- if (state.volume === undefined) return "its volume is not known";
794
- if (state.volume <= to) return `it was already at ${state.volume}`;
795
- earlier = state.volume;
922
+ earlier = state.volume!;
796
923
  try {
797
924
  shown = (await volumeTo(Math.round(to))).confirmed;
798
925
  } catch (error) {
@@ -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 })`, 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.
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 or 403 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.
@@ -6,7 +6,7 @@ import { ConnectorError, fetchTimeout, getJson } from "../../connector.ts";
6
6
 
7
7
  const BASE = "https://api.up.com.au/api/v1";
8
8
 
9
- /** A failed Up call. `status` 401 means the token is wrong or revoked; 429 the rate limit (unpublished: read `X-RateLimit-Remaining`). */
9
+ /** A failed Up call. `status` 401 or 403 (`isRejectedKey`) means the token is wrong or revoked; 429 the rate limit (unpublished: read `X-RateLimit-Remaining`). */
10
10
  export class UpError extends ConnectorError {}
11
11
 
12
12
  const Money = z.looseObject({
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { asOf, type Clock, realClock } from "../../clock.ts";
3
- import { errorText } from "../../connector.ts";
3
+ import { errorText, isRejectedKey } from "../../connector.ts";
4
4
  import { envName, type Integration } from "../../home.ts";
5
5
  import type { Manifest } from "../../manifest.ts";
6
6
  import { poller } from "../../poll.ts";
@@ -288,7 +288,7 @@ export function up(config: UpConfig): Integration<UpSnapshot, "up"> & {
288
288
  });
289
289
  } catch (error) {
290
290
  if (signal.aborted) return; // stopped mid-read: not a failure
291
- if (error instanceof UpError && error.status === 401)
291
+ if (isRejectedKey(error))
292
292
  return update({
293
293
  status: "unavailable",
294
294
  reason: "auth",
@@ -1,5 +1,9 @@
1
1
  import { z } from "zod";
2
- import { ConnectorError, fetchTimeout } from "../../connector.ts";
2
+ import {
3
+ ConnectorError,
4
+ fetchTimeout,
5
+ isRejectedKey,
6
+ } from "../../connector.ts";
3
7
  import { RAIN_INTENSITIES } from "./rain.ts";
4
8
  import { sunDisagrees } from "./sun.ts";
5
9
  import {
@@ -434,7 +438,7 @@ export function google(options: GoogleOptions): WeatherSource {
434
438
  ? error
435
439
  : ({} as { status?: number; why?: string });
436
440
  const said = `HTTP ${status}${why ? ` ${why}` : ""}`;
437
- if (status === 401 || status === 403 || why?.startsWith("API_KEY_"))
441
+ if (isRejectedKey(error) || why?.startsWith("API_KEY_"))
438
442
  return {
439
443
  stop: `google: the key was rejected (${said})`,
440
444
  stopReason: "auth",