@khalido/home 0.2.0 → 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.
Files changed (59) hide show
  1. package/CHANGELOG.md +16 -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/enphase/client.d.ts +1 -1
  8. package/dist/connectors/enphase/client.js +1 -1
  9. package/dist/connectors/enphase/index.js +2 -1
  10. package/dist/connectors/hue/events.d.ts +0 -2
  11. package/dist/connectors/hue/events.js +1 -2
  12. package/dist/connectors/hue/index.js +2 -2
  13. package/dist/connectors/printers/client.d.ts +41 -6
  14. package/dist/connectors/printers/client.js +81 -8
  15. package/dist/connectors/printers/codec.d.ts +17 -3
  16. package/dist/connectors/printers/codec.js +32 -8
  17. package/dist/connectors/printers/index.d.ts +2 -0
  18. package/dist/connectors/printers/index.js +4 -0
  19. package/dist/connectors/printers/raster.d.ts +30 -0
  20. package/dist/connectors/printers/raster.js +175 -0
  21. package/dist/connectors/printers/state.d.ts +2 -0
  22. package/dist/connectors/spotify/changes.d.ts +1 -1
  23. package/dist/connectors/spotify/changes.js +1 -1
  24. package/dist/connectors/spotify/index.js +12 -1
  25. package/dist/connectors/up/client.d.ts +1 -1
  26. package/dist/connectors/up/client.js +1 -1
  27. package/dist/connectors/up/index.js +2 -2
  28. package/dist/connectors/weather/google.js +2 -2
  29. package/docs/cli.md +2 -0
  30. package/docs/decisions.md +7 -0
  31. package/docs/ideas.md +2 -0
  32. package/package.json +1 -1
  33. package/skills/draw-home/README.md +2 -0
  34. package/skills/use-home/SKILL.md +1 -1
  35. package/src/cli/enphase.ts +31 -5
  36. package/src/cli/main.ts +8 -3
  37. package/src/cli/printers.ts +254 -3
  38. package/src/connector.ts +5 -0
  39. package/src/connectors/amber/README.md +1 -1
  40. package/src/connectors/amber/client.ts +1 -1
  41. package/src/connectors/amber/index.ts +2 -2
  42. package/src/connectors/enphase/README.md +3 -1
  43. package/src/connectors/enphase/client.ts +1 -1
  44. package/src/connectors/enphase/index.ts +2 -1
  45. package/src/connectors/hue/events.ts +1 -4
  46. package/src/connectors/hue/index.ts +2 -2
  47. package/src/connectors/printers/README.md +9 -4
  48. package/src/connectors/printers/client.ts +186 -12
  49. package/src/connectors/printers/codec.ts +44 -10
  50. package/src/connectors/printers/index.ts +17 -0
  51. package/src/connectors/printers/raster.ts +215 -0
  52. package/src/connectors/printers/state.ts +2 -0
  53. package/src/connectors/spotify/README.md +3 -1
  54. package/src/connectors/spotify/changes.ts +1 -1
  55. package/src/connectors/spotify/index.ts +12 -1
  56. package/src/connectors/up/README.md +1 -1
  57. package/src/connectors/up/client.ts +1 -1
  58. package/src/connectors/up/index.ts +2 -2
  59. package/src/connectors/weather/google.ts +6 -2
@@ -1,9 +1,34 @@
1
1
  // Printers without a config: what `home status`, `home printers` and `home probe` run. Cached addresses first, then
2
- // discovery (AirPrint over mDNS) finds the rest. Read-only; the integration has no writes yet.
3
- import { printers } from "../connectors/printers/index.ts";
2
+ // discovery (AirPrint over mDNS) finds the rest.
3
+ import { spawnSync } from "node:child_process";
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import { basename } from "node:path";
6
+ import {
7
+ cancelJob,
8
+ getPrinterAttributes,
9
+ identifyPrinter,
10
+ type PrinterAddress,
11
+ type PrintJob,
12
+ printJob,
13
+ validateJob,
14
+ } from "../connectors/printers/client.ts";
15
+ import { attributesOf } from "../connectors/printers/codec.ts";
16
+ import {
17
+ printerList,
18
+ printers,
19
+ type PrintersState,
20
+ } from "../connectors/printers/index.ts";
21
+ import {
22
+ type GreyPage,
23
+ mediaPixels,
24
+ pwgRaster,
25
+ type RasterMedia,
26
+ } from "../connectors/printers/raster.ts";
27
+ import { printerKey } from "../connectors/printers/state.ts";
4
28
  import { addressBook } from "../discover.ts";
