@khalido/home 0.1.3 → 0.2.1
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/.env.example +2 -2
- package/CHANGELOG.md +56 -1
- package/README.md +7 -2
- package/dist/config.d.ts +5 -3
- package/dist/config.js +5 -1
- 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 +1 -1
- package/dist/connectors/cast/index.js +1 -1
- package/dist/connectors/cast/state.d.ts +17 -3
- package/dist/connectors/cast/state.js +26 -7
- package/dist/connectors/daikin/client.d.ts +45 -2
- package/dist/connectors/daikin/client.js +67 -2
- package/dist/connectors/daikin/index.d.ts +2 -2
- package/dist/connectors/daikin/index.js +4 -3
- 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/ca.d.ts +2 -0
- package/dist/connectors/hue/ca.js +38 -0
- package/dist/connectors/hue/client.d.ts +81 -6
- package/dist/connectors/hue/client.js +217 -6
- package/dist/connectors/hue/color.d.ts +30 -0
- package/dist/connectors/hue/color.js +92 -0
- package/dist/connectors/hue/events.d.ts +0 -2
- package/dist/connectors/hue/events.js +1 -2
- package/dist/connectors/hue/index.d.ts +15 -4
- package/dist/connectors/hue/index.js +27 -10
- package/dist/connectors/hue/schemas.d.ts +27 -0
- package/dist/connectors/hue/schemas.js +26 -0
- package/dist/connectors/hue/state.d.ts +10 -1
- package/dist/connectors/hue/state.js +20 -1
- 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 +19 -5
- package/dist/connectors/spotify/changes.js +29 -7
- package/dist/connectors/spotify/index.d.ts +1 -1
- package/dist/connectors/spotify/index.js +20 -3
- 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/dist/devices.d.ts +24 -0
- package/dist/devices.js +56 -2
- package/dist/discover.js +1 -1
- package/dist/finders-entry.d.ts +1 -1
- package/dist/finders-entry.js +1 -1
- package/dist/finders.d.ts +2 -1
- package/dist/finders.js +16 -5
- package/dist/home.d.ts +11 -2
- package/dist/home.js +98 -34
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/node-fetch.d.ts +2 -2
- package/dist/node-fetch.js +3 -2
- package/docs/cli.md +15 -7
- package/docs/config.md +11 -3
- package/docs/decisions.md +19 -0
- package/docs/hue.md +161 -0
- package/docs/ideas.md +2 -0
- package/docs/using.md +98 -3
- package/examples/home.jsonc +2 -1
- package/home.schema.json +10 -3
- package/llms.txt +19 -6
- package/package.json +1 -1
- package/skills/draw-home/README.md +2 -0
- package/skills/draw-home/SKILL.md +1 -1
- package/skills/use-home/SKILL.md +3 -3
- package/src/cli/cast.ts +51 -5
- package/src/cli/context.ts +2 -0
- package/src/cli/daikin.ts +71 -0
- package/src/cli/enphase.ts +31 -5
- package/src/cli/hue.ts +147 -59
- package/src/cli/main.ts +43 -13
- package/src/cli/printers.ts +254 -3
- package/src/cli/probe.ts +21 -17
- package/src/cli/status.ts +61 -10
- package/src/config.ts +8 -2
- 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 +6 -3
- package/src/connectors/cast/index.ts +7 -1
- package/src/connectors/cast/state.ts +32 -5
- package/src/connectors/daikin/README.md +16 -4
- package/src/connectors/daikin/client.ts +104 -3
- package/src/connectors/daikin/index.ts +8 -2
- 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/README.md +62 -17
- package/src/connectors/hue/ca.ts +39 -0
- package/src/connectors/hue/client.ts +362 -15
- package/src/connectors/hue/color.ts +123 -0
- package/src/connectors/hue/events.ts +1 -4
- package/src/connectors/hue/index.ts +46 -13
- package/src/connectors/hue/schemas.ts +26 -0
- package/src/connectors/hue/state.ts +27 -1
- 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 +10 -2
- package/src/connectors/spotify/changes.ts +38 -9
- package/src/connectors/spotify/index.ts +21 -2
- 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
- package/src/devices.ts +83 -3
- package/src/discover.ts +1 -2
- package/src/finders-entry.ts +1 -1
- package/src/finders.ts +18 -6
- package/src/home.ts +99 -39
- package/src/index.ts +7 -1
- package/src/node-fetch.ts +6 -2
|
@@ -29,8 +29,14 @@ export const TAG = {
|
|
|
29
29
|
mimeMediaType: 0x49,
|
|
30
30
|
} as const;
|
|
31
31
|
|
|
32
|
-
/** The operation ids this package sends
|
|
33
|
-
export const OPERATION = {
|
|
32
|
+
/** The operation ids this package sends (RFC 8011; Identify-Printer from PWG 5100.13). */
|
|
33
|
+
export const OPERATION = {
|
|
34
|
+
printJob: 0x0002,
|
|
35
|
+
validateJob: 0x0004,
|
|
36
|
+
cancelJob: 0x0008,
|
|
37
|
+
getPrinterAttributes: 0x000b,
|
|
38
|
+
identifyPrinter: 0x003c,
|
|
39
|
+
} as const;
|
|
34
40
|
|
|
35
41
|
const STRING_TAGS: ReadonlySet<number> = new Set([
|
|
36
42
|
TAG.textWithoutLanguage,
|
|
@@ -138,19 +144,30 @@ export function encodeMessage(m: IppMessage): Uint8Array<ArrayBuffer> {
|
|
|
138
144
|
});
|
|
139
145
|
}
|
|
140
146
|
out.push(TAG.end);
|
|
141
|
-
|
|
147
|
+
const bytes = new Uint8Array(out.length + (m.data?.length ?? 0));
|
|
148
|
+
bytes.set(out);
|
|
149
|
+
if (m.data) bytes.set(m.data, out.length);
|
|
150
|
+
return bytes;
|
|
142
151
|
}
|
|
143
152
|
|
|
144
|
-
/**
|
|
145
|
-
|
|
153
|
+
/**
|
|
154
|
+
* A request to one printer: IPP 2.0, utf-8, English, the printer's `ipp://` URI, then `attributes` in the operation
|
|
155
|
+
* group, `job` (when given) in a job group, and `data` (a document) after the end tag.
|
|
156
|
+
*/
|
|
157
|
+
export function operationRequest(
|
|
158
|
+
code: number,
|
|
146
159
|
printerUri: string,
|
|
147
|
-
|
|
148
|
-
|
|
160
|
+
options: {
|
|
161
|
+
attributes?: IppAttribute[];
|
|
162
|
+
job?: IppAttribute[];
|
|
163
|
+
data?: Uint8Array;
|
|
164
|
+
requestId?: number;
|
|
165
|
+
} = {},
|
|
149
166
|
): Uint8Array<ArrayBuffer> {
|
|
150
167
|
return encodeMessage({
|
|
151
168
|
version: [2, 0],
|
|
152
|
-
code
|
|
153
|
-
requestId,
|
|
169
|
+
code,
|
|
170
|
+
requestId: options.requestId ?? 1,
|
|
154
171
|
groups: [
|
|
155
172
|
{
|
|
156
173
|
tag: TAG.operation,
|
|
@@ -162,13 +179,30 @@ export function getPrinterAttributesRequest(
|
|
|
162
179
|
values: ["en"],
|
|
163
180
|
},
|
|
164
181
|
{ name: "printer-uri", tag: TAG.uri, values: [printerUri] },
|
|
165
|
-
|
|
182
|
+
...(options.attributes ?? []),
|
|
166
183
|
],
|
|
167
184
|
},
|
|
185
|
+
...(options.job?.length
|
|
186
|
+
? [{ tag: TAG.job, attributes: options.job }]
|
|
187
|
+
: []),
|
|
168
188
|
],
|
|
189
|
+
data: options.data,
|
|
169
190
|
});
|
|
170
191
|
}
|
|
171
192
|
|
|
193
|
+
/** The Get-Printer-Attributes request: the attributes wanted. */
|
|
194
|
+
export const getPrinterAttributesRequest = (
|
|
195
|
+
printerUri: string,
|
|
196
|
+
requested: string[],
|
|
197
|
+
requestId = 1,
|
|
198
|
+
) =>
|
|
199
|
+
operationRequest(OPERATION.getPrinterAttributes, printerUri, {
|
|
200
|
+
requestId,
|
|
201
|
+
attributes: [
|
|
202
|
+
{ name: "requested-attributes", tag: TAG.keyword, values: requested },
|
|
203
|
+
],
|
|
204
|
+
});
|
|
205
|
+
|
|
172
206
|
const fail = (why: string): never => {
|
|
173
207
|
throw new Error(`ipp: ${why}`);
|
|
174
208
|
};
|
|
@@ -39,6 +39,21 @@ export {
|
|
|
39
39
|
type PrinterStatus,
|
|
40
40
|
printerText,
|
|
41
41
|
} from "./state.ts";
|
|
42
|
+
export {
|
|
43
|
+
cancelJob,
|
|
44
|
+
identifyPrinter,
|
|
45
|
+
type PrintedJob,
|
|
46
|
+
type PrinterAddress,
|
|
47
|
+
type PrintJob,
|
|
48
|
+
printJob,
|
|
49
|
+
validateJob,
|
|
50
|
+
} from "./client.ts";
|
|
51
|
+
export {
|
|
52
|
+
type GreyPage,
|
|
53
|
+
mediaPixels,
|
|
54
|
+
pwgRaster,
|
|
55
|
+
type RasterMedia,
|
|
56
|
+
} from "./raster.ts";
|
|
42
57
|
|
|
43
58
|
/** 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. */
|
|
44
59
|
export const configSchema = z.looseObject({
|
|
@@ -184,6 +199,8 @@ export function printers(
|
|
|
184
199
|
name: t.name,
|
|
185
200
|
model: good.model ?? t.foundModel,
|
|
186
201
|
host: t.host,
|
|
202
|
+
port: t.port,
|
|
203
|
+
rp: t.rp,
|
|
187
204
|
room: t.room,
|
|
188
205
|
...(offline ? { state: "offline" as const } : {}),
|
|
189
206
|
ipps: t.ipps,
|
|
@@ -0,0 +1,215 @@
|
|
|
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
|
+
|
|
6
|
+
/** One page: `pixels` is `width × height` bytes, row by row from the top, 0 black to 255 white. */
|
|
7
|
+
export type GreyPage = {
|
|
8
|
+
width: number;
|
|
9
|
+
height: number;
|
|
10
|
+
pixels: Uint8Array;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** What every page is printed as: the printer's resolution and the PWG media name and size it lists. */
|
|
14
|
+
export type RasterMedia = {
|
|
15
|
+
/** PWG self-describing name, as the printer lists it in `media-supported`: `iso_a4_210x297mm`. */
|
|
16
|
+
name: string;
|
|
17
|
+
/** dots per inch, one of the printer's `pwg-raster-document-resolution-supported`. */
|
|
18
|
+
dpi: number;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const HEADER = 1796;
|
|
22
|
+
const SGRAY = 18; // cupsColorSpace for sgray (PWG 5102.4 table 9)
|
|
23
|
+
const WHITE = 0xffffff; // AlternatePrimary: the white point, as PWG 5102.4 recommends
|
|
24
|
+
|
|
25
|
+
/** A PWG media name's size in inches: `iso_a4_210x297mm` is 8.27 × 11.69. */
|
|
26
|
+
function mediaInches(name: string): { width: number; height: number } {
|
|
27
|
+
const m = /_(\d+(?:\.\d+)?)x(\d+(?:\.\d+)?)(mm|in)$/.exec(name);
|
|
28
|
+
if (!m) throw new RangeError(`not a PWG media size: ${name}`);
|
|
29
|
+
const per = m[3] === "mm" ? 1 / 25.4 : 1;
|
|
30
|
+
return { width: Number(m[1]) * per, height: Number(m[2]) * per };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 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. */
|
|
34
|
+
export function mediaPixels(media: RasterMedia): {
|
|
35
|
+
width: number;
|
|
36
|
+
height: number;
|
|
37
|
+
} {
|
|
38
|
+
const i = mediaInches(media.name);
|
|
39
|
+
return {
|
|
40
|
+
width: Math.floor(i.width * media.dpi),
|
|
41
|
+
height: Math.floor(i.height * media.dpi),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function header(page: GreyPage, media: RasterMedia, total: number) {
|
|
46
|
+
const h = new Uint8Array(HEADER);
|
|
47
|
+
const v = new DataView(h.buffer);
|
|
48
|
+
const u32 = (offset: number, n: number) => v.setUint32(offset, n);
|
|
49
|
+
const str = (offset: number, s: string) =>
|
|
50
|
+
h.set(new TextEncoder().encode(s).subarray(0, 63), offset);
|
|
51
|
+
const pt = mediaPixels({ name: media.name, dpi: 72 }); // PageSize is in points
|
|
52
|
+
str(0, "PwgRaster"); // MediaClass
|
|
53
|
+
u32(276, media.dpi); // HWResolution
|
|
54
|
+
u32(280, media.dpi);
|
|
55
|
+
u32(340, 1); // NumCopies: copies are the job's, not the page's
|
|
56
|
+
u32(352, pt.width); // PageSize, points
|
|
57
|
+
u32(356, pt.height);
|
|
58
|
+
u32(372, page.width); // Width, Height in pixels
|
|
59
|
+
u32(376, page.height);
|
|
60
|
+
u32(384, 8); // BitsPerColor
|
|
61
|
+
u32(388, 8); // BitsPerPixel
|
|
62
|
+
u32(392, page.width); // BytesPerLine
|
|
63
|
+
u32(400, SGRAY); // ColorSpace; ColorOrder (396) 0 is chunky
|
|
64
|
+
u32(420, 1); // NumColors
|
|
65
|
+
u32(452, total); // TotalPageCount
|
|
66
|
+
u32(456, 1); // CrossFeedTransform
|
|
67
|
+
u32(460, 1); // FeedTransform
|
|
68
|
+
u32(480, WHITE); // AlternatePrimary
|
|
69
|
+
str(1732, media.name); // PageSizeName
|
|
70
|
+
return h;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** One line's runs, appended to `out`. */
|
|
74
|
+
function packLine(line: Uint8Array, out: number[]) {
|
|
75
|
+
let i = 0;
|
|
76
|
+
while (i < line.length) {
|
|
77
|
+
let run = 1;
|
|
78
|
+
while (run < 128 && i + run < line.length && line[i + run] === line[i])
|
|
79
|
+
run++;
|
|
80
|
+
if (run > 1 || i + 1 === line.length) {
|
|
81
|
+
out.push(run - 1, line[i]!);
|
|
82
|
+
i += run;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
// Literal: up to 128 pixels, stopping before two equal pixels that would pack better as a repeat.
|
|
86
|
+
let n = 1;
|
|
87
|
+
while (
|
|
88
|
+
n < 128 &&
|
|
89
|
+
i + n < line.length &&
|
|
90
|
+
!(i + n + 1 < line.length && line[i + n] === line[i + n + 1])
|
|
91
|
+
)
|
|
92
|
+
n++;
|
|
93
|
+
if (n === 1) {
|
|
94
|
+
out.push(0, line[i]!); // a lone pixel is a repeat of one: 257 − 256 can't be written
|
|
95
|
+
i += 1;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
out.push(257 - n);
|
|
99
|
+
for (let k = 0; k < n; k++) out.push(line[i + k]!);
|
|
100
|
+
i += n;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Pages as one PWG Raster document, `image/pwg-raster`. Every page must be `width × height` bytes. Pages may come one at
|
|
106
|
+
* a time (a generator), so only the packed document and the current page are held; then `total` is the page count.
|
|
107
|
+
*/
|
|
108
|
+
export function pwgRaster(
|
|
109
|
+
pages: Iterable<GreyPage>,
|
|
110
|
+
media: RasterMedia,
|
|
111
|
+
total = Array.isArray(pages) ? pages.length : 0,
|
|
112
|
+
): Uint8Array<ArrayBuffer> {
|
|
113
|
+
const chunks: Uint8Array[] = [new TextEncoder().encode("RaS2")];
|
|
114
|
+
for (const page of pages) {
|
|
115
|
+
if (page.pixels.length !== page.width * page.height)
|
|
116
|
+
throw new RangeError(
|
|
117
|
+
`a ${page.width} × ${page.height} page needs ${page.width * page.height} bytes, got ${page.pixels.length}`,
|
|
118
|
+
);
|
|
119
|
+
chunks.push(header(page, media, total));
|
|
120
|
+
let y = 0;
|
|
121
|
+
while (y < page.height) {
|
|
122
|
+
const line = page.pixels.subarray(y * page.width, (y + 1) * page.width);
|
|
123
|
+
let repeat = 0;
|
|
124
|
+
while (
|
|
125
|
+
repeat < 255 &&
|
|
126
|
+
y + repeat + 1 < page.height &&
|
|
127
|
+
equal(
|
|
128
|
+
line,
|
|
129
|
+
page.pixels.subarray(
|
|
130
|
+
(y + repeat + 1) * page.width,
|
|
131
|
+
(y + repeat + 2) * page.width,
|
|
132
|
+
),
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
repeat++;
|
|
136
|
+
const out = [repeat]; // one line at a time: a page as a number[] would be hundreds of MB
|
|
137
|
+
packLine(line, out);
|
|
138
|
+
chunks.push(Uint8Array.from(out));
|
|
139
|
+
y += repeat + 1;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const size = chunks.reduce((n, c) => n + c.length, 0);
|
|
143
|
+
const file = new Uint8Array(size);
|
|
144
|
+
let at = 0;
|
|
145
|
+
for (const c of chunks) (file.set(c, at), (at += c.length));
|
|
146
|
+
return file;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function equal(a: Uint8Array, b: Uint8Array) {
|
|
150
|
+
if (a.length !== b.length) return false;
|
|
151
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** A PWG Raster document back as pages and their headers' key fields; for tests and for checking a file before it prints. */
|
|
156
|
+
export function readPwgRaster(file: Uint8Array): (GreyPage & {
|
|
157
|
+
dpi: number;
|
|
158
|
+
pageSizeName: string;
|
|
159
|
+
colorSpace: number;
|
|
160
|
+
bitsPerPixel: number;
|
|
161
|
+
})[] {
|
|
162
|
+
const text = new TextDecoder();
|
|
163
|
+
if (text.decode(file.subarray(0, 4)) !== "RaS2")
|
|
164
|
+
throw new Error("pwg raster: no RaS2 sync word");
|
|
165
|
+
const pages = [];
|
|
166
|
+
let at = 4;
|
|
167
|
+
while (at < file.length) {
|
|
168
|
+
const v = new DataView(file.buffer, file.byteOffset + at, HEADER);
|
|
169
|
+
const width = v.getUint32(372);
|
|
170
|
+
const height = v.getUint32(376);
|
|
171
|
+
const bitsPerPixel = v.getUint32(388);
|
|
172
|
+
const bytes = bitsPerPixel / 8;
|
|
173
|
+
const name = file.subarray(at + 1732, at + 1796);
|
|
174
|
+
const pageSizeName = text.decode(name.subarray(0, name.indexOf(0)));
|
|
175
|
+
const page = {
|
|
176
|
+
width,
|
|
177
|
+
height,
|
|
178
|
+
dpi: v.getUint32(276),
|
|
179
|
+
pageSizeName,
|
|
180
|
+
colorSpace: v.getUint32(400),
|
|
181
|
+
bitsPerPixel,
|
|
182
|
+
pixels: new Uint8Array(width * height * bytes),
|
|
183
|
+
};
|
|
184
|
+
at += HEADER;
|
|
185
|
+
const lineBytes = width * bytes;
|
|
186
|
+
let y = 0;
|
|
187
|
+
while (y < height) {
|
|
188
|
+
const repeat = file[at++]! + 1;
|
|
189
|
+
const line = new Uint8Array(lineBytes);
|
|
190
|
+
let x = 0;
|
|
191
|
+
while (x < lineBytes) {
|
|
192
|
+
const c = file[at++]!;
|
|
193
|
+
if (c === 128) {
|
|
194
|
+
line.fill(255, x); // the rest of the line is white
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
if (c < 128) {
|
|
198
|
+
const px = file.subarray(at, at + bytes);
|
|
199
|
+
for (let k = 0; k <= c; k++) line.set(px, x + k * bytes);
|
|
200
|
+
at += bytes;
|
|
201
|
+
x += (c + 1) * bytes;
|
|
202
|
+
} else {
|
|
203
|
+
const n = (257 - c) * bytes;
|
|
204
|
+
line.set(file.subarray(at, at + n), x);
|
|
205
|
+
at += n;
|
|
206
|
+
x += n;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
for (let k = 0; k < repeat && y < height; k++, y++)
|
|
210
|
+
page.pixels.set(line, y * lineBytes);
|
|
211
|
+
}
|
|
212
|
+
pages.push(page);
|
|
213
|
+
}
|
|
214
|
+
return pages;
|
|
215
|
+
}
|
|
@@ -21,6 +21,8 @@ export type Printer = {
|
|
|
21
21
|
name: string;
|
|
22
22
|
model?: string;
|
|
23
23
|
host?: string;
|
|
24
|
+
port?: number; // where it last answered, with `rp`: what a write needs to reach it
|
|
25
|
+
rp?: string; // the IPP path from discovery's TXT; absent is `ipp/print`
|
|
24
26
|
room?: string;
|
|
25
27
|
state?: PrinterStatus; // absent when the printer sent a code outside 3 to 5; `stateCode` has it
|
|
26
28
|
stateCode?: number;
|
|
@@ -35,7 +35,7 @@ Soloist is Spotify's own headless Spotify Connect client (Linux arm64, armv7, x8
|
|
|
35
35
|
|
|
36
36
|
The restore runs even if `fn` throws: the error is `fn`'s, and a failed restore goes to `detail`. `duck` restores the earlier volume even above `maxVolume`. One aside at a time: never call one inside another's `fn` (it waits forever), and put two announcements in one `fn`. With Cast: `speaker.duck(() => cast.announce("Kitchen", clip), { to: 20 })`. Cast's own `announce` restores what a Cast device was playing; these are for Soloist's output.
|
|
37
37
|
|
|
38
|
-
**Reading it.** `speakerState` takes the Part; `spotifyText` and `positionMs` take its `state`. `speakerState(part)`: `offline`, `connecting`, `waiting`, then Soloist's status (`idle`, `playing`, `paused`, `buffering`, or its own string); signed in with no status yet is `idle`. `spotifyChanges(before, after)`, for `onChange`, gives events in this order: `offline`, `online`, `waiting`, `picked`, `started` (from idle), `resumed` (from paused), `paused`, `stopped`, `track`, `stalled`, `recovered`. Buffering counts as playing. Nothing on the first report, so a restart announces nothing; no volume or queue events. Playback events during an aside carry `own: true`; connection, sign-in and stall events never do. `track` waits for the track's name, which Soloist can send a few seconds after the track (seen live); one that never gets a name never fires. One press on a phone can fire track, resumed, stopped, started and track over 3 s, sometimes with a 0.8 s `waiting`, and the order varies: anything announcing these should wait a few seconds. `state.volume` isn't the level that will play until `active` (it reads `--initial-volume`).
|
|
38
|
+
**Reading it.** `speakerState` takes the Part; `spotifyText` and `positionMs` take its `state`. `speakerState(part)`: `offline`, `connecting`, `waiting`, `elsewhere` (Soloist says playing but isn't the active device: the music is on another Spotify device and this one is silent; `playingElsewhere(state)` is the test), then Soloist's status (`idle`, `playing`, `paused`, `buffering`, or its own string); signed in with no status yet is `idle`. `spotifyChanges(before, after)`, for `onChange`, gives events in this order: `offline`, `online`, `waiting`, `picked`, `started` (from idle), `resumed` (from paused), `paused`, `stopped`, `track`, `stalled`, `recovered`. Buffering counts as playing. Music elsewhere is silence here: leaving is `stopped`, arriving is `started` then `track`, and its tracks, pauses and stops meanwhile fire nothing (a resume there after a pause there fires one `stopped`: paused while inactive reads as Soloist says, so the resume looks like music leaving). Soloist doesn't say where it is; `spotifyElsewhere(snapshot)` from `@khalido/home` names the Cast devices playing that track in Spotify, and `homeDevices` puts them in the speaker's line (matched by name, built snapshots only, untried live), and is empty for a phone. Nothing on the first report, so a restart announces nothing; no volume or queue events. Playback events during an aside carry `own: true`; connection, sign-in and stall events never do. `track` waits for the track's name, which Soloist can send a few seconds after the track (seen live); one that never gets a name never fires. One press on a phone can fire track, resumed, stopped, started and track over 3 s, sometimes with a 0.8 s `waiting`, and the order varies: anything announcing these should wait a few seconds. `state.volume` isn't the level that will play until `active` (it reads `--initial-volume`).
|
|
39
39
|
|
|
40
40
|
**Stalls** ([#5](https://github.com/spotify/soloist/issues/5): playback stops mid-track, often the first after idle, while Soloist still says `playing`). While playing, one `get_state` every 20 s (`stallCheckMs`, code only; 0 turns it off).
|
|
41
41
|
|
|
@@ -58,6 +58,8 @@ Not built: `deactivate`, `available_actions`. An ad (`entity_type: "ad"`) shows
|
|
|
58
58
|
|
|
59
59
|
## Unverified
|
|
60
60
|
|
|
61
|
+
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).
|
|
62
|
+
|
|
61
63
|
Writes through this connector, and what a live read hasn't shown. In particular:
|
|
62
64
|
|
|
63
65
|
- **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.
|
|
@@ -68,12 +70,18 @@ Writes through this connector, and what a live read hasn't shown. In particular:
|
|
|
68
70
|
|
|
69
71
|
## Live findings
|
|
70
72
|
|
|
73
|
+
**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:
|
|
74
|
+
|
|
75
|
+
- 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`.
|
|
76
|
+
- 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".
|
|
77
|
+
- A volume set by hand (35) held through pause and play, so the put-back belongs to activation, not to pause.
|
|
78
|
+
|
|
71
79
|
**2026-11-10, Raspberry Pi 5, Bun 1.4.4, on 0.1.2, through this connector** (chota-bot's session, KO listening).
|
|
72
80
|
|
|
73
81
|
- Confirmed with `maxVolume: 60`, the speaker active and playing: `activate`, `setVolume(50)`, `setVolume(90)` (came back 60), `seek`, `setShuffle`, `setRepeat("context")` and `("off")` (about 2.1 s each), `queue` (first in `upNext`), `play` of a playlist URI. `duck(fn, { to: 20 })` around a Cast announce: 20 inside, `music: "restored"`, 60 after, 5.2 s in all.
|
|
74
82
|
- **The level can jump above the cap on a pause, not only on a start.** One-shot CLI, cap 60: `volume 45` read 45, `play` 45, then `pause` read 62 and the next `play` 62 (62 was the level before the test). Nothing lowered it: a pause is not a start signal, and each CLI command is a new process.
|
|
75
83
|
- **That jump did not repeat, and it is not WirePlumber.** A second pass the same day, the speaker already active: 60, `volume 45`, `pause`, `play`, `volume 60`. Soloist's number and the stream's live level (`wpctl get-volume`) matched at every step (0.60, 0.45, 0.45, 0.45, 0.60), WirePlumber 0.5.8 never wrote a saved entry for the stream in `stream-properties`, and the stream kept its id across pause and play. The run that jumped had just been made active with `activate`, minutes after the account was casting from a phone, which points at the account side. One case each way.
|
|
76
|
-
- **`active: false` with `status: "playing"`:** the account playing on another speaker. `speakerState`
|
|
84
|
+
- **`active: false` with `status: "playing"`:** the account playing on another speaker. `speakerState` said `playing`, `spotifyText` printed it as this speaker's, and `spotifyChanges` fired `resumed`, with the box silent. Since 2026-11-11 all three read it as `elsewhere`, and the stall watch skips it; not yet seen live.
|
|
77
85
|
- Through chota-bot's wrapper, later the same day: `play` of a playlist confirmed in 241 ms, `next` in 134 ms, `pause` in 17 ms; `duck` to 15 and `interrupt`, 2.5 s each, both `restored`. `play()` with the music paused on a Cast speaker: confirmed in 1.3 s, playing on the box at the same position.
|
|
78
86
|
- **`interrupt` paused music that was on another speaker** (found in chota's switch): it checked `playing` and not `active`. Fixed after 0.1.2.
|
|
79
87
|
- `positionMs` around a duck and an interrupt read 2 s, 2 s, 3 s, 5 s, then 4 s after a pause: it can step back a second.
|
|
@@ -14,11 +14,24 @@ export function stallText(s: Stall | undefined): string | undefined {
|
|
|
14
14
|
return s.tried ? `${what}; tried ${s.tried}` : what;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
const PLAYING = new Set(["playing", "buffering"]);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Soloist says playing while it is not the account's active device: the music is on another Spotify device (a phone,
|
|
21
|
+
* another speaker) and this one is silent (live, 2026-11-10). Soloist doesn't say which. Unknown `active` is not elsewhere.
|
|
22
|
+
*/
|
|
23
|
+
export const playingElsewhere = (s: SpotifyState | undefined): boolean =>
|
|
24
|
+
s?.active === false && PLAYING.has(s.status ?? "");
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The speaker in one word, to switch on. `offline`: not reached (or no part); `waiting`: running, nobody has picked it;
|
|
28
|
+
* `elsewhere`: the account's music is playing on another Spotify device, not this one. A status Soloist sends beyond its four passes through.
|
|
29
|
+
*/
|
|
18
30
|
export type SpeakerState =
|
|
19
31
|
| "offline"
|
|
20
32
|
| "connecting"
|
|
21
33
|
| "waiting"
|
|
34
|
+
| "elsewhere"
|
|
22
35
|
| "idle"
|
|
23
36
|
| "playing"
|
|
24
37
|
| "paused"
|
|
@@ -32,6 +45,7 @@ export function speakerState(
|
|
|
32
45
|
return "offline";
|
|
33
46
|
if (part.status === "connecting") return "connecting";
|
|
34
47
|
if (part.state?.loggedIn === false) return "waiting";
|
|
48
|
+
if (playingElsewhere(part.state)) return "elsewhere";
|
|
35
49
|
return part.state?.status ?? "idle"; // signed in, nothing reported yet: nothing is playing
|
|
36
50
|
}
|
|
37
51
|
|
|
@@ -57,12 +71,12 @@ export type SpotifyEvent = { own?: true } & (
|
|
|
57
71
|
| { type: "track"; track: Track }
|
|
58
72
|
);
|
|
59
73
|
|
|
60
|
-
const PLAYING = new Set(["playing", "buffering"]);
|
|
61
|
-
|
|
62
74
|
/**
|
|
63
75
|
* The events between two reports, for `onChange`'s `before` and `after`: `offline` and `online`; `waiting` (signed out) and
|
|
64
76
|
* `picked` (someone picked the speaker); `started` (from idle), `resumed` (from paused), `paused`, `stopped` (to idle), then
|
|
65
|
-
* `track` for a new track, once it has a name, then `stalled` and `recovered` (Soloist issue #5). Buffering counts as playing.
|
|
77
|
+
* `track` for a new track, once it has a name, then `stalled` and `recovered` (Soloist issue #5). Buffering counts as playing.
|
|
78
|
+
* Music on another Spotify device (`playingElsewhere`) is not playing here: it moving away is `stopped`, it arriving is `started`
|
|
79
|
+
* then `track`, and nothing fires for its tracks meanwhile. No `before`, or one
|
|
66
80
|
* with no state (createHome's placeholder before the first report, as at a restart): nothing, since nothing was seen to change. No volume or queue events: read the state.
|
|
67
81
|
*/
|
|
68
82
|
export function spotifyChanges(
|
|
@@ -87,8 +101,11 @@ export function spotifyChanges(
|
|
|
87
101
|
events.push({ type: "waiting" });
|
|
88
102
|
if (b?.loggedIn === false && a?.loggedIn === true)
|
|
89
103
|
events.push({ type: "picked" });
|
|
90
|
-
|
|
91
|
-
const
|
|
104
|
+
// What this speaker itself is doing: music on another device is silence here.
|
|
105
|
+
const here = (s?: SpotifyState) => (playingElsewhere(s) ? "idle" : s?.status);
|
|
106
|
+
const from = here(b);
|
|
107
|
+
// Still inactive after playing elsewhere: a pause or stop there is the other device's, not this one's.
|
|
108
|
+
const to = playingElsewhere(b) && a?.active === false ? "idle" : here(a);
|
|
92
109
|
const playingNow = to !== undefined && PLAYING.has(to);
|
|
93
110
|
const playingThen = from !== undefined && PLAYING.has(from);
|
|
94
111
|
if (playingNow && !playingThen)
|
|
@@ -98,7 +115,13 @@ export function spotifyChanges(
|
|
|
98
115
|
add({ type: "stopped" });
|
|
99
116
|
// Soloist's first report of a new track can have no name yet (seen live): the event waits for it.
|
|
100
117
|
const named = (p?: SpotifyState) => !!p?.track?.name;
|
|
101
|
-
|
|
118
|
+
const heard = playingElsewhere(b) && playingNow ? undefined : b; // a track that was on another device is new here once it plays here
|
|
119
|
+
if (
|
|
120
|
+
a?.track &&
|
|
121
|
+
named(a) &&
|
|
122
|
+
!playingElsewhere(a) &&
|
|
123
|
+
(a.track.uri !== heard?.track?.uri || !named(heard))
|
|
124
|
+
)
|
|
102
125
|
add({ type: "track", track: a.track });
|
|
103
126
|
if (a?.stalled && !b?.stalled)
|
|
104
127
|
events.push({ type: "stalled", stall: a.stalled });
|
|
@@ -112,14 +135,20 @@ export function spotifyChanges(
|
|
|
112
135
|
return events;
|
|
113
136
|
}
|
|
114
137
|
|
|
115
|
-
/**
|
|
116
|
-
|
|
138
|
+
/**
|
|
139
|
+
* The speaker in a few words: `Kitchen speaker: playing "Song" by Band · 35%`, `…: waiting for someone to pick it`, or,
|
|
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 Den speaker`.
|
|
142
|
+
*/
|
|
143
|
+
export function spotifyText(s: SpotifyState, on: string[] = []): string {
|
|
117
144
|
const who = s.deviceName ?? "Soloist";
|
|
118
145
|
if (s.loggedIn === false) return `${who}: waiting for someone to pick it`;
|
|
119
146
|
const t = s.track;
|
|
120
147
|
const what = t
|
|
121
148
|
? `${t.name ? ` "${t.name}"` : ` ${t.uri}`}${t.artists.length ? ` by ${t.artists.join(", ")}` : ""}`
|
|
122
149
|
: "";
|
|
150
|
+
if (playingElsewhere(s))
|
|
151
|
+
return `${who}: silent ·${what || " music"} is playing on ${on.length ? on.join(", ") : "another Spotify device"}`;
|
|
123
152
|
const parts = [
|
|
124
153
|
`${s.status ?? "status unknown"}${what}`,
|
|
125
154
|
s.volume === undefined ? "" : `${s.volume}%`,
|
|
@@ -43,6 +43,7 @@ export {
|
|
|
43
43
|
} from "./client.ts";
|
|
44
44
|
export {
|
|
45
45
|
type SpeakerState,
|
|
46
|
+
playingElsewhere,
|
|
46
47
|
speakerState,
|
|
47
48
|
type SpotifyEvent,
|
|
48
49
|
spotifyChanges,
|
|
@@ -389,6 +390,17 @@ export function spotify(
|
|
|
389
390
|
* one raised before it is reported can't be told from the start's own jump, so it is lowered too.
|
|
390
391
|
*/
|
|
391
392
|
const applyCap = () => {
|
|
393
|
+
// Lowered, then above the cap again in the same window: Soloist putting its own level back (seen live) or a person.
|
|
394
|
+
// The note says where it is now, not only that it was lowered.
|
|
395
|
+
if (capped && capNote && cap !== undefined && state.volume !== undefined) {
|
|
396
|
+
const base = capNote.replace(/; it is back at \d+$/, "");
|
|
397
|
+
const note =
|
|
398
|
+
state.volume > cap + VOLUME_SLACK
|
|
399
|
+
? `${base}; it is back at ${state.volume}`
|
|
400
|
+
: base;
|
|
401
|
+
if (note !== capNote) ((capNote = note), publish());
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
392
404
|
if (
|
|
393
405
|
cap === undefined ||
|
|
394
406
|
capped ||
|
|
@@ -409,7 +421,7 @@ export function spotify(
|
|
|
409
421
|
if (confirmed) {
|
|
410
422
|
capped = true;
|
|
411
423
|
if (state.status === "idle") return;
|
|
412
|
-
capNote = `started at volume ${from}, above maxVolume ${cap}; lowered it (Soloist issue #23)`;
|
|
424
|
+
capNote = `started at volume ${from}, above maxVolume ${cap}; lowered it to ${cap} (Soloist issue #23)`;
|
|
413
425
|
note = undefined;
|
|
414
426
|
return publish();
|
|
415
427
|
}
|
|
@@ -836,6 +848,7 @@ export function spotify(
|
|
|
836
848
|
!st ||
|
|
837
849
|
state.aside === "repair" ||
|
|
838
850
|
(state.track?.uri === st.track &&
|
|
851
|
+
state.active !== false && // moved to another device: no longer this speaker's stall
|
|
839
852
|
(state.status === "playing" || state.status === "buffering"))
|
|
840
853
|
)
|
|
841
854
|
return;
|
|
@@ -870,7 +883,12 @@ export function spotify(
|
|
|
870
883
|
a.track === b.track &&
|
|
871
884
|
b.atMs - a.atMs >= gap &&
|
|
872
885
|
Math.abs(b.ms - a.ms) < 1000;
|
|
873
|
-
|
|
886
|
+
// Not while inactive: "playing" then is another device's music, and a repair's seek or pause would land on it.
|
|
887
|
+
const playing = () =>
|
|
888
|
+
isOpen() &&
|
|
889
|
+
state.status === "playing" &&
|
|
890
|
+
state.active !== false &&
|
|
891
|
+
!state.aside;
|
|
874
892
|
|
|
875
893
|
/** One look while playing: a fresh position, then frozen, overrun, recovered or fine (Soloist issue #5). */
|
|
876
894
|
const checkStall = async () => {
|
|
@@ -916,6 +934,7 @@ export function spotify(
|
|
|
916
934
|
const ours = () =>
|
|
917
935
|
isOpen() &&
|
|
918
936
|
state.status === "playing" &&
|
|
937
|
+
state.active !== false &&
|
|
919
938
|
state.track?.uri === track &&
|
|
920
939
|
state.stalled?.track === track &&
|
|
921
940
|
(!state.aside || state.aside === "repair");
|
|
@@ -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
|
|
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 {
|
|
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 (
|
|
441
|
+
if (isRejectedKey(error) || why?.startsWith("API_KEY_"))
|
|
438
442
|
return {
|
|
439
443
|
stop: `google: the key was rejected (${said})`,
|
|
440
444
|
stopReason: "auth",
|