@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
@@ -1,5 +1,6 @@
1
1
  import { errorText } from "../../connector.js";
2
2
  import { writeLane } from "../../lane.js";
3
+ import { CAST_WAV, castWav, isWav, wavText } from "./wav.js";
3
4
  import { NS } from "./codec.js";
4
5
  import { MediaStatus, MultizoneStatus, ReceiverStatus, } from "./schemas.js";
5
6
  import { castKey, DEFAULT_MEDIA_RECEIVER, findCast, } from "./state.js";
@@ -221,22 +222,51 @@ export function castActions(ctx) {
221
222
  .get(id)
222
223
  .send("level" in volume ? "level" : "muted", volume, (v) => receiver(live(find(target)), { type: "SET_VOLUME", volume: v }));
223
224
  };
225
+ const toReceiver = (d, payload) => ({ device: d.name, channel: "receiver", payload });
226
+ /** A dry SET_VOLUME: the target found and connected, nothing sent. */
227
+ const dryVolume = (target, volume) => {
228
+ const d = find(target);
229
+ live(d);
230
+ return {
231
+ wouldSend: [toReceiver(d, { type: "SET_VOLUME", volume })],
232
+ };
233
+ };
234
+ /** Each WAV clip made here, and the format it came from, so a dry run can say what was converted. */
235
+ const fromWav = new WeakMap();
236
+ /** WAV bytes as 48 kHz 16-bit stereo, which audio-only speakers play (`wav.ts`); any other clip as it is. */
237
+ const playable = (clip) => {
238
+ if ("url" in clip || !isWav(clip.type) || fromWav.has(clip.bytes))
239
+ return clip;
240
+ const r = castWav(clip.bytes);
241
+ if (r.converted)
242
+ fromWav.set(r.bytes, r.from);
243
+ return { bytes: r.bytes, type: "audio/wav" };
244
+ };
224
245
  const actions = {
225
- async setVolume(target, level) {
246
+ async setVolume(target, level, options) {
226
247
  checkLevel(level);
248
+ if (options?.dryRun)
249
+ return dryVolume(target, { level });
227
250
  await setVolume(target, { level });
228
251
  },
229
- async setMuted(target, muted) {
252
+ async setMuted(target, muted, options) {
253
+ if (options?.dryRun)
254
+ return dryVolume(target, { muted });
230
255
  await setVolume(target, { muted });
231
256
  },
232
- async stop(target) {
233
- const s = live(find(target));
257
+ async stop(target, options) {
258
+ const d = find(target);
259
+ const s = live(d);
234
260
  const app = (await receiver(s)).applications?.[0];
235
- if (app && !app.isIdleScreen)
236
- await receiver(s, {
237
- type: "STOP",
238
- ...(app.sessionId ? { sessionId: app.sessionId } : {}),
239
- });
261
+ const stop = {
262
+ type: "STOP",
263
+ ...(app?.sessionId ? { sessionId: app.sessionId } : {}),
264
+ };
265
+ const running = app && !app.isIdleScreen;
266
+ if (options?.dryRun)
267
+ return { wouldSend: running ? [toReceiver(d, stop)] : [] };
268
+ if (running)
269
+ await receiver(s, stop);
240
270
  },
241
271
  /**
242
272
  * Read the target's state, set the volume if asked and unmute a muted target, launch the Default Media Receiver, play the clip to its end, then
@@ -244,11 +274,13 @@ export function castActions(ctx) {
244
274
  * device gets STOP, then the volume (a group's own first, then member by member through each member's own socket). Another app
245
275
  * (Spotify, YouTube) is interrupted and cannot be resumed from here; the result says so. Throws only before anything changed.
246
276
  */
247
- async announce(target, clip, options = {}) {
277
+ async announce(target, given, options = {}) {
248
278
  if (options.volume !== undefined)
249
279
  checkLevel(options.volume);
250
- if (!("url" in clip) && !ctx.serve)
280
+ if (!("url" in given) && !ctx.serve)
251
281
  throw new Error("a clip of bytes needs serve: serveClip from @khalido/home/cast/serve");
282
+ const clip = playable(given); // a bad WAV throws here, before anything changed
283
+ const wavFrom = "url" in clip ? undefined : fromWav.get(clip.bytes);
252
284
  let d = find(target);
253
285
  const s = d.kind === "group" ? await regroup(d) : live(d);
254
286
  d = ctx.devices()[d.id] ?? d; // a group's host as discovery gives it now, for serving the clip on its network
@@ -266,6 +298,8 @@ export function castActions(ctx) {
266
298
  restored: [],
267
299
  notRestored: [],
268
300
  };
301
+ if (options.dryRun)
302
+ return dryAnnounce();
269
303
  const serving = new AbortController();
270
304
  let served;
271
305
  let volumeSet = false;
@@ -311,6 +345,52 @@ export function castActions(ctx) {
311
345
  await restoreVolume();
312
346
  }
313
347
  return result;
348
+ /** What the announce would send, from the reads above; what it could not put back goes in `notRestored`. */
349
+ function dryAnnounce() {
350
+ const to = (device, payload) => ({
351
+ device: device.name,
352
+ channel: "receiver",
353
+ payload,
354
+ });
355
+ const media = (payload) => ({
356
+ device: d.name,
357
+ channel: "media",
358
+ payload,
359
+ });
360
+ const sends = [];
361
+ if (options.volume !== undefined)
362
+ sends.push(to(d, { type: "SET_VOLUME", volume: { level: options.volume } }));
363
+ if (unmute)
364
+ sends.push(to(d, { type: "SET_VOLUME", volume: { muted: false } }));
365
+ sends.push(to(d, { type: "LAUNCH", appId: DEFAULT_MEDIA_RECEIVER }), media({
366
+ type: "LOAD",
367
+ media: {
368
+ contentId: "url" in clip
369
+ ? clip.url
370
+ : `(${clip.bytes.length} bytes of ${clip.type}${wavFrom ? `, converted from ${wavText(wavFrom)} to ${wavText(CAST_WAV)}` : ""}, served on the LAN)`,
371
+ contentType: clip.type ?? "audio/mpeg",
372
+ streamType: "BUFFERED",
373
+ },
374
+ autoplay: true,
375
+ }));
376
+ if (priorMedia)
377
+ sends.push(media({ type: "LOAD", ...priorMedia }));
378
+ else {
379
+ sends.push(to(d, { type: "STOP" }));
380
+ if (!idleApp(prior) && prior?.appId !== DEFAULT_MEDIA_RECEIVER)
381
+ result.notRestored.push({
382
+ name: d.name,
383
+ why: `${prior?.displayName ?? prior?.appId ?? "an app"} would be interrupted and cannot be resumed from here`,
384
+ });
385
+ }
386
+ for (const step of volumeSteps())
387
+ if ("gap" in step)
388
+ result.notRestored.push({ name: step.name, why: step.gap });
389
+ else
390
+ for (const p of step.payloads)
391
+ sends.push(to(step, p));
392
+ return { ...result, why: "dry run", wouldSend: sends };
393
+ }
314
394
  async function restoreApp() {
315
395
  try {
316
396
  // A LAUNCH that timed out may still have started: look before deciding nothing of ours runs.
@@ -346,78 +426,91 @@ export function castActions(ctx) {
346
426
  });
347
427
  }
348
428
  }
349
- async function restoreVolume() {
350
- const each = d.kind === "group"
351
- ? members
352
- : [
353
- {
354
- id: d.id,
355
- name: d.name,
356
- level: before.volume?.level,
357
- muted: before.volume?.muted,
358
- },
359
- ];
360
- // A group keeps a level of its own, which its members' levels don't bring back (live, 2026-11-10: it stayed at the
361
- // announcement's 30 with every member restored). First, so whatever it does to the members, their own levels land last.
429
+ /**
430
+ * The volume put back after the clip, as steps: a group's own level first (it keeps one of its own, which its members'
431
+ * levels don't bring back: live, 2026-11-10, it stayed at the announcement's 30 with every member restored), so
432
+ * whatever it does to the members, their own levels land last; then each member's, or the target's, through its own
433
+ * socket. A step it can't take is a gap with the reason. `restoreVolume` takes them; a dry run reports them.
434
+ */
435
+ function volumeSteps() {
436
+ const steps = [];
362
437
  if (d.kind === "group" && options.volume !== undefined) {
363
438
  const level = before.volume?.level;
364
- if (level === undefined)
365
- result.notRestored.push({
439
+ steps.push(level === undefined
440
+ ? { name: d.name, gap: "its volume before was not reported" }
441
+ : {
366
442
  name: d.name,
367
- why: "its volume before was not reported",
443
+ socket: s,
444
+ payloads: [{ type: "SET_VOLUME", volume: { level } }],
445
+ label: `${d.name}: volume`,
368
446
  });
369
- else
370
- try {
371
- await receiver(s, { type: "SET_VOLUME", volume: { level } });
372
- result.restored.push(`${d.name}: volume`);
373
- }
374
- catch (error) {
375
- result.notRestored.push({
376
- name: d.name,
377
- why: `volume not restored: ${errorText(error)}`,
378
- });
379
- }
380
447
  }
448
+ const each = d.kind === "group"
449
+ ? members
450
+ : options.volume !== undefined || unmute
451
+ ? [
452
+ {
453
+ id: d.id,
454
+ name: d.name,
455
+ level: before.volume?.level,
456
+ muted: before.volume?.muted,
457
+ },
458
+ ]
459
+ : [];
381
460
  for (const m of each) {
382
461
  const ms = m.id === d.id ? s : ctx.session(m.id);
383
462
  if (m.level === undefined)
384
- result.notRestored.push({
463
+ steps.push({
385
464
  name: m.name,
386
- why: "its volume before was not reported",
465
+ gap: "its volume before was not reported",
387
466
  });
388
467
  else if (!ms)
389
- result.notRestored.push({
468
+ steps.push({
390
469
  name: m.name,
391
- why: "not connected, so its volume stays as the announcement set it",
470
+ gap: "not connected, so its volume stays as the announcement set it",
392
471
  });
393
472
  else
394
- try {
395
- await receiver(ms, {
396
- type: "SET_VOLUME",
397
- volume: { level: m.level },
398
- });
399
- if (m.muted)
400
- await receiver(ms, {
401
- type: "SET_VOLUME",
402
- volume: { muted: true },
403
- });
404
- result.restored.push(`${m.name}: volume${m.muted ? ", muted" : ""}`);
405
- }
406
- catch (error) {
407
- result.notRestored.push({
408
- name: m.name,
409
- why: `volume not restored: ${errorText(error)}`,
410
- });
411
- }
473
+ steps.push({
474
+ name: m.name,
475
+ socket: ms,
476
+ payloads: [
477
+ { type: "SET_VOLUME", volume: { level: m.level } },
478
+ ...(m.muted
479
+ ? [{ type: "SET_VOLUME", volume: { muted: true } }]
480
+ : []),
481
+ ],
482
+ label: `${m.name}: volume${m.muted ? ", muted" : ""}`,
483
+ });
484
+ }
485
+ return steps;
486
+ }
487
+ async function restoreVolume() {
488
+ for (const step of volumeSteps()) {
489
+ if ("gap" in step) {
490
+ result.notRestored.push({ name: step.name, why: step.gap });
491
+ continue;
492
+ }
493
+ try {
494
+ for (const p of step.payloads)
495
+ await receiver(step.socket, p);
496
+ result.restored.push(step.label);
497
+ }
498
+ catch (error) {
499
+ result.notRestored.push({
500
+ name: step.name,
501
+ why: `volume not restored: ${errorText(error)}`,
502
+ });
503
+ }
412
504
  }
413
505
  }
414
506
  },
415
- async announceAll(targets, clip, options = {}) {
507
+ async announceAll(targets, given, options = {}) {
416
508
  // A caller's mistake is one error, not a failure per speaker.
417
509
  if (options.volume !== undefined)
418
510
  checkLevel(options.volume);
419
- if (!("url" in clip) && !ctx.serve)
511
+ if (!("url" in given) && !ctx.serve)
420
512
  throw new Error("a clip of bytes needs serve: serveClip from @khalido/home/cast/serve");
513
+ const clip = playable(given); // converted once, for every target
421
514
  const id = (t) => {
422
515
  try {
423
516
  return find(t).id;
@@ -1,5 +1,5 @@
1
1
  import { ConnectorError } from "../../connector.ts";
2
- /** A failed Enphase call. `status` 401 means the token is wrong or expired. */
2
+ /** A failed Enphase call. `status` 401 or 403 (`isRejectedKey`) means the token is wrong or expired. */
3
3
  export declare class EnphaseError extends ConnectorError {
4
4
  }
5
5
  /** Solar right now and today. Watts and watt-hours as the gateway reports them, negatives included. */
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { ConnectorError, fetchTimeout, getJson } from "../../connector.js";
3
3
  import { Login, Meters, Production, Readings, } from "./schemas.js";
4
- /** A failed Enphase call. `status` 401 means the token is wrong or expired. */
4
+ /** A failed Enphase call. `status` 401 or 403 (`isRejectedKey`) means the token is wrong or expired. */
5
5
  export class EnphaseError extends ConnectorError {
6
6
  }
7
7
  /** A daily counter equal to the lifetime one is the software 8 bug seen on KO's gateway (D8.3.5528): not today's energy. */
@@ -1,4 +1,5 @@
1
1
  import { localDay, realClock } from "../../clock.js";
2
+ import { isRejectedKey } from "../../connector.js";
2
3
  import { z } from "zod";
3
4
  import { deviceRecords, filePath, } from "../../home.js";
4
5
  import { lostDetail, relocator } from "../../discover.js";
@@ -113,7 +114,7 @@ export function enphase(config) {
113
114
  catch (error) {
114
115
  if (signal.aborted)
115
116
  return; // stopped mid-read: not a failure
116
- if (error instanceof EnphaseError && error.status === 401)
117
+ if (isRejectedKey(error))
117
118
  return update({
118
119
  status: "unavailable",
119
120
  reason: "auth",
@@ -7,8 +7,6 @@ export type SseMessage = {
7
7
  };
8
8
  /** Split an SSE byte stream into messages, one per blank-line-separated block. Comment lines (`:`) are skipped. */
9
9
  export declare function parseSse(body: ReadableStream<Uint8Array>): AsyncGenerator<SseMessage>;
10
- /** A 401 or 403: the key is wrong or revoked. */
11
- export declare const isRejectedKey: (error: unknown) => boolean;
12
10
  /** What the stream yields: a (re)connection, a batch of events, or a drop with the wait before the next try. */
13
11
  export type StreamItem = {
14
12
  kind: "connected";
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { realClock } from "../../clock.js";
3
+ import { isRejectedKey } from "../../connector.js";
3
4
  import { HueError } from "./client.js";
4
5
  import { HueEvent } from "./schemas.js";
5
6
  /** Split an SSE byte stream into messages, one per blank-line-separated block. Comment lines (`:`) are skipped. */
@@ -50,8 +51,6 @@ const safeJson = (text) => {
50
51
  return undefined;
51
52
  }
52
53
  };
53
- /** A 401 or 403: the key is wrong or revoked. */
54
- export const isRejectedKey = (error) => error instanceof HueError && (error.status === 401 || error.status === 403);
55
54
  /**
56
55
  * A connection with no bytes for this long is reconnected: a silent half-close otherwise looks connected forever (aiohue
57
56
  * uses 90 s). The bridge sends nothing on its own, so a quiet house trips it too; the reconnect is a silent probe.
@@ -1,10 +1,10 @@
1
1
  import { asOf, realClock } from "../../clock.js";
2
- import { errorText } from "../../connector.js";
2
+ import { errorText, isRejectedKey } from "../../connector.js";
3
3
  import { z } from "zod";
4
4
  import { lostDetail, relocator } from "../../discover.js";
5
5
  import { envName, filePath, deviceRecords, } from "../../home.js";
6
6
  import { bunPinnedFetch, hueClient } from "./client.js";
7
- import { hueEvents, isRejectedKey } from "./events.js";
7
+ import { hueEvents } from "./events.js";
8
8
  import { groups, lights, reduce, snapshotFrom, } from "./state.js";
9
9
  export { HUE_BRIDGE_ROOT_CA } from "./ca.js";
10
10
  export { colorToXy, kelvinToMirek, mirekToKelvin, xyToHex, } from "./color.js";
@@ -17,12 +17,47 @@ export declare function printerUrl(a: PrinterAddress): {
17
17
  secure: boolean;
18
18
  path: string;
19
19
  };
20
- /**
21
- * Get-Printer-Attributes over HTTP, through `fetchTimeout`. A non-ok HTTP status or an IPP status of 0x0100 and up throws a
22
- * ConnectorError saying which. Returns the whole response; `attributesOf` gives the printer's attributes.
23
- */
24
- export declare function getPrinterAttributes(fetcher: typeof fetch, address: PrinterAddress, options?: {
20
+ type CallOptions = {
25
21
  requestId?: number;
26
22
  signal?: AbortSignal;
27
23
  timeoutMs?: number;
28
- }): Promise<IppMessage>;
24
+ };
25
+ /** Get-Printer-Attributes: the whole response; `attributesOf` gives the printer's attributes. `requested` replaces the poll's list. */
26
+ export declare const getPrinterAttributes: (fetcher: typeof fetch, address: PrinterAddress, options?: CallOptions & {
27
+ requested?: string[];
28
+ }) => Promise<IppMessage>;
29
+ /** Identify-Printer: the printer shows or sounds that it is the one (PWG 5100.13). No paper. */
30
+ export declare const identifyPrinter: (fetcher: typeof fetch, address: PrinterAddress, options?: CallOptions) => Promise<unknown>;
31
+ /**
32
+ * What a job says about itself. `format` is the document's MIME type and must be one the printer lists; `media` a PWG
33
+ * media name (`iso_a4_210x297mm`). `sides` defaults to `one-sided`, sent explicitly so a printer's duplex default
34
+ * never applies to a document made for one side.
35
+ */
36
+ export type PrintJob = {
37
+ format: string;
38
+ name?: string;
39
+ user?: string;
40
+ media?: string;
41
+ sides?: "one-sided" | "two-sided-long-edge" | "two-sided-short-edge";
42
+ copies?: number;
43
+ };
44
+ /** A job the printer took: its id, and its state when it answered (RFC 8011: 3 pending, 5 processing, 9 completed). */
45
+ export type PrintedJob = {
46
+ jobId?: number;
47
+ state?: number;
48
+ reasons?: string[];
49
+ };
50
+ /** Validate-Job: the printer checks the job's attributes as Print-Job would, and prints nothing. */
51
+ export declare const validateJob: (fetcher: typeof fetch, address: PrinterAddress, job: PrintJob, options?: CallOptions) => Promise<unknown>;
52
+ /** No upload waits longer than this: a page of raster is a few hundred kB, and a printer can be slow to take it. */
53
+ export declare const PRINT_TIMEOUT_MS = 60000;
54
+ /**
55
+ * Print-Job: the job's attributes and the document in one request. A timeout leaves it unknown whether the printer took
56
+ * the job, so the error says not to send it again blindly.
57
+ */
58
+ export declare function printJob(fetcher: typeof fetch, address: PrinterAddress, job: PrintJob, document: Uint8Array, options?: CallOptions): Promise<PrintedJob>;
59
+ /** Cancel-Job: stop a job by its id, from Print-Job's answer. */
60
+ export declare const cancelJob: (fetcher: typeof fetch, address: PrinterAddress, jobId: number, options?: CallOptions & {
61
+ user?: string;
62
+ }) => Promise<unknown>;
63
+ export {};
@@ -1,8 +1,8 @@
1
1
  import { ConnectorError, fetchTimeout } from "../../connector.js";
2
- import { attributesOf, decodeMessage, getPrinterAttributesRequest, } from "./codec.js";
3
- // Pure reads: a `fetch`, an address and a path come in, the printer's attributes go out. No writes here yet.
4
- // TODO(writes, behind KO's hand): Identify-Printer (0x003C, a beep) first, then Print-Job (0x0002) with application/pdf
5
- // where `document-format-supported` lists it. Each is one more request builder in codec.ts and one function here.
2
+ import { attributesOf, decodeMessage, getPrinterAttributesRequest, OPERATION, operationRequest, TAG, } from "./codec.js";
3
+ // Pure calls: a `fetch`, an address and the bytes to send come in, the printer's answer goes out. The reads are
4
+ // Get-Printer-Attributes; the writes Identify-Printer, Validate-Job, Print-Job and Cancel-Job. The document is the caller's,
5
+ // already in a format the printer lists (`document-format-supported`): this package makes no PDF into raster.
6
6
  /** Everything the integration reads from one printer. */
7
7
  export const REQUESTED = [
8
8
  "printer-state",
@@ -51,15 +51,15 @@ const STATUS = {
51
51
  0x0507: "server-error-busy",
52
52
  };
53
53
  /**
54
- * Get-Printer-Attributes over HTTP, through `fetchTimeout`. A non-ok HTTP status or an IPP status of 0x0100 and up throws a
55
- * ConnectorError saying which. Returns the whole response; `attributesOf` gives the printer's attributes.
54
+ * One IPP request over HTTP, through `fetchTimeout`. A non-ok HTTP status or an IPP status of 0x0100 and up throws a
55
+ * ConnectorError saying which (`code: "ipp"`, the IPP status in `status`).
56
56
  */
57
- export async function getPrinterAttributes(fetcher, address, options = {}) {
57
+ async function call(fetcher, address, body, options = {}) {
58
58
  const url = printerUrl(address);
59
59
  const res = await fetchTimeout(fetcher, url.http, {
60
60
  method: "POST",
61
61
  headers: { "content-type": "application/ipp", accept: "application/ipp" },
62
- body: getPrinterAttributesRequest(url.ipp, REQUESTED, options.requestId),
62
+ body: body(url.ipp),
63
63
  }, options);
64
64
  if (!res.ok)
65
65
  throw new ConnectorError(`HTTP ${res.status} on POST /${url.path}`, {
@@ -72,3 +72,76 @@ export async function getPrinterAttributes(fetcher, address, options = {}) {
72
72
  }
73
73
  return message;
74
74
  }
75
+ /** Get-Printer-Attributes: the whole response; `attributesOf` gives the printer's attributes. `requested` replaces the poll's list. */
76
+ export const getPrinterAttributes = (fetcher, address, options = {}) => call(fetcher, address, (uri) => getPrinterAttributesRequest(uri, options.requested ?? REQUESTED, options.requestId), options);
77
+ /** Identify-Printer: the printer shows or sounds that it is the one (PWG 5100.13). No paper. */
78
+ export const identifyPrinter = (fetcher, address, options = {}) => call(fetcher, address, (uri) => operationRequest(OPERATION.identifyPrinter, uri, {
79
+ requestId: options.requestId,
80
+ }), options);
81
+ /** Who asks: RFC 8011 has a client send it on every operation. `home` unless the caller names someone. */
82
+ const userAttribute = (user = "home") => ({
83
+ name: "requesting-user-name",
84
+ tag: TAG.nameWithoutLanguage,
85
+ values: [user],
86
+ });
87
+ function jobAttributes(job) {
88
+ const attributes = [
89
+ userAttribute(job.user),
90
+ {
91
+ name: "job-name",
92
+ tag: TAG.nameWithoutLanguage,
93
+ values: [job.name ?? "home"],
94
+ },
95
+ { name: "document-format", tag: TAG.mimeMediaType, values: [job.format] },
96
+ ];
97
+ const attrs = [
98
+ { name: "sides", tag: TAG.keyword, values: [job.sides ?? "one-sided"] },
99
+ ];
100
+ if (job.media)
101
+ attrs.push({ name: "media", tag: TAG.keyword, values: [job.media] });
102
+ if (job.copies !== undefined)
103
+ attrs.push({ name: "copies", tag: TAG.integer, values: [job.copies] });
104
+ return { attributes, job: attrs };
105
+ }
106
+ /** Validate-Job: the printer checks the job's attributes as Print-Job would, and prints nothing. */
107
+ export const validateJob = (fetcher, address, job, options = {}) => call(fetcher, address, (uri) => operationRequest(OPERATION.validateJob, uri, {
108
+ ...jobAttributes(job),
109
+ requestId: options.requestId,
110
+ }), options);
111
+ /** No upload waits longer than this: a page of raster is a few hundred kB, and a printer can be slow to take it. */
112
+ export const PRINT_TIMEOUT_MS = 60_000;
113
+ /**
114
+ * Print-Job: the job's attributes and the document in one request. A timeout leaves it unknown whether the printer took
115
+ * the job, so the error says not to send it again blindly.
116
+ */
117
+ export async function printJob(fetcher, address, job, document, options = {}) {
118
+ let answer;
119
+ try {
120
+ answer = await call(fetcher, address, (uri) => operationRequest(OPERATION.printJob, uri, {
121
+ ...jobAttributes(job),
122
+ data: document,
123
+ requestId: options.requestId,
124
+ }), { timeoutMs: PRINT_TIMEOUT_MS, ...options });
125
+ }
126
+ catch (error) {
127
+ if (error instanceof Error && error.name === "TimeoutError")
128
+ throw new ConnectorError("the printer did not answer the print job in time; it may still print, so check the printer before sending it again", { code: "timeout", cause: error });
129
+ throw error;
130
+ }
131
+ const a = attributesOf(answer, TAG.job);
132
+ const n = (v) => (typeof v === "number" ? v : undefined);
133
+ const reasons = a["job-state-reasons"]?.filter((r) => typeof r === "string");
134
+ return {
135
+ jobId: n(a["job-id"]?.[0]),
136
+ state: n(a["job-state"]?.[0]),
137
+ reasons: reasons?.length ? reasons : undefined,
138
+ };
139
+ }
140
+ /** Cancel-Job: stop a job by its id, from Print-Job's answer. */
141
+ export const cancelJob = (fetcher, address, jobId, options = {}) => call(fetcher, address, (uri) => operationRequest(OPERATION.cancelJob, uri, {
142
+ requestId: options.requestId,
143
+ attributes: [
144
+ { name: "job-id", tag: TAG.integer, values: [jobId] },
145
+ userAttribute(options.user), // printers often let only the job's owner cancel it
146
+ ],
147
+ }), options);
@@ -23,9 +23,13 @@ export declare const TAG: {
23
23
  readonly naturalLanguage: 72;
24
24
  readonly mimeMediaType: 73;
25
25
  };
26
- /** The operation ids this package sends. Identify-Printer (0x003C) and Print-Job (0x0002) are the writes that come later. */
26
+ /** The operation ids this package sends (RFC 8011; Identify-Printer from PWG 5100.13). */
27
27
  export declare const OPERATION: {
28
+ readonly printJob: 2;
29
+ readonly validateJob: 4;
30
+ readonly cancelJob: 8;
28
31
  readonly getPrinterAttributes: 11;
32
+ readonly identifyPrinter: 60;
29
33
  };
30
34
  export type IppRange = {
31
35
  lower: number;
@@ -65,8 +69,18 @@ export type IppMessage = {
65
69
  };
66
70
  /** A message as bytes. Collections are not encoded (a Get-Printer-Attributes request has none). */
67
71
  export declare function encodeMessage(m: IppMessage): Uint8Array<ArrayBuffer>;
68
- /** The Get-Printer-Attributes request: IPP 2.0, utf-8, English, the printer's `ipp://` URI, and the attributes wanted. */
69
- export declare function getPrinterAttributesRequest(printerUri: string, requested: string[], requestId?: number): Uint8Array<ArrayBuffer>;
72
+ /**
73
+ * A request to one printer: IPP 2.0, utf-8, English, the printer's `ipp://` URI, then `attributes` in the operation
74
+ * group, `job` (when given) in a job group, and `data` (a document) after the end tag.
75
+ */
76
+ export declare function operationRequest(code: number, printerUri: string, options?: {
77
+ attributes?: IppAttribute[];
78
+ job?: IppAttribute[];
79
+ data?: Uint8Array;
80
+ requestId?: number;
81
+ }): Uint8Array<ArrayBuffer>;
82
+ /** The Get-Printer-Attributes request: the attributes wanted. */
83
+ export declare const getPrinterAttributesRequest: (printerUri: string, requested: string[], requestId?: number) => Uint8Array<ArrayBuffer>;
70
84
  /**
71
85
  * A message's bytes as groups of attributes. An unknown or malformed value is kept as `IppUnknown`, never thrown; only a
72
86
  * message that cannot be walked (cut short, a value before any attribute) throws. A collection and its members are skipped.
@@ -27,8 +27,14 @@ export const TAG = {
27
27
  naturalLanguage: 0x48,
28
28
  mimeMediaType: 0x49,
29
29
  };
30
- /** The operation ids this package sends. Identify-Printer (0x003C) and Print-Job (0x0002) are the writes that come later. */
31
- export const OPERATION = { getPrinterAttributes: 0x000b };
30
+ /** The operation ids this package sends (RFC 8011; Identify-Printer from PWG 5100.13). */
31
+ export const OPERATION = {
32
+ printJob: 0x0002,
33
+ validateJob: 0x0004,
34
+ cancelJob: 0x0008,
35
+ getPrinterAttributes: 0x000b,
36
+ identifyPrinter: 0x003c,
37
+ };
32
38
  const STRING_TAGS = new Set([
33
39
  TAG.textWithoutLanguage,
34
40
  TAG.nameWithoutLanguage,
@@ -94,14 +100,21 @@ export function encodeMessage(m) {
94
100
  });
95
101
  }
96
102
  out.push(TAG.end);
97
- return Uint8Array.from([...out, ...(m.data ?? [])]);
103
+ const bytes = new Uint8Array(out.length + (m.data?.length ?? 0));
104
+ bytes.set(out);
105
+ if (m.data)
106
+ bytes.set(m.data, out.length);
107
+ return bytes;
98
108
  }
99
- /** The Get-Printer-Attributes request: IPP 2.0, utf-8, English, the printer's `ipp://` URI, and the attributes wanted. */
100
- export function getPrinterAttributesRequest(printerUri, requested, requestId = 1) {
109
+ /**
110
+ * A request to one printer: IPP 2.0, utf-8, English, the printer's `ipp://` URI, then `attributes` in the operation
111
+ * group, `job` (when given) in a job group, and `data` (a document) after the end tag.
112
+ */
113
+ export function operationRequest(code, printerUri, options = {}) {
101
114
  return encodeMessage({
102
115
  version: [2, 0],
103
- code: OPERATION.getPrinterAttributes,
104
- requestId,
116
+ code,
117
+ requestId: options.requestId ?? 1,
105
118
  groups: [
106
119
  {
107
120
  tag: TAG.operation,
@@ -113,12 +126,23 @@ export function getPrinterAttributesRequest(printerUri, requested, requestId = 1
113
126
  values: ["en"],
114
127
  },
115
128
  { name: "printer-uri", tag: TAG.uri, values: [printerUri] },
116
- { name: "requested-attributes", tag: TAG.keyword, values: requested },
129
+ ...(options.attributes ?? []),
117
130
  ],
118
131
  },
132
+ ...(options.job?.length
133
+ ? [{ tag: TAG.job, attributes: options.job }]
134
+ : []),
119
135
  ],
136
+ data: options.data,
120
137
  });
121
138
  }
139
+ /** The Get-Printer-Attributes request: the attributes wanted. */
140
+ export const getPrinterAttributesRequest = (printerUri, requested, requestId = 1) => operationRequest(OPERATION.getPrinterAttributes, printerUri, {
141
+ requestId,
142
+ attributes: [
143
+ { name: "requested-attributes", tag: TAG.keyword, values: requested },
144
+ ],
145
+ });
122
146
  const fail = (why) => {
123
147
  throw new Error(`ipp: ${why}`);
124
148
  };
@@ -5,6 +5,8 @@ import { type DeviceRecord, type Integration, type Part } from "../../home.ts";
5
5
  import type { Manifest } from "../../manifest.ts";
6
6
  import { type Printer, type PrinterStatus, type PrintersState } from "./state.ts";
7
7
  export { type Marker, type Printer, type PrintersState, type PrinterStatus, printerText, } from "./state.ts";
8
+ export { cancelJob, identifyPrinter, type PrintedJob, type PrinterAddress, type PrintJob, printJob, validateJob, } from "./client.ts";
9
+ export { type GreyPage, mediaPixels, pwgRaster, type RasterMedia, } from "./raster.ts";
8
10
  /** 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. */
9
11
  export declare const configSchema: z.ZodObject<{
10
12
  connector: z.ZodLiteral<"printers">;