5
29
  import { FINDERS } from "../finders.ts";
6
- import { addresses } from "./context.ts";
30
+ import { settle } from "../home.ts";
31
+ import { addresses, fail, usage, type Verbs } from "./context.ts";
7
32
 
8
33
  /** Every printer the address cache knows plus whatever discovery finds, read once. */
9
34
  export function printersNow() {
@@ -19,3 +44,229 @@ export function printersNow() {
19
44
  seen: (uuid, a) => book.seen("printers", uuid, a),
20
45
  });
21
46
  }
47
+
48
+ // `home printers identify | print | cancel`: the writes. A printer that lists no PDF gets PWG Raster, rendered here with
49
+ // poppler's pdftoppm (the one tool this needs; `brew install poppler`, `apt install poppler-utils`).
50
+ // Live 2026-11-12 on a Brother HL-L2350DW (Bun 1.4.4): identify, validate and one A4 page.
51
+
52
+ export const PRINTERS_USAGE =
53
+ "usage: home printers identify [printer] | print <file.pdf> [printer] | cancel <job> [printer]";
54
+
55
+ /** One printer by name or UUID, or the only one there is; with its address. */
56
+ async function onePrinter(name: string | undefined) {
57
+ const part = (await settle([printersNow()], { ms: 15_000 })).printers!;
58
+ const all = printerList(
59
+ (part.state as PrintersState | undefined) ?? { printers: {}, asOf: "" },
60
+ );
61
+ const p = name
62
+ ? (all.find((x) => x.uuid === printerKey(name)) ??
63
+ all.find((x) => x.name.toLowerCase() === name.toLowerCase()))
64
+ : all.length === 1
65
+ ? all[0]
66
+ : undefined;
67
+ if (!p)
68
+ return fail(
69
+ name
70
+ ? `no printer named ${name}; \`home printers\` lists them`
71
+ : all.length
72
+ ? `${all.length} printers: name one (${all.map((x) => x.name).join(", ")})`
73
+ : "no printer found; `home discover` looks for them",
74
+ name || !all.length ? "error" : "usage",
75
+ );
76
+ if (p.state === "offline" || !p.host)
77
+ return fail(`${p.name} is offline`, "network");
78
+ const address: PrinterAddress = {
79
+ host: p.host,
80
+ port: p.port,
81
+ rp: p.rp,
82
+ secure: p.ipps,
83
+ };
84
+ return { printer: p, address };
85
+ }
86
+
87
+ /** What the printer takes, asked for once before a print. */
88
+ async function capabilities(address: PrinterAddress) {
89
+ const a = attributesOf(
90
+ await getPrinterAttributes(fetch, address, {
91
+ requested: [
92
+ "document-format-supported",
93
+ "pwg-raster-document-resolution-supported",
94
+ "pwg-raster-document-type-supported",
95
+ "media-ready",
96
+ "media-default",
97
+ ],
98
+ }),
99
+ );
100
+ const strings = (k: string) =>
101
+ (a[k] ?? []).filter((v): v is string => typeof v === "string");
102
+ const dpis = (a["pwg-raster-document-resolution-supported"] ?? []).flatMap(
103
+ (r) =>
104
+ typeof r === "object" && "x" in r && r.units === "dpi" && r.x === r.y
105
+ ? [r.x]
106
+ : [],
107
+ );
108
+ return {
109
+ formats: strings("document-format-supported"),
110
+ rasterTypes: strings("pwg-raster-document-type-supported"),
111
+ dpis,
112
+ media: strings("media-ready")[0] ?? strings("media-default")[0],
113
+ };
114
+ }
115
+
116
+ /**
117
+ * A PDF's pages as 8-bit grey, one at a time, each fitted to the media and centred on white, through pdfinfo and
118
+ * pdftoppm. A page's `/Rotate` is applied, as pdftoppm does; a landscape page is shrunk to fit, not turned.
119
+ */
120
+ function renderPdf(
121
+ file: string,
122
+ media: RasterMedia,
123
+ ): { count: number; pages: Iterable<GreyPage> } {
124
+ const run = (command: string, args: string[]) => {
125
+ const r = spawnSync(command, args, { maxBuffer: 1 << 30 });
126
+ if (r.error)
127
+ fail(
128
+ `${command} is not installed: it renders the PDF for a printer that takes no PDF`,
129
+ "error",
130
+ process.platform === "darwin"
131
+ ? "brew install poppler"
132
+ : "sudo apt install poppler-utils",
133
+ );
134
+ if (r.status !== 0) fail(`${command}: ${r.stderr.toString().trim()}`);
135
+ return r.stdout;
136
+ };
137
+ const info = run("pdfinfo", ["-l", "9999", file]).toString();
138
+ const turned = new Map(
139
+ [...info.matchAll(/^Page\s+(\d+) rot:\s+(\d+)/gm)].map((m) => [
140
+ Number(m[1]),
141
+ Number(m[2]) % 180 === 90,
142
+ ]),
143
+ );
144
+ const sizes = [
145
+ ...info.matchAll(/^Page\s+(\d+) size:\s+([\d.]+) x ([\d.]+) pts/gm),
146
+ ].map((m) =>
147
+ turned.get(Number(m[1]))
148
+ ? { w: Number(m[3]), h: Number(m[2]) }
149
+ : { w: Number(m[2]), h: Number(m[3]) },
150
+ );
151
+ if (!sizes.length) fail(`${file}: pdfinfo found no pages`);
152
+ const out = mediaPixels(media);
153
+ function* pages(): Generator<GreyPage> {
154
+ for (const [i, size] of sizes.entries()) {
155
+ // The largest resolution at which the page fits the media, so nothing is cut off; never above the printer's.
156
+ const r = Math.min(
157
+ media.dpi,
158
+ (out.width / size.w) * 72,
159
+ (out.height / size.h) * 72,
160
+ );
161
+ const pgm = run("pdftoppm", [
162
+ "-gray",
163
+ "-r",
164
+ r.toFixed(3),
165
+ "-f",
166
+ String(i + 1),
167
+ "-l",
168
+ String(i + 1),
169
+ "-singlefile",
170
+ file,
171
+ ]);
172
+ const head = /^P5\s+(\d+)\s+(\d+)\s+255\s/.exec(
173
+ pgm.subarray(0, 64).toString("latin1"),
174
+ );
175
+ if (!head)
176
+ return fail(`pdftoppm: page ${i + 1} is not an 8-bit grey image`);
177
+ const w = Number(head[1]);
178
+ const h = Number(head[2]);
179
+ // A pixel over is rounding; more means the size was misread, and a clipped page must not print.
180
+ if (w > out.width + 1 || h > out.height + 1)
181
+ return fail(
182
+ `page ${i + 1} rendered at ${w} × ${h}, larger than ${media.name} (${out.width} × ${out.height}); nothing sent`,
183
+ );
184
+ const pixels = new Uint8Array(out.width * out.height).fill(255);
185
+ const left = Math.max(0, Math.floor((out.width - w) / 2));
186
+ const top = Math.max(0, Math.floor((out.height - h) / 2));
187
+ const from = pgm.subarray(head[0].length);
188
+ for (let y = 0; y < Math.min(h, out.height); y++)
189
+ pixels.set(
190
+ from.subarray(y * w, y * w + Math.min(w, out.width)),
191
+ (top + y) * out.width + left,
192
+ );
193
+ yield { ...out, pixels };
194
+ }
195
+ }
196
+ return { count: sizes.length, pages: pages() };
197
+ }
198
+
199
+ const STATES: Record<number, string> = {
200
+ 3: "pending",
201
+ 4: "held",
202
+ 5: "printing",
203
+ 6: "stopped",
204
+ 7: "cancelled",
205
+ 8: "aborted",
206
+ 9: "completed",
207
+ };
208
+
209
+ export const printersVerbs: Verbs = {
210
+ async identify([name, extra], o) {
211
+ if (o.dryRun) usage("home printers identify has no dry run");
212
+ if (extra) usage(PRINTERS_USAGE);
213
+ const { printer, address } = await onePrinter(name);
214
+ await identifyPrinter(fetch, address);
215
+ console.log(`${printer.name}: identify sent`);
216
+ },
217
+
218
+ async print([file, name, extra], o) {
219
+ if (!file || extra) return usage(PRINTERS_USAGE);
220
+ if (!/\.pdf$/i.test(file)) return usage(`${file}: only a PDF prints`);
221
+ if (!existsSync(file)) return usage(`${file}: no such file`);
222
+ const { printer, address } = await onePrinter(name);
223
+ const can = await capabilities(address);
224
+ const job: PrintJob = {
225
+ format: "application/pdf",
226
+ name: basename(file),
227
+ media: can.media,
228
+ };
229
+ let document: Uint8Array | undefined;
230
+ let what = `${basename(file)} as PDF`;
231
+ if (!can.formats.includes("application/pdf")) {
232
+ const dpi = can.dpis.includes(600) ? 600 : can.dpis[0];
233
+ if (!can.formats.includes("image/pwg-raster") || !dpi)
234
+ return fail(
235
+ `${printer.name} takes neither PDF nor PWG Raster (it lists ${can.formats.join(", ") || "nothing"})`,
236
+ );
237
+ if (!can.rasterTypes.includes("sgray_8"))
238
+ return fail(
239
+ `${printer.name}'s PWG Raster is ${can.rasterTypes.join(", ")}; only sgray_8 is built`,
240
+ );
241
+ if (!can.media)
242
+ return fail(`${printer.name} reports no media loaded or default`);
243
+ const { count, pages } = renderPdf(file, { name: can.media, dpi });
244
+ document = pwgRaster(pages, { name: can.media, dpi }, count);
245
+ job.format = "image/pwg-raster";
246
+ what = `${count} page${count === 1 ? "" : "s"} of ${can.media} at ${dpi} dpi as PWG Raster (${Math.round(document.length / 1024)} kB)`;
247
+ }
248
+ await validateJob(fetch, address, job); // prints nothing: a dry run checks the job as the printer would
249
+ if (o.dryRun)
250
+ return console.log(
251
+ `dry run, no job sent: the printer accepts it; would print ${what} on ${printer.name}`,
252
+ );
253
+ const done = await printJob(
254
+ fetch,
255
+ address,
256
+ job,
257
+ document ?? readFileSync(file),
258
+ );
259
+ console.log(
260
+ `${printer.name}: job ${done.jobId ?? "?"} ${STATES[done.state ?? 0] ?? "sent"} · ${what}`,
261
+ );
262
+ },
263
+
264
+ async cancel([id, name, extra], o) {
265
+ if (o.dryRun) usage("home printers cancel has no dry run");
266
+ const job = Number(id);
267
+ if (!Number.isInteger(job) || extra) return usage(PRINTERS_USAGE);
268
+ const { printer, address } = await onePrinter(name);
269
+ await cancelJob(fetch, address, job);
270
+ console.log(`${printer.name}: job ${job} cancelled`);
271
+ },
272
+ };
package/src/connector.ts CHANGED
@@ -51,6 +51,11 @@ export class ConnectorError extends Error {
51
51
  }
