@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
@@ -8,6 +8,8 @@ import { getPrinterAttributes, printerUrl } from "./client.js";
8
8
  import { attributesOf } from "./codec.js";
9
9
  import { printerKey, printerText, toState, } from "./state.js";
10
10
  export { printerText, } from "./state.js";
11
+ export { cancelJob, identifyPrinter, printJob, validateJob, } from "./client.js";
12
+ export { mediaPixels, pwgRaster, } from "./raster.js";
11
13
  /** One printer in a home config. Everything the printer reports about itself (model, markers, state) comes live; `host` and `port` are only where it last answered. */
12
14
  export const configSchema = z.looseObject({
13
15
  connector: z.literal("printers"),
@@ -92,6 +94,8 @@ export function printers(config = {}) {
92
94
  name: t.name,
93
95
  model: good.model ?? t.foundModel,
94
96
  host: t.host,
97
+ port: t.port,
98
+ rp: t.rp,
95
99
  room: t.room,
96
100
  ...(offline ? { state: "offline" } : {}),
97
101
  ipps: t.ipps,
@@ -0,0 +1,30 @@
1
+ /** One page: `pixels` is `width × height` bytes, row by row from the top, 0 black to 255 white. */
2
+ export type GreyPage = {
3
+ width: number;
4
+ height: number;
5
+ pixels: Uint8Array;
6
+ };
7
+ /** What every page is printed as: the printer's resolution and the PWG media name and size it lists. */
8
+ export type RasterMedia = {
9
+ /** PWG self-describing name, as the printer lists it in `media-supported`: `iso_a4_210x297mm`. */
10
+ name: string;
11
+ /** dots per inch, one of the printer's `pwg-raster-document-resolution-supported`. */
12
+ dpi: number;
13
+ };
14
+ /** The pixels a page of this media is at its resolution, rounded down as macOS's filters do: A4 at 600 dpi is 4960 × 7015. */
15
+ export declare function mediaPixels(media: RasterMedia): {
16
+ width: number;
17
+ height: number;
18
+ };
19
+ /**
20
+ * Pages as one PWG Raster document, `image/pwg-raster`. Every page must be `width × height` bytes. Pages may come one at
21
+ * a time (a generator), so only the packed document and the current page are held; then `total` is the page count.
22
+ */
23
+ export declare function pwgRaster(pages: Iterable<GreyPage>, media: RasterMedia, total?: number): Uint8Array<ArrayBuffer>;
24
+ /** A PWG Raster document back as pages and their headers' key fields; for tests and for checking a file before it prints. */
25
+ export declare function readPwgRaster(file: Uint8Array): (GreyPage & {
26
+ dpi: number;
27
+ pageSizeName: string;
28
+ colorSpace: number;
29
+ bitsPerPixel: number;
30
+ })[];
@@ -0,0 +1,175 @@
1
+ // PWG Raster (PWG 5102.4), 8-bit grey only: what an AirPrint printer that lists `image/pwg-raster` takes when it lists no
2
+ // PDF. A file is "RaS2", then per page a 1796-byte big-endian header and the page's pixels, compressed line by line:
3
+ // a line-repeat byte (the line appears n + 1 times), then runs: 0 to 127 is one pixel repeated n + 1 times, 129 to 255 is
4
+ // 257 − n pixels as they are. Checked against a page macOS's own filters made for a Brother (test/fixtures/printers).
5
+ const HEADER = 1796;
6
+ const SGRAY = 18; // cupsColorSpace for sgray (PWG 5102.4 table 9)
7
+ const WHITE = 0xffffff; // AlternatePrimary: the white point, as PWG 5102.4 recommends
8
+ /** A PWG media name's size in inches: `iso_a4_210x297mm` is 8.27 × 11.69. */
9
+ function mediaInches(name) {
10
+ const m = /_(\d+(?:\.\d+)?)x(\d+(?:\.\d+)?)(mm|in)$/.exec(name);
11
+ if (!m)
12
+ throw new RangeError(`not a PWG media size: ${name}`);
13
+ const per = m[3] === "mm" ? 1 / 25.4 : 1;
14
+ return { width: Number(m[1]) * per, height: Number(m[2]) * per };
15
+ }
16
+ /** The pixels a page of this media is at its resolution, rounded down as macOS's filters do: A4 at 600 dpi is 4960 × 7015. */
17
+ export function mediaPixels(media) {
18
+ const i = mediaInches(media.name);
19
+ return {
20
+ width: Math.floor(i.width * media.dpi),
21
+ height: Math.floor(i.height * media.dpi),
22
+ };
23
+ }
24
+ function header(page, media, total) {
25
+ const h = new Uint8Array(HEADER);
26
+ const v = new DataView(h.buffer);
27
+ const u32 = (offset, n) => v.setUint32(offset, n);
28
+ const str = (offset, s) => h.set(new TextEncoder().encode(s).subarray(0, 63), offset);
29
+ const pt = mediaPixels({ name: media.name, dpi: 72 }); // PageSize is in points
30
+ str(0, "PwgRaster"); // MediaClass
31
+ u32(276, media.dpi); // HWResolution
32
+ u32(280, media.dpi);
33
+ u32(340, 1); // NumCopies: copies are the job's, not the page's
34
+ u32(352, pt.width); // PageSize, points
35
+ u32(356, pt.height);
36
+ u32(372, page.width); // Width, Height in pixels
37
+ u32(376, page.height);
38
+ u32(384, 8); // BitsPerColor
39
+ u32(388, 8); // BitsPerPixel
40
+ u32(392, page.width); // BytesPerLine
41
+ u32(400, SGRAY); // ColorSpace; ColorOrder (396) 0 is chunky
42
+ u32(420, 1); // NumColors
43
+ u32(452, total); // TotalPageCount
44
+ u32(456, 1); // CrossFeedTransform
45
+ u32(460, 1); // FeedTransform
46
+ u32(480, WHITE); // AlternatePrimary
47
+ str(1732, media.name); // PageSizeName
48
+ return h;
49
+ }
50
+ /** One line's runs, appended to `out`. */
51
+ function packLine(line, out) {
52
+ let i = 0;
53
+ while (i < line.length) {
54
+ let run = 1;
55
+ while (run < 128 && i + run < line.length && line[i + run] === line[i])
56
+ run++;
57
+ if (run > 1 || i + 1 === line.length) {
58
+ out.push(run - 1, line[i]);
59
+ i += run;
60
+ continue;
61
+ }
62
+ // Literal: up to 128 pixels, stopping before two equal pixels that would pack better as a repeat.
63
+ let n = 1;
64
+ while (n < 128 &&
65
+ i + n < line.length &&
66
+ !(i + n + 1 < line.length && line[i + n] === line[i + n + 1]))
67
+ n++;
68
+ if (n === 1) {
69
+ out.push(0, line[i]); // a lone pixel is a repeat of one: 257 − 256 can't be written
70
+ i += 1;
71
+ continue;
72
+ }
73
+ out.push(257 - n);
74
+ for (let k = 0; k < n; k++)
75
+ out.push(line[i + k]);
76
+ i += n;
77
+ }
78
+ }
79
+ /**
80
+ * Pages as one PWG Raster document, `image/pwg-raster`. Every page must be `width × height` bytes. Pages may come one at
81
+ * a time (a generator), so only the packed document and the current page are held; then `total` is the page count.
82
+ */
83
+ export function pwgRaster(pages, media, total = Array.isArray(pages) ? pages.length : 0) {
84
+ const chunks = [new TextEncoder().encode("RaS2")];
85
+ for (const page of pages) {
86
+ if (page.pixels.length !== page.width * page.height)
87
+ throw new RangeError(`a ${page.width} × ${page.height} page needs ${page.width * page.height} bytes, got ${page.pixels.length}`);
88
+ chunks.push(header(page, media, total));
89
+ let y = 0;
90
+ while (y < page.height) {
91
+ const line = page.pixels.subarray(y * page.width, (y + 1) * page.width);
92
+ let repeat = 0;
93
+ while (repeat < 255 &&
94
+ y + repeat + 1 < page.height &&
95
+ equal(line, page.pixels.subarray((y + repeat + 1) * page.width, (y + repeat + 2) * page.width)))
96
+ repeat++;
97
+ const out = [repeat]; // one line at a time: a page as a number[] would be hundreds of MB
98
+ packLine(line, out);
99
+ chunks.push(Uint8Array.from(out));
100
+ y += repeat + 1;
101
+ }
102
+ }
103
+ const size = chunks.reduce((n, c) => n + c.length, 0);
104
+ const file = new Uint8Array(size);
105
+ let at = 0;
106
+ for (const c of chunks)
107
+ (file.set(c, at), (at += c.length));
108
+ return file;
109
+ }
110
+ function equal(a, b) {
111
+ if (a.length !== b.length)
112
+ return false;
113
+ for (let i = 0; i < a.length; i++)
114
+ if (a[i] !== b[i])
115
+ return false;
116
+ return true;
117
+ }
118
+ /** A PWG Raster document back as pages and their headers' key fields; for tests and for checking a file before it prints. */
119
+ export function readPwgRaster(file) {
120
+ const text = new TextDecoder();
121
+ if (text.decode(file.subarray(0, 4)) !== "RaS2")
122
+ throw new Error("pwg raster: no RaS2 sync word");
123
+ const pages = [];
124
+ let at = 4;
125
+ while (at < file.length) {
126
+ const v = new DataView(file.buffer, file.byteOffset + at, HEADER);
127
+ const width = v.getUint32(372);
128
+ const height = v.getUint32(376);
129
+ const bitsPerPixel = v.getUint32(388);
130
+ const bytes = bitsPerPixel / 8;
131
+ const name = file.subarray(at + 1732, at + 1796);
132
+ const pageSizeName = text.decode(name.subarray(0, name.indexOf(0)));
133
+ const page = {
134
+ width,
135
+ height,
136
+ dpi: v.getUint32(276),
137
+ pageSizeName,
138
+ colorSpace: v.getUint32(400),
139
+ bitsPerPixel,
140
+ pixels: new Uint8Array(width * height * bytes),
141
+ };
142
+ at += HEADER;
143
+ const lineBytes = width * bytes;
144
+ let y = 0;
145
+ while (y < height) {
146
+ const repeat = file[at++] + 1;
147
+ const line = new Uint8Array(lineBytes);
148
+ let x = 0;
149
+ while (x < lineBytes) {
150
+ const c = file[at++];
151
+ if (c === 128) {
152
+ line.fill(255, x); // the rest of the line is white
153
+ break;
154
+ }
155
+ if (c < 128) {
156
+ const px = file.subarray(at, at + bytes);
157
+ for (let k = 0; k <= c; k++)
158
+ line.set(px, x + k * bytes);
159
+ at += bytes;
160
+ x += (c + 1) * bytes;
161
+ }
162
+ else {
163
+ const n = (257 - c) * bytes;
164
+ line.set(file.subarray(at, at + n), x);
165
+ at += n;
166
+ x += n;
167
+ }
168
+ }
169
+ for (let k = 0; k < repeat && y < height; k++, y++)
170
+ page.pixels.set(line, y * lineBytes);
171
+ }
172
+ pages.push(page);
173
+ }
174
+ return pages;
175
+ }
@@ -18,6 +18,8 @@ export type Printer = {
18
18
  name: string;
19
19
  model?: string;
20
20
  host?: string;
21
+ port?: number;
22
+ rp?: string;
21
23
  room?: string;
22
24
  state?: PrinterStatus;
23
25
  stateCode?: number;
@@ -41,6 +41,6 @@ export declare function spotifyChanges(before: Part<SpotifyState> | undefined, a
41
41
  /**
42
42
  * The speaker in a few words: `Kitchen speaker: playing "Song" by Band · 35%`, `…: waiting for someone to pick it`, or,
43
43
  * 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).
44
- * `on` names where, when the caller knows (`spotifyElsewhere`): `… is playing on Front room speaker`.
44
+ * `on` names where, when the caller knows (`spotifyElsewhere`): `… is playing on Den speaker`.
45
45
  */
46
46
  export declare function spotifyText(s: SpotifyState, on?: string[]): string;
@@ -87,7 +87,7 @@ export function spotifyChanges(before, after) {
87
87
  /**
88
88
  * The speaker in a few words: `Kitchen speaker: playing "Song" by Band · 35%`, `…: waiting for someone to pick it`, or,
89
89
  * 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).
90
- * `on` names where, when the caller knows (`spotifyElsewhere`): `… is playing on Front room speaker`.
90
+ * `on` names where, when the caller knows (`spotifyElsewhere`): `… is playing on Den speaker`.
91
91
  */
92
92
  export function spotifyText(s, on = []) {
93
93
  const who = s.deviceName ?? "Soloist";
@@ -30,10 +30,23 @@ export type SpotifyConfig = {
30
30
  };
31
31
  /** Soloist's repeat modes. */
32
32
  export type Repeat = "off" | "context" | "track";
33
- /** 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. */
33
+ /** One WebSocket command, as a dry run reports it: Soloist's command name and its fields. */
34
+ export type SpotifyRequest = {
35
+ command: string;
36
+ fields?: Record<string, unknown>;
37
+ };
38
+ /** `dryRun`: run every check a real write runs (connected, signed in, the input), send nothing, and say what would go. */
39
+ export type SpotifyWriteOptions = {
40
+ dryRun?: boolean;
41
+ };
42
+ /**
43
+ * What a write left: the state once Soloist reported the change (`confirmed`), or the state as it stood when `CONFIRM_MS`
44
+ * 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: []`.
45
+ */
34
46
  export type Applied = {
35
47
  state: SpotifyState;
36
48
  confirmed: boolean;
49
+ wouldSend?: SpotifyRequest[];
37
50
  };
38
51
  /**
39
52
  * What happened to the music around `duck` or `interrupt`'s `fn`: `untouched` (it was not playing, not connected, already
@@ -54,36 +67,37 @@ export type Aside<T> = {
54
67
  */
55
68
  export type SpotifyActions = {
56
69
  /** 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. */
57
- activate(): Promise<Applied>;
70
+ activate(options?: SpotifyWriteOptions): Promise<Applied>;
58
71
  /** Play a Spotify link or URI (track, album, playlist, artist, episode, show), or resume what is loaded when none is given. */
59
- play(linkOrUri?: string): Promise<Applied>;
60
- pause(): Promise<Applied>;
61
- resume(): Promise<Applied>;
62
- next(): Promise<Applied>;
63
- prev(): Promise<Applied>;
72
+ play(linkOrUri?: string, options?: SpotifyWriteOptions): Promise<Applied>;
73
+ pause(options?: SpotifyWriteOptions): Promise<Applied>;
74
+ resume(options?: SpotifyWriteOptions): Promise<Applied>;
75
+ next(options?: SpotifyWriteOptions): Promise<Applied>;
76
+ prev(options?: SpotifyWriteOptions): Promise<Applied>;
64
77
  /** Jump to a position in the current track, in ms. */
65
- seek(ms: number): Promise<Applied>;
78
+ seek(ms: number, options?: SpotifyWriteOptions): Promise<Applied>;
66
79
  /** Set the volume 0–100, held to `maxVolume` and rounded to a whole percent: `state.volume` is what Soloist then reports. */
67
- setVolume(volume: number): Promise<Applied>;
80
+ setVolume(volume: number, options?: SpotifyWriteOptions): Promise<Applied>;
68
81
  /** Add a track to the queue (Soloist queues tracks only); confirmed when it shows in `upNext`. */
69
- queue(track: string): Promise<Applied>;
70
- setShuffle(on: boolean): Promise<Applied>;
82
+ queue(track: string, options?: SpotifyWriteOptions): Promise<Applied>;
83
+ setShuffle(on: boolean, options?: SpotifyWriteOptions): Promise<Applied>;
71
84
  /** Repeat off, the context (playlist, album) or the track: two commands, in the order Soloist's docs give, as one write. */
72
- setRepeat(mode: Repeat): Promise<Applied>;
85
+ setRepeat(mode: Repeat, options?: SpotifyWriteOptions): Promise<Applied>;
73
86
  /**
74
87
  * Pause if playing, run `fn`, then resume, unless someone changed it meanwhile (resumed, stopped or skipped it from a
75
88
  * phone): then it is theirs and stays. The resume runs even if `fn` throws. Not playing or not connected: just `fn`.
76
89
  * One `interrupt` or `duck` at a time; a second waits for the first, so never call either from inside `fn` (it would wait
77
90
  * forever). Two announcements back to back belong in one `fn`, or the music comes back between them.
78
91
  */
79
- interrupt<T>(fn: () => Promise<T> | T): Promise<Aside<T>>;
92
+ interrupt<T>(fn: () => Promise<T> | T, options?: SpotifyWriteOptions): Promise<Aside<T>>;
80
93
  /**
81
94
  * Lower the volume to `to` while `fn` runs, then put the earlier volume back, unless someone set the volume meanwhile.
82
95
  * For a short announcement over the music. Not playing, not connected or already at `to` or below: just `fn`.
96
+ * With `dryRun`, `interrupt` and `duck` run `fn`, touch nothing, and say in `why` what they would have done.
83
97
  */
84
98
  duck<T>(fn: () => Promise<T> | T, options: {
85
99
  to: number;
86
- }): Promise<Aside<T>>;
100
+ } & SpotifyWriteOptions): Promise<Aside<T>>;
87
101
  };
88
102
  /**
89
103
  * A Spotify Soloist speaker as one integration: one WebSocket to Soloist, its `auth_state` and `playback_state` on connect,
@@ -183,12 +183,31 @@ export function spotify(config = {}) {
183
183
  * Soloist accepted the command that carried it (a merged write's may be another's: a pause merged into a later play never
184
184
  * confirms). A drop ends the wait early, unconfirmed.
185
185
  */
186
- const change = async (slot, body) => {
186
+ const change = async (slot, body, options = {}) => {
187
+ if (options.dryRun)
188
+ return dry(body);
187
189
  const sent = await write(slot, body);
188
190
  const took = () => body.took?.(state, sent.before, sent.at) ?? true;
189
191
  await waitFor(() => took() || !isOpen(), CONFIRM_MS);
190
192
  return { state, confirmed: took() };
191
193
  };
194
+ /** A dry run: `command`'s own checks (connected, signed in), then the commands `send` would send, in order. */
195
+ const dry = (body) => {
196
+ if (!socket || !isOpen() || socket.readyState !== OPEN)
197
+ throw offline();
198
+ if (state.loggedIn === false)
199
+ throw new ConnectorError(WAITING, { code: "waiting" });
200
+ const one = (command, fields) => fields ? { command, fields } : { command };
201
+ return {
202
+ state,
203
+ confirmed: false,
204
+ wouldSend: [
205
+ ...(body.activeFirst && inactive() ? [one("activate")] : []),
206
+ one(body.command, body.fields),
207
+ ...(body.then ? [one(body.then.command, body.then.fields)] : []),
208
+ ],
209
+ };
210
+ };
192
211
  /** 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). */
193
212
  const restarted = (s, b, at) => s.position !== b.position &&
194
213
  (s.position?.ms ?? Infinity) < 5000 &&
@@ -196,11 +215,11 @@ export function spotify(config = {}) {
196
215
  const newTrack = (s, b) => !!s.track && s.track.uri !== b.track?.uri;
197
216
  const invalid = (message) => new ConnectorError(message, { code: "invalid" });
198
217
  const near = (volume, v) => volume !== undefined && Math.abs(volume - v) <= VOLUME_SLACK;
199
- const volumeTo = (v) => change("volume", {
218
+ const volumeTo = (v, options) => change("volume", {
200
219
  command: "set_volume",
201
220
  fields: { volume: v },
202
221
  took: (s) => near(s.volume, v),
203
- });
222
+ }, options);
204
223
  /** Soloist accepts a `set_volume` while it isn't the active device and drops it (three times live, 2026-11-09). */
205
224
  const inactive = () => isOpen() && state.loggedIn !== false && state.active === false;
206
225
  /** 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. */
@@ -228,6 +247,17 @@ export function spotify(config = {}) {
228
247
  * one raised before it is reported can't be told from the start's own jump, so it is lowered too.
229
248
  */
230
249
  const applyCap = () => {
250
+ // Lowered, then above the cap again in the same window: Soloist putting its own level back (seen live) or a person.
251
+ // The note says where it is now, not only that it was lowered.
252
+ if (capped && capNote && cap !== undefined && state.volume !== undefined) {
253
+ const base = capNote.replace(/; it is back at \d+$/, "");
254
+ const note = state.volume > cap + VOLUME_SLACK
255
+ ? `${base}; it is back at ${state.volume}`
256
+ : base;
257
+ if (note !== capNote)
258
+ ((capNote = note), publish());
259
+ return;
260
+ }
231
261
  if (cap === undefined ||
232
262
  capped ||
233
263
  capping ||
@@ -247,7 +277,7 @@ export function spotify(config = {}) {
247
277
  capped = true;
248
278
  if (state.status === "idle")
249
279
  return;
250
- capNote = `started at volume ${from}, above maxVolume ${cap}; lowered it (Soloist issue #23)`;
280
+ capNote = `started at volume ${from}, above maxVolume ${cap}; lowered it to ${cap} (Soloist issue #23)`;
251
281
  note = undefined;
252
282
  return publish();
253
283
  }
@@ -462,6 +492,27 @@ export function spotify(config = {}) {
462
492
  asides = p.catch(() => { });
463
493
  return p;
464
494
  };
495
+ /** Why `interrupt` would leave the music alone, if it would. */
496
+ const interruptSkip = () => notPlaying() ??
497
+ // "playing" while inactive is the account playing on another speaker (live, 2026-11-10): a pause would stop that one.
498
+ (inactive()
499
+ ? "it is not the active device, so the music playing is on another speaker"
500
+ : undefined);
501
+ /** Why `duck` would leave the volume alone, if it would. */
502
+ const duckSkip = (to) => notPlaying() ??
503
+ (inactive()
504
+ ? "it is not the active device, so its volume can't be changed"
505
+ : state.volume === undefined
506
+ ? "its volume is not known"
507
+ : state.volume <= to
508
+ ? `it was already at ${state.volume}`
509
+ : undefined);
510
+ /** A dry `interrupt` or `duck`: `fn` runs, the music is not touched, and `why` says what would have happened to it. */
511
+ const dryAside = async (fn, skip, would) => ({
512
+ value: await fn(),
513
+ music: "untouched",
514
+ why: `dry run: ${skip ? `${skip}, so the music would be left alone` : `would ${would}`}`,
515
+ });
465
516
  const putBack = (r, what) => r.confirmed
466
517
  ? { music: "restored" }
467
518
  : {
@@ -469,22 +520,23 @@ export function spotify(config = {}) {
469
520
  why: failed(`sent the ${what}; Soloist has not reported it`),
470
521
  };
471
522
  const actions = {
472
- async activate() {
523
+ async activate(options) {
473
524
  if (isOpen() && state.loggedIn !== false && state.active === true)
474
- return { state, confirmed: true }; // a second activate might make a new stream
475
- return change("activate", {
476
- command: "activate",
477
- took: (s) => s.active === true,
478
- });
525
+ return {
526
+ state,
527
+ confirmed: true,
528
+ ...(options?.dryRun ? { wouldSend: [] } : {}),
529
+ }; // a second activate might make a new stream
530
+ return change("activate", { command: "activate", took: (s) => s.active === true }, options);
479
531
  },
480
532
  // A `play` while inactive didn't take live; going active first is untested for this. `resume` worked inactive, so it doesn't.
481
- async play(linkOrUri) {
533
+ async play(linkOrUri, options) {
482
534
  if (linkOrUri === undefined)
483
535
  return change("transport", {
484
536
  command: "play",
485
537
  activeFirst: true,
486
538
  took: (s) => s.status === "playing",
487
- });
539
+ }, options);
488
540
  const uri = toSpotifyUri(linkOrUri);
489
541
  if (!uri)
490
542
  throw invalid(`not a Spotify link or URI: ${linkOrUri}`);
@@ -494,28 +546,22 @@ export function spotify(config = {}) {
494
546
  activeFirst: true,
495
547
  took: (s) => s.status === "playing" &&
496
548
  (s.track?.uri === uri || s.context?.uri === uri),
497
- });
549
+ }, options);
498
550
  },
499
- pause: () => change("transport", {
500
- command: "pause",
501
- took: (s) => s.status === "paused",
502
- }),
503
- resume: () => change("transport", {
504
- command: "play",
505
- took: (s) => s.status === "playing",
506
- }),
551
+ pause: (options) => change("transport", { command: "pause", took: (s) => s.status === "paused" }, options),
552
+ resume: (options) => change("transport", { command: "play", took: (s) => s.status === "playing" }, options),
507
553
  // The end of the queue goes idle; repeat-track restarts it.
508
- next: () => change(`once-${++once}`, {
554
+ next: (options) => change(`once-${++once}`, {
509
555
  command: "skip_next",
510
556
  took: (s, b, at) => newTrack(s, b) ||
511
557
  (s.status === "idle" && b.status !== "idle") ||
512
558
  restarted(s, b, at),
513
- }),
514
- prev: () => change(`once-${++once}`, {
559
+ }, options),
560
+ prev: (options) => change(`once-${++once}`, {
515
561
  command: "skip_prev",
516
562
  took: (s, b, at) => newTrack(s, b) || restarted(s, b, at),
517
- }),
518
- async seek(ms) {
563
+ }, options),
564
+ async seek(ms, options) {
519
565
  if (!Number.isFinite(ms) || ms < 0)
520
566
  throw invalid(`a position in ms, 0 or more, not ${ms}`);
521
567
  return change("seek", {
@@ -523,16 +569,16 @@ export function spotify(config = {}) {
523
569
  fields: { position_ms: ms },
524
570
  took: (s, b) => s.position !== b.position &&
525
571
  Math.abs((s.position?.ms ?? -Infinity) - ms) < 2000,
526
- });
572
+ }, options);
527
573
  },
528
- async setVolume(volume) {
574
+ async setVolume(volume, options) {
529
575
  if (!Number.isFinite(volume) || volume < 0 || volume > 100)
530
576
  throw invalid(`volume is 0–100, not ${volume}`);
531
577
  if (inactive())
532
578
  throw new ConnectorError("the speaker is not the active Spotify device, so Soloist would drop this; activate it first (`home spotify activate`; its volume may jump to the level last saved)", { code: "inactive" });
533
- return volumeTo(Math.round(cap === undefined ? volume : Math.min(volume, cap)));
579
+ return volumeTo(Math.round(cap === undefined ? volume : Math.min(volume, cap)), options);
534
580
  },
535
- async queue(track) {
581
+ async queue(track, options) {
536
582
  const uri = toSpotifyUri(track);
537
583
  if (!uri?.startsWith("spotify:track:"))
538
584
  throw invalid(`only a Spotify track can be queued, not ${track}`);
@@ -540,14 +586,14 @@ export function spotify(config = {}) {
540
586
  command: "add_to_queue",
541
587
  fields: { uri },
542
588
  took: (s) => !!s.upNext?.some((t) => t.uri === uri),
543
- });
589
+ }, options);
544
590
  },
545
- setShuffle: (on) => change("shuffle", {
591
+ setShuffle: (on, options) => change("shuffle", {
546
592
  command: "set_shuffle",
547
593
  fields: { enabled: on },
548
594
  took: (s) => s.shuffle === on,
549
- }),
550
- async setRepeat(mode) {
595
+ }, options),
596
+ async setRepeat(mode, options) {
551
597
  // developer.spotify.com/documentation/soloist/reference/websocket-api: clear the other mode first, then set this one.
552
598
  if (!Object.hasOwn(REPEAT_ORDER, mode))
553
599
  throw invalid(`repeat is off, context or track, not ${mode}`);
@@ -557,18 +603,17 @@ export function spotify(config = {}) {
557
603
  fields: { enabled: first[1] },
558
604
  then: { command: second[0], fields: { enabled: second[1] } },
559
605
  took: (s) => s.repeat === mode,
560
- });
606
+ }, options);
561
607
  },
562
- interrupt(fn) {
608
+ interrupt(fn, options) {
609
+ if (options?.dryRun)
610
+ return dryAside(fn, interruptSkip(), "pause the music, then resume it");
563
611
  let track;
564
612
  let shown = false; // the pause showed
565
613
  return aside("interrupt", fn, async () => {
566
- const no = notPlaying();
614
+ const no = interruptSkip();
567
615
  if (no)
568
616
  return no;
569
- // "playing" while inactive is the account playing on another speaker (live, 2026-11-10): a pause would stop that one.
570
- if (inactive())
571
- return "it is not the active device, so the music playing is on another speaker";
572
617
  track = state.track?.uri;
573
618
  try {
574
619
  shown = (await actions.pause()).confirmed; // a pause that never shows still runs fn: what interrupts matters more
@@ -597,21 +642,17 @@ export function spotify(config = {}) {
597
642
  }
598
643
  });
599
644
  },
600
- duck(fn, { to }) {
645
+ duck(fn, { to, dryRun }) {
601
646
  if (!Number.isFinite(to) || to < 0 || to > 100)
602
647
  return Promise.reject(invalid(`duck's volume is 0–100, not ${to}`));
648
+ if (dryRun)
649
+ return dryAside(fn, duckSkip(to), `lower the volume from ${state.volume} to ${Math.round(to)}, then put it back`);
603
650
  let earlier = 0;
604
651
  let shown = false; // the lowered volume showed
605
652
  return aside("duck", fn, async () => {
606
- const no = notPlaying();
653
+ const no = duckSkip(to);
607
654
  if (no)
608
655
  return no;
609
- if (inactive())
610
- return "it is not the active device, so its volume can't be changed";
611
- if (state.volume === undefined)
612
- return "its volume is not known";
613
- if (state.volume <= to)
614
- return `it was already at ${state.volume}`;
615
656
  earlier = state.volume;
616
657
  try {
617
658
  shown = (await volumeTo(Math.round(to))).confirmed;
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { ConnectorError } from "../../connector.ts";
3
- /** A failed Up call. `status` 401 means the token is wrong or revoked; 429 the rate limit (unpublished: read `X-RateLimit-Remaining`). */
3
+ /** 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`). */
4
4
  export declare class UpError extends ConnectorError {
5
5
  }
6
6
  declare const TransactionResource: z.ZodObject<{
@@ -3,7 +3,7 @@ import { ConnectorError, fetchTimeout, getJson } from "../../connector.js";
3
3
  // Up Bank's API v1 (https://developer.up.com.au/, spec github.com/up-banking/api, v1/openapi.json). Field names checked
4
4
  // against one live read, 2026-10-16. Every field optional so a change degrades a reading rather than throwing.
5
5
  const BASE = "https://api.up.com.au/api/v1";
6
- /** A failed Up call. `status` 401 means the token is wrong or revoked; 429 the rate limit (unpublished: read `X-RateLimit-Remaining`). */
6
+ /** 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`). */
7
7
  export class UpError extends ConnectorError {
8
8
  }
9
9
  const Money = z.looseObject({
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { asOf, realClock } from "../../clock.js";
3
- import { errorText } from "../../connector.js";
3
+ import { errorText, isRejectedKey } from "../../connector.js";
4
4
  import { envName } from "../../home.js";
5
5
  import { poller } from "../../poll.js";
6
6
  import { upClient, UpError, } from "./client.js";
@@ -139,7 +139,7 @@ export function up(config) {
139
139
  catch (error) {
140
140
  if (signal.aborted)
141
141
  return; // stopped mid-read: not a failure
142
- if (error instanceof UpError && error.status === 401)
142
+ if (isRejectedKey(error))
143
143
  return update({
144
144
  status: "unavailable",
145
145
  reason: "auth",
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { ConnectorError, fetchTimeout } from "../../connector.js";
2
+ import { ConnectorError, fetchTimeout, isRejectedKey, } from "../../connector.js";
3
3
  import { RAIN_INTENSITIES } from "./rain.js";
4
4
  import { sunDisagrees } from "./sun.js";
5
5
  import { HOURLY_MINUTES, iso, nextPoll, num, RAIN_HORIZON_MINUTES, USER_AGENT, } from "./source.js";
@@ -360,7 +360,7 @@ export function google(options) {
360
360
  ? error
361
361
  : {};
362
362
  const said = `HTTP ${status}${why ? ` ${why}` : ""}`;
363
- if (status === 401 || status === 403 || why?.startsWith("API_KEY_"))
363
+ if (isRejectedKey(error) || why?.startsWith("API_KEY_"))
364
364
  return {
365
365
  stop: `google: the key was rejected (${said})`,
366
366
  stopReason: "auth",