@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,7 +8,7 @@ import {
|
|
|
8
8
|
type Measurement,
|
|
9
9
|
} from "./schemas.ts";
|
|
10
10
|
|
|
11
|
-
/** A failed Enphase call. `status` 401 means the token is wrong or expired. */
|
|
11
|
+
/** A failed Enphase call. `status` 401 or 403 (`isRejectedKey`) means the token is wrong or expired. */
|
|
12
12
|
export class EnphaseError extends ConnectorError {}
|
|
13
13
|
|
|
14
14
|
/** Solar right now and today. Watts and watt-hours as the gateway reports them, negatives included. */
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type Clock, localDay, realClock } from "../../clock.ts";
|
|
2
|
+
import { isRejectedKey } from "../../connector.ts";
|
|
2
3
|
import { z } from "zod";
|
|
3
4
|
import {
|
|
4
5
|
type DeviceRecord,
|
|
@@ -180,7 +181,7 @@ export function enphase(
|
|
|
180
181
|
});
|
|
181
182
|
} catch (error) {
|
|
182
183
|
if (signal.aborted) return; // stopped mid-read: not a failure
|
|
183
|
-
if (error
|
|
184
|
+
if (isRejectedKey(error))
|
|
184
185
|
return update({
|
|
185
186
|
status: "unavailable",
|
|
186
187
|
reason: "auth",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { type Clock, realClock } from "../../clock.ts";
|
|
3
|
+
import { isRejectedKey } from "../../connector.ts";
|
|
3
4
|
import { HueError, type HueOptions } from "./client.ts";
|
|
4
5
|
import { HueEvent } from "./schemas.ts";
|
|
5
6
|
|
|
@@ -56,10 +57,6 @@ const safeJson = (text: string): unknown => {
|
|
|
56
57
|
}
|
|
57
58
|
};
|
|
58
59
|
|
|
59
|
-
/** A 401 or 403: the key is wrong or revoked. */
|
|
60
|
-
export const isRejectedKey = (error: unknown) =>
|
|
61
|
-
error instanceof HueError && (error.status === 401 || error.status === 403);
|
|
62
|
-
|
|
63
60
|
/** What the stream yields: a (re)connection, a batch of events, or a drop with the wait before the next try. */
|
|
64
61
|
export type StreamItem =
|
|
65
62
|
| { kind: "connected" }
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { asOf, type Clock, realClock } from "../../clock.ts";
|
|
2
|
-
import { errorText } from "../../connector.ts";
|
|
2
|
+
import { errorText, isRejectedKey } from "../../connector.ts";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { type Finder, lostDetail, relocator } from "../../discover.ts";
|
|
5
5
|
import {
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
} from "../../home.ts";
|
|
14
14
|
import type { Manifest } from "../../manifest.ts";
|
|
15
15
|
import { bunPinnedFetch, type HueClient, hueClient } from "./client.ts";
|
|
16
|
-
import { hueEvents
|
|
16
|
+
import { hueEvents } from "./events.ts";
|
|
17
17
|
import type { HueEvent } from "./schemas.ts";
|
|
18
18
|
import {
|
|
19
19
|
type GroupView,
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Printers
|
|
2
2
|
|
|
3
|
-
AirPrint printers, found by mDNS and read over IPP on the local network. `iot_class`: **local_polling** (one Get-Printer-Attributes per printer every 120 s).
|
|
3
|
+
AirPrint printers, found by mDNS and read over IPP on the local network, and a PDF printed on one. `iot_class`: **local_polling** (one Get-Printer-Attributes per printer every 120 s). Reads live since 2026-11-10; a print live on 2026-11-12 (see Live findings).
|
|
4
4
|
|
|
5
|
-
Try it: `home printers`
|
|
5
|
+
Try it: `home printers`, `home printers print page.pdf --dry-run`
|
|
6
6
|
|
|
7
7
|
## Built
|
|
8
8
|
|
|
@@ -12,7 +12,10 @@ Try it: `home printers`
|
|
|
12
12
|
- **Odd values pass through.** A `printer-state` outside 3 to 5 leaves `state` out, keeps `stateCode`, and is named in `anomaly`. A marker level outside 0 to 100 stays as sent and is named (PWG 5100.13 reserves negative levels for "unknown" kinds; read it before treating one as empty). Marker lists of different lengths are named too.
|
|
13
13
|
- **Identity check.** The printer's `printer-uuid` must match the entry's UUID, or the read fails with "the address answers as another printer", so a recycled address never shows one printer's toner under another's name.
|
|
14
14
|
- **Discovery** is one shared scan, as Cast's is, not a relocator per printer (the relocator is one device and one scan each; three printers down would be three `dns-sd` runs). It runs at start, after a failed read at 1, 5 and 15 minutes then hourly (`rescanDelay`), hourly for new printers, and a `refresh()` resets the backoff. A moved printer is found by its UUID and read again at once; `detail` says "not found by discovery" only after a scan looked and missed it (`lostDetail`).
|
|
15
|
-
- `home discover` lists printers and `--write` adds them (`uuid`, `name`, `host`, `port`); `home probe` reads each once.
|
|
15
|
+
- `home discover` lists printers and `--write` adds them (`uuid`, `name`, `host`, `port`); `home probe` reads each once.
|
|
16
|
+
- **Writes** (`client.ts`, exported): `identifyPrinter` (the printer shows or sounds; no paper), `validateJob` (checks a job, prints nothing), `printJob` (attributes and document in one request; resolves the job's id and state) and `cancelJob`. Each takes the printer's address (`host`, `port`, `rp` and `ipps` are in the snapshot) and bytes already in a format the printer lists. A job is `one-sided` unless it says otherwise, so a printer's duplex default never applies. A Print-Job that times out (60 s) is a `timeout` error that says it may still print: never resend it blindly.
|
|
17
|
+
- **PWG Raster** (`raster.ts`): `pwgRaster(pages, media, total?)` packs 8-bit grey pages, an array or one at a time from a generator, into `image/pwg-raster` (PWG 5102.4), for a printer that lists no PDF. `mediaPixels` gives a page's size at a resolution. Only `sgray_8`.
|
|
18
|
+
- **`home printers print <file.pdf> [printer]`**: sends the PDF as is when the printer lists `application/pdf`; otherwise renders each page with poppler's `pdftoppm` at the printer's raster resolution, fitted to the loaded media and centred (a page's `/Rotate` applied; a landscape page shrunk to fit, not turned), then Validate-Job, then Print-Job. One page is held at a time. `--dry-run` renders and has the printer validate the job (Validate-Job prints nothing), and sends no job. `home printers identify [printer]` and `cancel <job> [printer]`. The printer can be left out when there is one.
|
|
16
19
|
|
|
17
20
|
## Protocol
|
|
18
21
|
|
|
@@ -30,10 +33,12 @@ Discovery is mDNS `_ipp._tcp` and `_ipps._tcp` ([Apple's AirPrint TXT keys](http
|
|
|
30
33
|
|
|
31
34
|
## Unverified
|
|
32
35
|
|
|
33
|
-
|
|
36
|
+
`printer-uuid` against TXT `UUID` on a Brother, a sleeping printer waking for a request, what `printer-alert` means on it, and the macOS local-network prompt on first use. For printing: a printer that takes PDF, a document of more than one page, two-sided, a colour printer, and the Node CLI (only Bun has run it).
|
|
34
37
|
|
|
35
38
|
## Live findings
|
|
36
39
|
|
|
40
|
+
**2026-11-12, from a Mac (Bun 1.4.4), Brother HL-L2350DW.** It lists `application/octet-stream`, `image/urf` and `image/pwg-raster`, no PDF; PWG Raster at 600 dpi, `sgray_8` only, `pwg-raster-document-sheet-back` `rotated`; operations include Validate-Job, Cancel-Job and Identify-Printer. Identify-Printer and Validate-Job answered OK. Two one-page jobs printed: first a raster macOS's own filters made (`cupsfilter` with the AirPrint PPD; now `test/fixtures/printers/macos-a4-600.pwg`), then one from `home printers print` and `pwgRaster`, 60 kB for a page of text. Each answered `job-state` 5 (processing), both pages came out right (two lines of text, A4), and the printer was idle again within 30 s.
|
|
41
|
+
|
|
37
42
|
**2026-11-10, from a Pi (Bun 1.4.4, 0.1.2; chota-bot's session).** `home discover` found the Brother by mDNS and `home printers` read it: "Brother HL-L2350DW series: idle · BK 80% · A4 ready". So discovery, the request, the default path, a real `marker-levels` and `media-ready` work on this model. Still unverified from the list above: `printer-uuid` against TXT `UUID`, a sleeping printer waking, `printer-alert`, and the macOS local-network prompt.
|
|
38
43
|
|
|
39
44
|
2026-10-14 (Bun 1.4.3): `home discover` (two passes) and `home printers` found no `_ipp._tcp` or `_ipps._tcp` instance on the network, so the Brother was not advertising (off, asleep, or on another network).
|
|
@@ -3,12 +3,16 @@ import {
|
|
|
3
3
|
attributesOf,
|
|
4
4
|
decodeMessage,
|
|
5
5
|
getPrinterAttributesRequest,
|
|
6
|
+
type IppAttribute,
|
|
6
7
|
type IppMessage,
|
|
8
|
+
OPERATION,
|
|
9
|
+
operationRequest,
|
|
10
|
+
TAG,
|
|
7
11
|
} from "./codec.ts";
|
|
8
12
|
|
|
9
|
-
// Pure
|
|
10
|
-
//
|
|
11
|
-
//
|
|
13
|
+
// Pure calls: a `fetch`, an address and the bytes to send come in, the printer's answer goes out. The reads are
|
|
14
|
+
// Get-Printer-Attributes; the writes Identify-Printer, Validate-Job, Print-Job and Cancel-Job. The document is the caller's,
|
|
15
|
+
// already in a format the printer lists (`document-format-supported`): this package makes no PDF into raster.
|
|
12
16
|
|
|
13
17
|
/** Everything the integration reads from one printer. */
|
|
14
18
|
export const REQUESTED = [
|
|
@@ -74,18 +78,21 @@ const STATUS: Record<number, string> = {
|
|
|
74
78
|
0x0507: "server-error-busy",
|
|
75
79
|
};
|
|
76
80
|
|
|
81
|
+
type CallOptions = {
|
|
82
|
+
requestId?: number;
|
|
83
|
+
signal?: AbortSignal;
|
|
84
|
+
timeoutMs?: number;
|
|
85
|
+
};
|
|
86
|
+
|
|
77
87
|
/**
|
|
78
|
-
*
|
|
79
|
-
* ConnectorError saying which
|
|
88
|
+
* One IPP request over HTTP, through `fetchTimeout`. A non-ok HTTP status or an IPP status of 0x0100 and up throws a
|
|
89
|
+
* ConnectorError saying which (`code: "ipp"`, the IPP status in `status`).
|
|
80
90
|
*/
|
|
81
|
-
|
|
91
|
+
async function call(
|
|
82
92
|
fetcher: typeof fetch,
|
|
83
93
|
address: PrinterAddress,
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
signal?: AbortSignal;
|
|
87
|
-
timeoutMs?: number;
|
|
88
|
-
} = {},
|
|
94
|
+
body: (ippUri: string) => Uint8Array<ArrayBuffer>,
|
|
95
|
+
options: CallOptions = {},
|
|
89
96
|
): Promise<IppMessage> {
|
|
90
97
|
const url = printerUrl(address);
|
|
91
98
|
const res = await fetchTimeout(
|
|
@@ -94,7 +101,7 @@ export async function getPrinterAttributes(
|
|
|
94
101
|
{
|
|
95
102
|
method: "POST",
|
|
96
103
|
headers: { "content-type": "application/ipp", accept: "application/ipp" },
|
|
97
|
-
body:
|
|
104
|
+
body: body(url.ipp),
|
|
98
105
|
},
|
|
99
106
|
options,
|
|
100
107
|
);
|
|
@@ -112,3 +119,170 @@ export async function getPrinterAttributes(
|
|
|
112
119
|
}
|
|
113
120
|
return message;
|
|
114
121
|
}
|
|
122
|
+
|
|
123
|
+
/** Get-Printer-Attributes: the whole response; `attributesOf` gives the printer's attributes. `requested` replaces the poll's list. */
|
|
124
|
+
export const getPrinterAttributes = (
|
|
125
|
+
fetcher: typeof fetch,
|
|
126
|
+
address: PrinterAddress,
|
|
127
|
+
options: CallOptions & { requested?: string[] } = {},
|
|
128
|
+
): Promise<IppMessage> =>
|
|
129
|
+
call(
|
|
130
|
+
fetcher,
|
|
131
|
+
address,
|
|
132
|
+
(uri) =>
|
|
133
|
+
getPrinterAttributesRequest(
|
|
134
|
+
uri,
|
|
135
|
+
options.requested ?? REQUESTED,
|
|
136
|
+
options.requestId,
|
|
137
|
+
),
|
|
138
|
+
options,
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
/** Identify-Printer: the printer shows or sounds that it is the one (PWG 5100.13). No paper. */
|
|
142
|
+
export const identifyPrinter = (
|
|
143
|
+
fetcher: typeof fetch,
|
|
144
|
+
address: PrinterAddress,
|
|
145
|
+
options: CallOptions = {},
|
|
146
|
+
): Promise<unknown> =>
|
|
147
|
+
call(
|
|
148
|
+
fetcher,
|
|
149
|
+
address,
|
|
150
|
+
(uri) =>
|
|
151
|
+
operationRequest(OPERATION.identifyPrinter, uri, {
|
|
152
|
+
requestId: options.requestId,
|
|
153
|
+
}),
|
|
154
|
+
options,
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* What a job says about itself. `format` is the document's MIME type and must be one the printer lists; `media` a PWG
|
|
159
|
+
* media name (`iso_a4_210x297mm`). `sides` defaults to `one-sided`, sent explicitly so a printer's duplex default
|
|
160
|
+
* never applies to a document made for one side.
|
|
161
|
+
*/
|
|
162
|
+
export type PrintJob = {
|
|
163
|
+
format: string;
|
|
164
|
+
name?: string;
|
|
165
|
+
user?: string;
|
|
166
|
+
media?: string;
|
|
167
|
+
sides?: "one-sided" | "two-sided-long-edge" | "two-sided-short-edge";
|
|
168
|
+
copies?: number;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/** A job the printer took: its id, and its state when it answered (RFC 8011: 3 pending, 5 processing, 9 completed). */
|
|
172
|
+
export type PrintedJob = { jobId?: number; state?: number; reasons?: string[] };
|
|
173
|
+
|
|
174
|
+
/** Who asks: RFC 8011 has a client send it on every operation. `home` unless the caller names someone. */
|
|
175
|
+
const userAttribute = (user = "home"): IppAttribute => ({
|
|
176
|
+
name: "requesting-user-name",
|
|
177
|
+
tag: TAG.nameWithoutLanguage,
|
|
178
|
+
values: [user],
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
function jobAttributes(job: PrintJob): {
|
|
182
|
+
attributes: IppAttribute[];
|
|
183
|
+
job: IppAttribute[];
|
|
184
|
+
} {
|
|
185
|
+
const attributes: IppAttribute[] = [
|
|
186
|
+
userAttribute(job.user),
|
|
187
|
+
{
|
|
188
|
+
name: "job-name",
|
|
189
|
+
tag: TAG.nameWithoutLanguage,
|
|
190
|
+
values: [job.name ?? "home"],
|
|
191
|
+
},
|
|
192
|
+
{ name: "document-format", tag: TAG.mimeMediaType, values: [job.format] },
|
|
193
|
+
];
|
|
194
|
+
const attrs: IppAttribute[] = [
|
|
195
|
+
{ name: "sides", tag: TAG.keyword, values: [job.sides ?? "one-sided"] },
|
|
196
|
+
];
|
|
197
|
+
if (job.media)
|
|
198
|
+
attrs.push({ name: "media", tag: TAG.keyword, values: [job.media] });
|
|
199
|
+
if (job.copies !== undefined)
|
|
200
|
+
attrs.push({ name: "copies", tag: TAG.integer, values: [job.copies] });
|
|
201
|
+
return { attributes, job: attrs };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Validate-Job: the printer checks the job's attributes as Print-Job would, and prints nothing. */
|
|
205
|
+
export const validateJob = (
|
|
206
|
+
fetcher: typeof fetch,
|
|
207
|
+
address: PrinterAddress,
|
|
208
|
+
job: PrintJob,
|
|
209
|
+
options: CallOptions = {},
|
|
210
|
+
): Promise<unknown> =>
|
|
211
|
+
call(
|
|
212
|
+
fetcher,
|
|
213
|
+
address,
|
|
214
|
+
(uri) =>
|
|
215
|
+
operationRequest(OPERATION.validateJob, uri, {
|
|
216
|
+
...jobAttributes(job),
|
|
217
|
+
requestId: options.requestId,
|
|
218
|
+
}),
|
|
219
|
+
options,
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
/** No upload waits longer than this: a page of raster is a few hundred kB, and a printer can be slow to take it. */
|
|
223
|
+
export const PRINT_TIMEOUT_MS = 60_000;
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Print-Job: the job's attributes and the document in one request. A timeout leaves it unknown whether the printer took
|
|
227
|
+
* the job, so the error says not to send it again blindly.
|
|
228
|
+
*/
|
|
229
|
+
export async function printJob(
|
|
230
|
+
fetcher: typeof fetch,
|
|
231
|
+
address: PrinterAddress,
|
|
232
|
+
job: PrintJob,
|
|
233
|
+
document: Uint8Array,
|
|
234
|
+
options: CallOptions = {},
|
|
235
|
+
): Promise<PrintedJob> {
|
|
236
|
+
let answer: IppMessage;
|
|
237
|
+
try {
|
|
238
|
+
answer = await call(
|
|
239
|
+
fetcher,
|
|
240
|
+
address,
|
|
241
|
+
(uri) =>
|
|
242
|
+
operationRequest(OPERATION.printJob, uri, {
|
|
243
|
+
...jobAttributes(job),
|
|
244
|
+
data: document,
|
|
245
|
+
requestId: options.requestId,
|
|
246
|
+
}),
|
|
247
|
+
{ timeoutMs: PRINT_TIMEOUT_MS, ...options },
|
|
248
|
+
);
|
|
249
|
+
} catch (error) {
|
|
250
|
+
if (error instanceof Error && error.name === "TimeoutError")
|
|
251
|
+
throw new ConnectorError(
|
|
252
|
+
"the printer did not answer the print job in time; it may still print, so check the printer before sending it again",
|
|
253
|
+
{ code: "timeout", cause: error },
|
|
254
|
+
);
|
|
255
|
+
throw error;
|
|
256
|
+
}
|
|
257
|
+
const a = attributesOf(answer, TAG.job);
|
|
258
|
+
const n = (v: unknown) => (typeof v === "number" ? v : undefined);
|
|
259
|
+
const reasons = a["job-state-reasons"]?.filter(
|
|
260
|
+
(r): r is string => typeof r === "string",
|
|
261
|
+
);
|
|
262
|
+
return {
|
|
263
|
+
jobId: n(a["job-id"]?.[0]),
|
|
264
|
+
state: n(a["job-state"]?.[0]),
|
|
265
|
+
reasons: reasons?.length ? reasons : undefined,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Cancel-Job: stop a job by its id, from Print-Job's answer. */
|
|
270
|
+
export const cancelJob = (
|
|
271
|
+
fetcher: typeof fetch,
|
|
272
|
+
address: PrinterAddress,
|
|
273
|
+
jobId: number,
|
|
274
|
+
options: CallOptions & { user?: string } = {},
|
|
275
|
+
): Promise<unknown> =>
|
|
276
|
+
call(
|
|
277
|
+
fetcher,
|
|
278
|
+
address,
|
|
279
|
+
(uri) =>
|
|
280
|
+
operationRequest(OPERATION.cancelJob, uri, {
|
|
281
|
+
requestId: options.requestId,
|
|
282
|
+
attributes: [
|
|
283
|
+
{ name: "job-id", tag: TAG.integer, values: [jobId] },
|
|
284
|
+
userAttribute(options.user), // printers often let only the job's owner cancel it
|
|
285
|
+
],
|
|
286
|
+
}),
|
|
287
|
+
options,
|
|
288
|
+
);
|
|
@@ -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;
|