@khalido/home 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +24 -2
  2. package/dist/connector.d.ts +2 -0
  3. package/dist/connector.js +3 -0
  4. package/dist/connectors/amber/client.d.ts +1 -1
  5. package/dist/connectors/amber/client.js +1 -1
  6. package/dist/connectors/amber/index.js +2 -2
  7. package/dist/connectors/cast/index.d.ts +2 -1
  8. package/dist/connectors/cast/index.js +1 -0
  9. package/dist/connectors/cast/wav.d.ts +31 -0
  10. package/dist/connectors/cast/wav.js +149 -0
  11. package/dist/connectors/cast/writes.d.ts +25 -6
  12. package/dist/connectors/cast/writes.js +155 -62
  13. package/dist/connectors/enphase/client.d.ts +1 -1
  14. package/dist/connectors/enphase/client.js +1 -1
  15. package/dist/connectors/enphase/index.js +2 -1
  16. package/dist/connectors/hue/events.d.ts +0 -2
  17. package/dist/connectors/hue/events.js +1 -2
  18. package/dist/connectors/hue/index.js +2 -2
  19. package/dist/connectors/printers/client.d.ts +41 -6
  20. package/dist/connectors/printers/client.js +81 -8
  21. package/dist/connectors/printers/codec.d.ts +17 -3
  22. package/dist/connectors/printers/codec.js +32 -8
  23. package/dist/connectors/printers/index.d.ts +2 -0
  24. package/dist/connectors/printers/index.js +4 -0
  25. package/dist/connectors/printers/raster.d.ts +30 -0
  26. package/dist/connectors/printers/raster.js +175 -0
  27. package/dist/connectors/printers/state.d.ts +2 -0
  28. package/dist/connectors/spotify/changes.d.ts +1 -1
  29. package/dist/connectors/spotify/changes.js +1 -1
  30. package/dist/connectors/spotify/index.d.ts +28 -14
  31. package/dist/connectors/spotify/index.js +89 -48
  32. package/dist/connectors/up/client.d.ts +1 -1
  33. package/dist/connectors/up/client.js +1 -1
  34. package/dist/connectors/up/index.js +2 -2
  35. package/dist/connectors/weather/google.js +2 -2
  36. package/docs/cast.md +1 -1
  37. package/docs/cli.md +3 -1
  38. package/docs/decisions.md +7 -0
  39. package/docs/ideas.md +2 -0
  40. package/docs/using.md +1 -1
  41. package/llms.txt +2 -2
  42. package/package.json +1 -1
  43. package/skills/draw-home/README.md +2 -0
  44. package/skills/use-home/SKILL.md +1 -1
  45. package/src/cli/cast.ts +41 -11
  46. package/src/cli/context.ts +1 -1
  47. package/src/cli/enphase.ts +31 -5
  48. package/src/cli/main.ts +14 -5
  49. package/src/cli/printers.ts +254 -3
  50. package/src/cli/spotify.ts +16 -12
  51. package/src/connector.ts +5 -0
  52. package/src/connectors/amber/README.md +1 -1
  53. package/src/connectors/amber/client.ts +1 -1
  54. package/src/connectors/amber/index.ts +2 -2
  55. package/src/connectors/cast/README.md +2 -0
  56. package/src/connectors/cast/index.ts +4 -0
  57. package/src/connectors/cast/wav.ts +181 -0
  58. package/src/connectors/cast/writes.ts +211 -71
  59. package/src/connectors/enphase/README.md +3 -1
  60. package/src/connectors/enphase/client.ts +1 -1
  61. package/src/connectors/enphase/index.ts +2 -1
  62. package/src/connectors/hue/events.ts +1 -4
  63. package/src/connectors/hue/index.ts +2 -2
  64. package/src/connectors/printers/README.md +9 -4
  65. package/src/connectors/printers/client.ts +186 -12
  66. package/src/connectors/printers/codec.ts +44 -10
  67. package/src/connectors/printers/index.ts +17 -0
  68. package/src/connectors/printers/raster.ts +215 -0
  69. package/src/connectors/printers/state.ts +2 -0
  70. package/src/connectors/spotify/README.md +5 -1
  71. package/src/connectors/spotify/changes.ts +1 -1
  72. package/src/connectors/spotify/index.ts +233 -106
  73. package/src/connectors/up/README.md +1 -1
  74. package/src/connectors/up/client.ts +1 -1
  75. package/src/connectors/up/index.ts +2 -2
  76. package/src/connectors/weather/google.ts +6 -2
@@ -8,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 instanceof EnphaseError && error.status === 401)
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, isRejectedKey } from "./events.ts";
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). Built read-only; first read of a live printer 2026-11-10 (see Live findings).
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. No writes. The place for them is marked `TODO(writes…)` in `client.ts`.
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
- Everything, until a real printer answers: the request bytes (the codec is checked against RFC 8010's layout and a fixture of invented values, not a printer), the default path `ipp/print`, that `printer-uuid` equals TXT `UUID` on a Brother, that a sleeping printer wakes for the request, what a Brother sends for `marker-levels` and `printer-alert` (the sample uses invented values), Bun's `fetch` on port 631, and the macOS local-network prompt on first use.
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 reads: a `fetch`, an address and a path come in, the printer's attributes go out. No writes here yet.
10
- // TODO(writes, behind KO's hand): Identify-Printer (0x003C, a beep) first, then Print-Job (0x0002) with application/pdf
11
- // where `document-format-supported` lists it. Each is one more request builder in codec.ts and one function here.
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
- * Get-Printer-Attributes over HTTP, through `fetchTimeout`. A non-ok HTTP status or an IPP status of 0x0100 and up throws a
79
- * ConnectorError saying which. Returns the whole response; `attributesOf` gives the printer's attributes.
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
- export async function getPrinterAttributes(
91
+ async function call(
82
92
  fetcher: typeof fetch,
83
93
  address: PrinterAddress,
84
- options: {
85
- requestId?: number;
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: getPrinterAttributesRequest(url.ipp, REQUESTED, options.requestId),
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. Identify-Printer (0x003C) and Print-Job (0x0002) are the writes that come later. */
33
- export const OPERATION = { getPrinterAttributes: 0x000b } as const;
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
- return Uint8Array.from([...out, ...(m.data ?? [])]);
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
- /** The Get-Printer-Attributes request: IPP 2.0, utf-8, English, the printer's `ipp://` URI, and the attributes wanted. */
145
- export function getPrinterAttributesRequest(
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
- requested: string[],
148
- requestId = 1,
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: OPERATION.getPrinterAttributes,
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
- { name: "requested-attributes", tag: TAG.keyword, values: requested },
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;