52
52
  }
53
53
 
54
+ /** A 401 or 403 from a device or service: the key or token is wrong or revoked. Every connector reads it as `reason: "auth"`. */
55
+ export const isRejectedKey = (error: unknown) =>
56
+ error instanceof ConnectorError &&
57
+ (error.status === 401 || error.status === 403);
58
+
54
59
  /** `fetch` that gives up after `timeoutMs`, or as soon as `signal` (a run's) aborts. Returns the response whatever its status. */
55
60
  export function fetchTimeout(
56
61
  fetcher: typeof fetch,
@@ -9,7 +9,7 @@ Amber Electric's live wholesale-linked prices, read through Amber's public API.
9
9
  - `forecast`: the current interval, then each forecast one, both channels on one row (`PriceInterval`). `next=96` asks for more than Amber has, so the reply runs as far as Amber forecasts: on 2026-10-28 at 3 pm, 12 five-minute intervals then 14 half-hours, to 11 pm. Amber's `startTime` is a second past the interval's start, so `start` is taken to the minute.
10
10
  - `estimate: true` means the current interval's price can still move; it is shown as "(estimate)", never as an anomaly.
11
11
  - The Amber line in `home status` shows the descriptor, "(estimate)", a spike other than `none`, and a negative price. `energyNow` combines the prices with Enphase; see Energy below.
12
- - A 401 marks Amber unavailable and stops polling.
12
+ - A 401 or 403 marks Amber unavailable and stops polling.
13
13
 
14
14
  ## Energy
15
15
 
@@ -4,7 +4,7 @@ import { ConnectorError, getJson } from "../../connector.ts";
4
4
  // Amber Electric's public API v1 (https://app.amber.com.au/developers; the published spec is 1.0, there is no v2). Shapes checked against live replies,
5
5
  // 2026-10-13; every field optional so a change degrades a reading rather than throwing.
6
6
 
7
- /** A failed Amber call. `status` 401 means the key is wrong or revoked; 429 means the rate limit (50 per 5 min). */
7
+ /** A failed Amber call. `status` 401 or 403 (`isRejectedKey`) means the key is wrong or revoked; 429 means the rate limit (50 per 5 min). */
8
8
  export class AmberError extends ConnectorError {}
9
9
 
10
10
  /** One 5-minute price interval on one channel. Prices are c/kWh including GST, as a cost: negative feed-in means you are paid. */
@@ -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 { envName, type Integration } from "../../home.ts";
5
5
  import { energyNow } from "../../energy.ts";
@@ -62,7 +62,7 @@ export function amber(
62
62
  update({ status: "connected", anomaly: last.anomaly, state: last });
63
63
  } catch (error) {
64
64
  if (signal.aborted) return; // stopped mid-read: not a failure
65
- if (error instanceof AmberError && error.status === 401)
65
+ if (isRejectedKey(error))
66
66
  return update({
67
67
  status: "unavailable",
68
68
  reason: "auth",
@@ -10,9 +10,11 @@ Reads an IQ Gateway (Envoy) on the LAN: production, consumption and grid flow. `
10
10
  - `host` may be pasted with a scheme or a trailing slash (`https://…/`); `gatewayHost` reduces it to the bare host for requests, discovery and the address cache.
11
11
  - In the home config the device may carry `marker` (the plan's marker where the array is drawn, on the roof outside any room) and `capacityW` (the array's rated watts from the installer's paperwork, never guessed from the gateway). Neither reaches the gateway; they are for drawing.
12
12
  - A meter counts only when `/ivp/meters` says `enabled` **and** its reading has `activeCount > 0`; otherwise production comes from the inverters and Wh today is left out, since pyenphase found the inverter daily total unreliable.
13
- - The token's days left ride in the snapshot; under 30 the status says to run `home enphase login`. A 401 marks Enphase unavailable and stops polling.
13
+ - The token's days left ride in the snapshot; under 30 the status says to run `home enphase login`. A 401 or 403 marks Enphase unavailable and stops polling.
14
14
  - `home enphase login` mints the token (below) and writes `~/.config/home/enphase-token.json` (0600, directory 0700), printing only the days left. `home status` shows solar now, consumption and the token's days left.
15
15
 
16
+ **Live, 2026-11-12 (Bun 1.4.4).** From late October `home enphase` said ECONNREFUSED. The gateway had not changed: DHCP had given it a new address, mDNS and the address cache had the new one, and an `IQ_GATEWAY_HOST` in the shell still named the old one, which the CLI trusted without a fallback. The CLI now takes the serial from the address cache and runs discovery after one failed read, so the status line reads and says "moved from a to b". The owner token was fine throughout (338 days left).
17
+
16
18
  **Live, KO's gateway, 2026-10-13 (D8.3.5528, owner token).** `/info` answers without a token; everything else answers 401 without one. With the token, one GET each:
17
19
 
18
20
  - `/ivp/meters`: `production` and `net-consumption` **enabled**, `meteringStatus normal`; `storage`, `backfeed`, `load`, `evse`, `pv3p` disabled. So both CTs are fitted.
@@ -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
+ );