@khalido/home 0.1.3 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/.env.example +2 -2
  2. package/CHANGELOG.md +56 -1
  3. package/README.md +7 -2
  4. package/dist/config.d.ts +5 -3
  5. package/dist/config.js +5 -1
  6. package/dist/connector.d.ts +2 -0
  7. package/dist/connector.js +3 -0
  8. package/dist/connectors/amber/client.d.ts +1 -1
  9. package/dist/connectors/amber/client.js +1 -1
  10. package/dist/connectors/amber/index.js +2 -2
  11. package/dist/connectors/cast/index.d.ts +1 -1
  12. package/dist/connectors/cast/index.js +1 -1
  13. package/dist/connectors/cast/state.d.ts +17 -3
  14. package/dist/connectors/cast/state.js +26 -7
  15. package/dist/connectors/daikin/client.d.ts +45 -2
  16. package/dist/connectors/daikin/client.js +67 -2
  17. package/dist/connectors/daikin/index.d.ts +2 -2
  18. package/dist/connectors/daikin/index.js +4 -3
  19. package/dist/connectors/enphase/client.d.ts +1 -1
  20. package/dist/connectors/enphase/client.js +1 -1
  21. package/dist/connectors/enphase/index.js +2 -1
  22. package/dist/connectors/hue/ca.d.ts +2 -0
  23. package/dist/connectors/hue/ca.js +38 -0
  24. package/dist/connectors/hue/client.d.ts +81 -6
  25. package/dist/connectors/hue/client.js +217 -6
  26. package/dist/connectors/hue/color.d.ts +30 -0
  27. package/dist/connectors/hue/color.js +92 -0
  28. package/dist/connectors/hue/events.d.ts +0 -2
  29. package/dist/connectors/hue/events.js +1 -2
  30. package/dist/connectors/hue/index.d.ts +15 -4
  31. package/dist/connectors/hue/index.js +27 -10
  32. package/dist/connectors/hue/schemas.d.ts +27 -0
  33. package/dist/connectors/hue/schemas.js +26 -0
  34. package/dist/connectors/hue/state.d.ts +10 -1
  35. package/dist/connectors/hue/state.js +20 -1
  36. package/dist/connectors/printers/client.d.ts +41 -6
  37. package/dist/connectors/printers/client.js +81 -8
  38. package/dist/connectors/printers/codec.d.ts +17 -3
  39. package/dist/connectors/printers/codec.js +32 -8
  40. package/dist/connectors/printers/index.d.ts +2 -0
  41. package/dist/connectors/printers/index.js +4 -0
  42. package/dist/connectors/printers/raster.d.ts +30 -0
  43. package/dist/connectors/printers/raster.js +175 -0
  44. package/dist/connectors/printers/state.d.ts +2 -0
  45. package/dist/connectors/spotify/changes.d.ts +19 -5
  46. package/dist/connectors/spotify/changes.js +29 -7
  47. package/dist/connectors/spotify/index.d.ts +1 -1
  48. package/dist/connectors/spotify/index.js +20 -3
  49. package/dist/connectors/up/client.d.ts +1 -1
  50. package/dist/connectors/up/client.js +1 -1
  51. package/dist/connectors/up/index.js +2 -2
  52. package/dist/connectors/weather/google.js +2 -2
  53. package/dist/devices.d.ts +24 -0
  54. package/dist/devices.js +56 -2
  55. package/dist/discover.js +1 -1
  56. package/dist/finders-entry.d.ts +1 -1
  57. package/dist/finders-entry.js +1 -1
  58. package/dist/finders.d.ts +2 -1
  59. package/dist/finders.js +16 -5
  60. package/dist/home.d.ts +11 -2
  61. package/dist/home.js +98 -34
  62. package/dist/index.d.ts +1 -1
  63. package/dist/index.js +1 -1
  64. package/dist/node-fetch.d.ts +2 -2
  65. package/dist/node-fetch.js +3 -2
  66. package/docs/cli.md +15 -7
  67. package/docs/config.md +11 -3
  68. package/docs/decisions.md +19 -0
  69. package/docs/hue.md +161 -0
  70. package/docs/ideas.md +2 -0
  71. package/docs/using.md +98 -3
  72. package/examples/home.jsonc +2 -1
  73. package/home.schema.json +10 -3
  74. package/llms.txt +19 -6
  75. package/package.json +1 -1
  76. package/skills/draw-home/README.md +2 -0
  77. package/skills/draw-home/SKILL.md +1 -1
  78. package/skills/use-home/SKILL.md +3 -3
  79. package/src/cli/cast.ts +51 -5
  80. package/src/cli/context.ts +2 -0
  81. package/src/cli/daikin.ts +71 -0
  82. package/src/cli/enphase.ts +31 -5
  83. package/src/cli/hue.ts +147 -59
  84. package/src/cli/main.ts +43 -13
  85. package/src/cli/printers.ts +254 -3
  86. package/src/cli/probe.ts +21 -17
  87. package/src/cli/status.ts +61 -10
  88. package/src/config.ts +8 -2
  89. package/src/connector.ts +5 -0
  90. package/src/connectors/amber/README.md +1 -1
  91. package/src/connectors/amber/client.ts +1 -1
  92. package/src/connectors/amber/index.ts +2 -2
  93. package/src/connectors/cast/README.md +6 -3
  94. package/src/connectors/cast/index.ts +7 -1
  95. package/src/connectors/cast/state.ts +32 -5
  96. package/src/connectors/daikin/README.md +16 -4
  97. package/src/connectors/daikin/client.ts +104 -3
  98. package/src/connectors/daikin/index.ts +8 -2
  99. package/src/connectors/enphase/README.md +3 -1
  100. package/src/connectors/enphase/client.ts +1 -1
  101. package/src/connectors/enphase/index.ts +2 -1
  102. package/src/connectors/hue/README.md +62 -17
  103. package/src/connectors/hue/ca.ts +39 -0
  104. package/src/connectors/hue/client.ts +362 -15
  105. package/src/connectors/hue/color.ts +123 -0
  106. package/src/connectors/hue/events.ts +1 -4
  107. package/src/connectors/hue/index.ts +46 -13
  108. package/src/connectors/hue/schemas.ts +26 -0
  109. package/src/connectors/hue/state.ts +27 -1
  110. package/src/connectors/printers/README.md +9 -4
  111. package/src/connectors/printers/client.ts +186 -12
  112. package/src/connectors/printers/codec.ts +44 -10
  113. package/src/connectors/printers/index.ts +17 -0
  114. package/src/connectors/printers/raster.ts +215 -0
  115. package/src/connectors/printers/state.ts +2 -0
  116. package/src/connectors/spotify/README.md +10 -2
  117. package/src/connectors/spotify/changes.ts +38 -9
  118. package/src/connectors/spotify/index.ts +21 -2
  119. package/src/connectors/up/README.md +1 -1
  120. package/src/connectors/up/client.ts +1 -1
  121. package/src/connectors/up/index.ts +2 -2
  122. package/src/connectors/weather/google.ts +6 -2
  123. package/src/devices.ts +83 -3
  124. package/src/discover.ts +1 -2
  125. package/src/finders-entry.ts +1 -1
  126. package/src/finders.ts +18 -6
  127. package/src/home.ts +99 -39
  128. package/src/index.ts +7 -1
  129. package/src/node-fetch.ts +6 -2
