@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.
- package/CHANGELOG.md +24 -2
- package/dist/connector.d.ts +2 -0
- package/dist/connector.js +3 -0
- package/dist/connectors/amber/client.d.ts +1 -1
- package/dist/connectors/amber/client.js +1 -1
- package/dist/connectors/amber/index.js +2 -2
- package/dist/connectors/cast/index.d.ts +2 -1
- package/dist/connectors/cast/index.js +1 -0
- package/dist/connectors/cast/wav.d.ts +31 -0
- package/dist/connectors/cast/wav.js +149 -0
- package/dist/connectors/cast/writes.d.ts +25 -6
- package/dist/connectors/cast/writes.js +155 -62
- package/dist/connectors/enphase/client.d.ts +1 -1
- package/dist/connectors/enphase/client.js +1 -1
- package/dist/connectors/enphase/index.js +2 -1
- package/dist/connectors/hue/events.d.ts +0 -2
- package/dist/connectors/hue/events.js +1 -2
- package/dist/connectors/hue/index.js +2 -2
- package/dist/connectors/printers/client.d.ts +41 -6
- package/dist/connectors/printers/client.js +81 -8
- package/dist/connectors/printers/codec.d.ts +17 -3
- package/dist/connectors/printers/codec.js +32 -8
- package/dist/connectors/printers/index.d.ts +2 -0
- package/dist/connectors/printers/index.js +4 -0
- package/dist/connectors/printers/raster.d.ts +30 -0
- package/dist/connectors/printers/raster.js +175 -0
- package/dist/connectors/printers/state.d.ts +2 -0
- package/dist/connectors/spotify/changes.d.ts +1 -1
- package/dist/connectors/spotify/changes.js +1 -1
- package/dist/connectors/spotify/index.d.ts +28 -14
- package/dist/connectors/spotify/index.js +89 -48
- package/dist/connectors/up/client.d.ts +1 -1
- package/dist/connectors/up/client.js +1 -1
- package/dist/connectors/up/index.js +2 -2
- package/dist/connectors/weather/google.js +2 -2
- package/docs/cast.md +1 -1
- package/docs/cli.md +3 -1
- package/docs/decisions.md +7 -0
- package/docs/ideas.md +2 -0
- package/docs/using.md +1 -1
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/skills/draw-home/README.md +2 -0
- package/skills/use-home/SKILL.md +1 -1
- package/src/cli/cast.ts +41 -11
- package/src/cli/context.ts +1 -1
- package/src/cli/enphase.ts +31 -5
- package/src/cli/main.ts +14 -5
- package/src/cli/printers.ts +254 -3
- package/src/cli/spotify.ts +16 -12
- package/src/connector.ts +5 -0
- package/src/connectors/amber/README.md +1 -1
- package/src/connectors/amber/client.ts +1 -1
- package/src/connectors/amber/index.ts +2 -2
- package/src/connectors/cast/README.md +2 -0
- package/src/connectors/cast/index.ts +4 -0
- package/src/connectors/cast/wav.ts +181 -0
- package/src/connectors/cast/writes.ts +211 -71
- package/src/connectors/enphase/README.md +3 -1
- package/src/connectors/enphase/client.ts +1 -1
- package/src/connectors/enphase/index.ts +2 -1
- package/src/connectors/hue/events.ts +1 -4
- package/src/connectors/hue/index.ts +2 -2
- package/src/connectors/printers/README.md +9 -4
- package/src/connectors/printers/client.ts +186 -12
- package/src/connectors/printers/codec.ts +44 -10
- package/src/connectors/printers/index.ts +17 -0
- package/src/connectors/printers/raster.ts +215 -0
- package/src/connectors/printers/state.ts +2 -0
- package/src/connectors/spotify/README.md +5 -1
- package/src/connectors/spotify/changes.ts +1 -1
- package/src/connectors/spotify/index.ts +233 -106
- package/src/connectors/up/README.md +1 -1
- package/src/connectors/up/client.ts +1 -1
- package/src/connectors/up/index.ts +2 -2
- 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
|
+
}
|
|
@@ -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
|
|
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
|
|
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
|
-
/**
|
|
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 {
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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 (
|
|
363
|
+
if (isRejectedKey(error) || why?.startsWith("API_KEY_"))
|
|
364
364
|
return {
|
|
365
365
|
stop: `google: the key was rejected (${said})`,
|
|
366
366
|
stopReason: "auth",
|