@@ -1,12 +1,14 @@
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
- export { bunPinnedFetch, HueError, hueClient, pair, } from "./client.js";
9
+ export { HUE_BRIDGE_ROOT_CA } from "./ca.js";
10
+ export { colorToXy, kelvinToMirek, mirekToKelvin, xyToHex, } from "./color.js";
11
+ export { bunPinnedFetch, CONFIRM_MS, HueError, hueClient, pair, pairWhenPressed, } from "./client.js";
10
12
  export { groups, homeGroupedLightId, lights, scenes, } from "./state.js";
11
13
  /** A Hue bridge in a home config: what it takes to reach it. Its model and software come live into the snapshot. */
12
14
  export const configSchema = z.looseObject({
@@ -16,7 +18,9 @@ export const configSchema = z.looseObject({
16
18
  .string()
17
19
  .describe("the bridge id, the CN its certificate must carry: its identity"),
18
20
  key: envName.describe("env name holding the application key from `home pair`"),
19
- ca: filePath.describe("Signify's root CA (PEM), relative to the config file"),
21
+ ca: filePath
22
+ .optional()
23
+ .describe("a root CA (PEM) to trust instead of Signify's two, which the package carries; relative to the config file"),
20
24
  });
21
25
  /**
22
26
  * Hue as a home integration: a full read on every (re)connect, then patched from the event stream; `refresh` re-reads it all.
@@ -162,8 +166,20 @@ const view = (s) => ({
162
166
  brightness,
163
167
  })),
164
168
  });
165
- /** "Kitchen: on · 40%", "Kitchen: off", or the name alone when the bridge hasn't said. */
166
- const onText = (v) => `${v.name ?? v.id}${v.on === undefined ? "" : `: ${v.on ? `on${v.brightness === undefined ? "" : ` · ${Math.round(v.brightness)}%`}` : "off"}`}`;
169
+ /** A light, room or zone in a line: "Kitchen: on · 40%", "Desk: on · 40% · 2700 K", "Strip: on · 80% · #ff8800", "Kitchen: off", or the name alone when the bridge hasn't said. */
170
+ export const hueText = (v) => {
171
+ if (v.on === undefined)
172
+ return v.name ?? v.id;
173
+ const parts = v.on
174
+ ? [
175
+ "on",
176
+ v.brightness === undefined ? "" : `${Math.round(v.brightness)}%`,
177
+ // A colour light showing a white reports both; the white is what it is showing.
178
+ v.kelvin !== undefined ? `${v.kelvin} K` : (v.hex ?? ""),
179
+ ]
180
+ : ["off"];
181
+ return `${v.name ?? v.id}: ${parts.filter(Boolean).join(" · ")}`;
182
+ };
167
183
  /** Every light, then every room and zone. A light's `available` is its Zigbee link; a group's `members` are its lights. */
168
184
  export function hueHomeDevices(part) {
169
185
  const s = part?.state;
@@ -181,7 +197,7 @@ export function hueHomeDevices(part) {
181
197
  ...(l.reachable === undefined ? {} : { available: l.reachable }),
182
198
  ...on(l),
183
199
  asOf: s.asOf,
184
- text: onText(l),
200
+ text: hueText(l),
185
201
  detail: l,
186
202
  })),
187
203
  ...groups(s).map((g) => ({
@@ -191,7 +207,7 @@ export function hueHomeDevices(part) {
191
207
  kind: "group",
192
208
  ...on(g),
193
209
  asOf: s.asOf,
194
- text: onText(g),
210
+ text: hueText(g),
195
211
  members: g.lightIds,
196
212
  detail: g,
197
213
  })),
@@ -223,7 +239,8 @@ export const manifest = {
223
239
  {
224
240
  name: "HUE_CA_FILE",
225
241
  example: "/path/to/hue-root-ca.pem",
226
- what: "until HUE_BRIDGE_ROOT_CA ships in src/connectors/hue/",
242
+ optional: true,
243
+ what: "a root CA to trust instead of Signify's two, which the package carries",
227
244
  },
228
245
  ],
229
246
  files: ["ca"],
@@ -232,7 +249,7 @@ export const manifest = {
232
249
  if (!state)
233
250
  return [];
234
251
  const { lightsOn } = view(state);
235
- return [`${lightsOn} lights on`, ...groups(state).map(onText)];
252
+ return [`${lightsOn} lights on`, ...groups(state).map(hueText)];
236
253
  },
237
254
  record: ({ state }) => (state ? view(state) : {}),
238
255
  };
@@ -19,6 +19,33 @@ export declare const Resource: z.ZodObject<{
19
19
  dimming: z.ZodOptional<z.ZodObject<{
20
20
  brightness: z.ZodNumber;
21
21
  }, z.core.$loose>>;
22
+ color: z.ZodCatch<z.ZodOptional<z.ZodObject<{
23
+ xy: z.ZodCatch<z.ZodOptional<z.ZodObject<{
24
+ x: z.ZodNumber;
25
+ y: z.ZodNumber;
26
+ }, z.core.$loose>>>;
27
+ gamut: z.ZodCatch<z.ZodOptional<z.ZodObject<{
28
+ red: z.ZodObject<{
29
+ x: z.ZodNumber;
30
+ y: z.ZodNumber;
31
+ }, z.core.$loose>;
32
+ green: z.ZodObject<{
33
+ x: z.ZodNumber;
34
+ y: z.ZodNumber;
35
+ }, z.core.$loose>;
36
+ blue: z.ZodObject<{
37
+ x: z.ZodNumber;
38
+ y: z.ZodNumber;
39
+ }, z.core.$loose>;
40
+ }, z.core.$loose>>>;
41
+ }, z.core.$loose>>>;
42
+ color_temperature: z.ZodCatch<z.ZodOptional<z.ZodObject<{
43
+ mirek: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
44
+ mirek_schema: z.ZodOptional<z.ZodObject<{
45
+ mirek_minimum: z.ZodOptional<z.ZodNumber>;
46
+ mirek_maximum: z.ZodOptional<z.ZodNumber>;
47
+ }, z.core.$loose>>;
48
+ }, z.core.$loose>>>;
22
49
  children: z.ZodOptional<z.ZodArray<z.ZodObject<{
23
50
  rid: z.ZodString;
24
51
  rtype: z.ZodString;
@@ -4,6 +4,7 @@ import { z } from "zod";
4
4
  // field is unconfirmed from the primary source. Only the fields the snapshot reads are parsed;
5
5
  // all are optional and unknown fields pass through, so a schema change degrades, never throws.
6
6
  const ref = z.object({ rid: z.string(), rtype: z.string() });
7
+ const xy = z.looseObject({ x: z.number(), y: z.number() });
7
8
  /** The resource kinds this library reads. */
8
9
  export const KINDS = [
9
10
  "light",
@@ -22,6 +23,31 @@ export const Resource = z.looseObject({
22
23
  metadata: z.looseObject({ name: z.string().optional() }).optional(),
23
24
  on: z.looseObject({ on: z.boolean() }).optional(),
24
25
  dimming: z.looseObject({ brightness: z.number() }).optional(),
26
+ // A light's colour and the triangle of colours it can show; absent on a white light. A shape this doesn't expect
27
+ // reads as absent, so one odd light never fails the whole read.
28
+ color: z
29
+ .looseObject({
30
+ xy: xy.optional().catch(undefined),
31
+ gamut: z
32
+ .looseObject({ red: xy, green: xy, blue: xy })
33
+ .optional()
34
+ .catch(undefined),
35
+ })
36
+ .optional()
37
+ .catch(undefined),
38
+ // mirek is null while the light shows a colour; the schema is this light's own range.
39
+ color_temperature: z
40
+ .looseObject({
41
+ mirek: z.number().nullable().optional(),
42
+ mirek_schema: z
43
+ .looseObject({
44
+ mirek_minimum: z.number().optional(),
45
+ mirek_maximum: z.number().optional(),
46
+ })
47
+ .optional(),
48
+ })
49
+ .optional()
50
+ .catch(undefined),
25
51
  children: z.array(ref).optional(),
26
52
  services: z.array(ref).optional(),
27
53
  group: ref.optional(), // scene: the room or zone it belongs to
@@ -1,3 +1,4 @@
1
+ import { type Gamut, type XY } from "./color.ts";
1
2
  import type { HueEvent, Resource } from "./schemas.ts";
2
3
  /** Every resource on the bridge by id, from the last full read plus every event since. `asOf` is local time it was last known fresh. */
3
4
  export type HueSnapshot = {
@@ -17,8 +18,16 @@ export type LightView = {
17
18
  on?: boolean;
18
19
  brightness?: number;
19
20
  reachable?: boolean;
21
+ /** White temperature in kelvin, while the light shows a white; its range is `kelvinRange` (warmest, coolest). */
22
+ kelvin?: number;
23
+ kelvinRange?: [number, number];
24
+ /** The colour it shows: the bridge's `xy`, and `hex` for a screen (the hue at full brightness). Colour lights only. */
25
+ xy?: XY;
26
+ hex?: string;
27
+ /** The colours it can show. Present on a colour light, so its presence says "takes a colour". */
28
+ gamut?: Gamut;
20
29
  };
21
- /** Every light, with on, brightness and reachability. */
30
+ /** Every light, with on, brightness, reachability, and its white temperature and colour where it has them. */
22
31
  export declare function lights(s: HueSnapshot): LightView[];
23
32
  /** A room or zone as a row shows it, with its grouped light's state. */
24
33
  export type GroupView = {
@@ -1,3 +1,4 @@
1
+ import { mirekToKelvin, xyToHex } from "./color.js";
1
2
  /** A snapshot from one full `get()`. */
2
3
  export function snapshotFrom(resources, asOf) {
3
4
  return {
@@ -32,7 +33,7 @@ const ofType = (s, type) => Object.values(s.resources).filter((r) => r.type ===
32
33
  const groupedLightOf = (s, r) => r.services
33
34
  ?.map((x) => s.resources[x.rid])
34
35
  .find((x) => x?.type === "grouped_light");
35
- /** Every light, with on, brightness and reachability. */
36
+ /** Every light, with on, brightness, reachability, and its white temperature and colour where it has them. */
36
37
  export function lights(s) {
37
38
  const zigbee = ofType(s, "zigbee_connectivity");
38
39
  return ofType(s, "light").map((l) => {
@@ -43,9 +44,27 @@ export function lights(s) {
43
44
  on: l.on?.on,
44
45
  brightness: l.dimming?.brightness,
45
46
  reachable: link ? link.status === "connected" : undefined,
47
+ ...colorOf(l),
46
48
  };
47
49
  });
48
50
  }
51
+ /** A light's temperature and colour fields, only those the bridge reported. */
52
+ function colorOf(l) {
53
+ const out = {};
54
+ const ct = l.color_temperature;
55
+ if (typeof ct?.mirek === "number")
56
+ out.kelvin = mirekToKelvin(ct.mirek);
57
+ const { mirek_minimum: min, mirek_maximum: max } = ct?.mirek_schema ?? {};
58
+ if (min !== undefined && max !== undefined)
59
+ out.kelvinRange = [mirekToKelvin(max), mirekToKelvin(min)];
60
+ if (l.color?.xy) {
61
+ out.xy = l.color.xy;
62
+ out.hex = xyToHex(l.color.xy);
63
+ }
64
+ if (l.color?.gamut)
65
+ out.gamut = l.color.gamut;
66
+ return out;
67
+ }
49
68
  /** Rooms and zones. A room's children are devices; a zone's are lights. */
50
69
  export function groups(s) {
51
70
  const all = ofType(s, "light");
@@ -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">;
@@ -8,6 +8,8 @@ import { getPrinterAttributes, printerUrl } from "./client.js";
8
8
  import { attributesOf } from "./codec.js";
9
9
  import { printerKey, printerText, toState, } from "./state.js";
10
10
  export { printerText, } from "./state.js";
11
+ export { cancelJob, identifyPrinter, printJob, validateJob, } from "./client.js";
12
+ export { mediaPixels, pwgRaster, } from "./raster.js";
11
13
  /** 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. */
12
14
  export const configSchema = z.looseObject({
13
15
  connector: z.literal("printers"),
@@ -92,6 +94,8 @@ export function printers(config = {}) {
92
94
  name: t.name,
93
95
  model: good.model ?? t.foundModel,
94
96
  host: t.host,
97
+ port: t.port,
98
+ rp: t.rp,
95
99
  room: t.room,
96
100
  ...(offline ? { state: "offline" } : {}),
97
101
  ipps: t.ipps,
@@ -0,0 +1,30 @@
1
+ /** One page: `pixels` is `width × height` bytes, row by row from the top, 0 black to 255 white. */
2
+ export type GreyPage = {
3
+ width: number;
4
+ height: number;
5
+ pixels: Uint8Array;
6
+ };
7
+ /** What every page is printed as: the printer's resolution and the PWG media name and size it lists. */
8
+ export type RasterMedia = {
9
+ /** PWG self-describing name, as the printer lists it in `media-supported`: `iso_a4_210x297mm`. */
10
+ name: string;
11
+ /** dots per inch, one of the printer's `pwg-raster-document-resolution-supported`. */
12
+ dpi: number;
13
+ };
14
+ /** 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. */
15
+ export declare function mediaPixels(media: RasterMedia): {
16
+ width: number;
17
+ height: number;
18
+ };
19
+ /**
20
+ * Pages as one PWG Raster document, `image/pwg-raster`. Every page must be `width × height` bytes. Pages may come one at
21
+ * a time (a generator), so only the packed document and the current page are held; then `total` is the page count.
22
+ */
23
+ export declare function pwgRaster(pages: Iterable<GreyPage>, media: RasterMedia, total?: number): Uint8Array<ArrayBuffer>;
24
+ /** A PWG Raster document back as pages and their headers' key fields; for tests and for checking a file before it prints. */
25
+ export declare function readPwgRaster(file: Uint8Array): (GreyPage & {
26
+ dpi: number;
27
+ pageSizeName: string;
28
+ colorSpace: number;
29
+ bitsPerPixel: number;
30
+ })[];