@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
@@ -0,0 +1,123 @@
1
+ import { ConnectorError } from "../../connector.ts";
2
+
3
+ // Colour for Hue lights. A light's colour is an `xy` point in the CIE 1931 space, and each light can show the points
4
+ // inside its gamut triangle, which the bridge reports per light. The steps are the standard sRGB ones (gamma, the D65
5
+ // matrix), as Signify's "Color Conversion Formulas" page describes; that page's SDK extract is not copied.
6
+
7
+ export type XY = { x: number; y: number };
8
+ export type Gamut = { red: XY; green: XY; blue: XY };
9
+ export type Rgb = { r: number; g: number; b: number };
10
+
11
+ /** A colour as a caller gives it: "#ff8800", "ff8800", "#f80", or `{ r, g, b }` each 0–255. Throws on anything else. */
12
+ export function parseColor(color: string | Rgb): Rgb {
13
+ if (typeof color !== "string") {
14
+ const ok = [color.r, color.g, color.b].every(
15
+ (v) => Number.isFinite(v) && v >= 0 && v <= 255,
16
+ );
17
+ if (!ok)
18
+ throw new ConnectorError("a colour's r, g and b are each 0–255", {
19
+ code: "invalid",
20
+ });
21
+ return color;
22
+ }
23
+ const hex = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(color.trim())?.[1];
24
+ if (!hex)
25
+ throw new ConnectorError(`"${color}" is not a colour like #ff8800`, {
26
+ code: "invalid",
27
+ });
28
+ const full = hex.length === 3 ? [...hex].map((c) => c + c).join("") : hex;
29
+ const n = Number.parseInt(full, 16);
30
+ return { r: n >> 16, g: (n >> 8) & 255, b: n & 255 };
31
+ }
32
+
33
+ const linear = (v: number) =>
34
+ v > 0.04045 ? ((v + 0.055) / 1.055) ** 2.4 : v / 12.92;
35
+ const gamma = (v: number) =>
36
+ v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055;
37
+
38
+ const cross = (a: XY, b: XY) => a.x * b.y - a.y * b.x;
39
+ const minus = (a: XY, b: XY): XY => ({ x: a.x - b.x, y: a.y - b.y });
40
+
41
+ /** Whether `p` is inside the gamut triangle. */
42
+ export function inGamut(p: XY, g: Gamut): boolean {
43
+ const v1 = minus(g.green, g.red);
44
+ const v2 = minus(g.blue, g.red);
45
+ const q = minus(p, g.red);
46
+ const s = cross(q, v2) / cross(v1, v2);
47
+ const t = cross(v1, q) / cross(v1, v2);
48
+ return s >= 0 && t >= 0 && s + t <= 1;
49
+ }
50
+
51
+ /** The nearest point to `p` on the segment a–b. */
52
+ function onSegment(a: XY, b: XY, p: XY): XY {
53
+ const ab = minus(b, a);
54
+ const t = Math.min(
55
+ 1,
56
+ Math.max(
57
+ 0,
58
+ ((p.x - a.x) * ab.x + (p.y - a.y) * ab.y) / (ab.x ** 2 + ab.y ** 2),
59
+ ),
60
+ );
61
+ return { x: a.x + ab.x * t, y: a.y + ab.y * t };
62
+ }
63
+
64
+ /** `p` if the light can show it, else the nearest point on its gamut's edge: the colour it can show that is closest. */
65
+ export function nearestInGamut(p: XY, g: Gamut): XY {
66
+ if (inGamut(p, g)) return p;
67
+ const edges = [
68
+ onSegment(g.red, g.green, p),
69
+ onSegment(g.green, g.blue, p),
70
+ onSegment(g.blue, g.red, p),
71
+ ];
72
+ const far = (q: XY) => (q.x - p.x) ** 2 + (q.y - p.y) ** 2;
73
+ return edges.reduce((best, q) => (far(q) < far(best) ? q : best));
74
+ }
75
+
76
+ /**
77
+ * A colour's `xy`, moved inside `gamut` when one is given. Brightness is not part of it: black has no colour and throws
78
+ * (turn the light off instead), and a dark red and a bright red are the same point.
79
+ */
80
+ export function colorToXy(color: string | Rgb, gamut?: Gamut): XY {
81
+ const { r, g, b } = parseColor(color);
82
+ const [lr, lg, lb] = [r, g, b].map((v) => linear(v / 255)) as [
83
+ number,
84
+ number,
85
+ number,
86
+ ];
87
+ const X = lr * 0.4124 + lg * 0.3576 + lb * 0.1805;
88
+ const Y = lr * 0.2126 + lg * 0.7152 + lb * 0.0722;
89
+ const Z = lr * 0.0193 + lg * 0.1192 + lb * 0.9505;
90
+ const sum = X + Y + Z;
91
+ if (sum === 0)
92
+ throw new ConnectorError("black is not a colour a light can show", {
93
+ code: "invalid",
94
+ });
95
+ const xy = { x: X / sum, y: Y / sum };
96
+ const at = gamut ? nearestInGamut(xy, gamut) : xy;
97
+ return { x: round4(at.x), y: round4(at.y) };
98
+ }
99
+ const round4 = (v: number) => Math.round(v * 1e4) / 1e4;
100
+
101
+ /** An `xy` as a hex colour for a screen, at full brightness: what the light's colour looks like, not how bright it is. */
102
+ export function xyToHex(xy: XY): string {
103
+ if (xy.y <= 0) return "#000000";
104
+ const X = xy.x / xy.y;
105
+ const Z = (1 - xy.x - xy.y) / xy.y;
106
+ const rgb = [
107
+ X * 3.2406 - 1.5372 - Z * 0.4986,
108
+ -X * 0.9689 + 1.8758 + Z * 0.0415,
109
+ X * 0.0557 - 0.204 + Z * 1.057,
110
+ ].map((v) => Math.max(0, v));
111
+ const max = Math.max(...rgb);
112
+ return `#${rgb
113
+ .map((v) =>
114
+ Math.round(255 * Math.min(1, Math.max(0, gamma(max > 0 ? v / max : 0))))
115
+ .toString(16)
116
+ .padStart(2, "0"),
117
+ )
118
+ .join("")}`;
119
+ }
120
+
121
+ /** Colour temperature: Hue counts in mirek, a million over the kelvin. 2700 K is 370 mirek. */
122
+ export const kelvinToMirek = (kelvin: number) => Math.round(1e6 / kelvin);
123
+ export const mirekToKelvin = (mirek: number) => Math.round(1e6 / mirek);
@@ -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,
@@ -25,13 +25,29 @@ import {
25
25
  snapshotFrom,
26
26
  } from "./state.ts";
27
27
 
28
+ export { HUE_BRIDGE_ROOT_CA } from "./ca.ts";
29
+ export {
30
+ colorToXy,
31
+ type Gamut,
32
+ kelvinToMirek,
33
+ mirekToKelvin,
34
+ type Rgb,
35
+ type XY,
36
+ xyToHex,
37
+ } from "./color.ts";
28
38
  export {
29
39
  bunPinnedFetch,
40
+ CONFIRM_MS,
41
+ type HueApplied,
42
+ type HueRequest,
43
+ type HueWriteOptions,
44
+ type LightChange,
30
45
  type HueClient,
31
46
  HueError,
32
47
  type HueOptions,
33
48
  hueClient,
34
49
  pair,
50
+ pairWhenPressed,
35
51
  } from "./client.ts";
36
52
  export type { HueEvent, Resource } from "./schemas.ts";
37
53
  export {
@@ -54,18 +70,22 @@ export const configSchema = z.looseObject({
54
70
  key: envName.describe(
55
71
  "env name holding the application key from `home pair`",
56
72
  ),
57
- ca: filePath.describe("Signify's root CA (PEM), relative to the config file"),
73
+ ca: filePath
74
+ .optional()
75
+ .describe(
76
+ "a root CA (PEM) to trust instead of Signify's two, which the package carries; relative to the config file",
77
+ ),
58
78
  });
59
79
 
60
80
  /**
61
- * Hue settings for a home. Leave out `fetch` in Bun and it pins to `ca` and `bridgeId`; in Node pass a pinned one.
81
+ * Hue settings for a home. Leave out `fetch` in Bun and it pins to `bridgeId` and `ca` (Signify's roots, `HUE_BRIDGE_ROOT_CA`, when left out); in Node pass a pinned one.
62
82
  * `find` (mDNS, matched by `bridgeId`) finds the bridge again after three failed reconnects; `idleMs` is the stream's watchdog.
63
83
  */
64
84
  export type HueConfig = {
65
85
  host: string;
66
86
  bridgeId: string;
67
87
  key: string;
68
- ca: string;
88
+ ca?: string;
69
89
  fetch?: typeof fetch;
70
90
  clock?: Clock;
71
91
  find?: Finder;
@@ -220,14 +240,26 @@ const view = (s: HueSnapshot) => ({
220
240
  })),
221
241
  });
222
242
 
223
- /** "Kitchen: on · 40%", "Kitchen: off", or the name alone when the bridge hasn't said. */
224
- const onText = (v: {
243
+ /** 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. */
244
+ export const hueText = (v: {
225
245
  name?: string;
226
246
  id: string;
227
247
  on?: boolean;
228
248
  brightness?: number;
229
- }) =>
230
- `${v.name ?? v.id}${v.on === undefined ? "" : `: ${v.on ? `on${v.brightness === undefined ? "" : ` · ${Math.round(v.brightness)}%`}` : "off"}`}`;
249
+ kelvin?: number;
250
+ hex?: string;
251
+ }) => {
252
+ if (v.on === undefined) return v.name ?? v.id;
253
+ const parts = v.on
254
+ ? [
255
+ "on",
256
+ v.brightness === undefined ? "" : `${Math.round(v.brightness)}%`,
257
+ // A colour light showing a white reports both; the white is what it is showing.
258
+ v.kelvin !== undefined ? `${v.kelvin} K` : (v.hex ?? ""),
259
+ ]
260
+ : ["off"];
261
+ return `${v.name ?? v.id}: ${parts.filter(Boolean).join(" · ")}`;
262
+ };
231
263
 
232
264
  /** A Hue light, or a room or zone, as a home device: `detail` is its `LightView` or `GroupView`. */
233
265
  export type HueHomeDevice =
@@ -253,7 +285,7 @@ export function hueHomeDevices(
253
285
  ...(l.reachable === undefined ? {} : { available: l.reachable }),
254
286
  ...on(l),
255
287
  asOf: s.asOf,
256
- text: onText(l),
288
+ text: hueText(l),
257
289
  detail: l,
258
290
  })),
259
291
  ...groups(s).map((g): HueHomeDevice => ({
@@ -263,7 +295,7 @@ export function hueHomeDevices(
263
295
  kind: "group",
264
296
  ...on(g),
265
297
  asOf: s.asOf,
266
- text: onText(g),
298
+ text: hueText(g),
267
299
  members: g.lightIds,
268
300
  detail: g,
269
301
  })),
@@ -296,7 +328,8 @@ export const manifest: Manifest<HueSnapshot> = {
296
328
  {
297
329
  name: "HUE_CA_FILE",
298
330
  example: "/path/to/hue-root-ca.pem",
299
- what: "until HUE_BRIDGE_ROOT_CA ships in src/connectors/hue/",
331
+ optional: true,
332
+ what: "a root CA to trust instead of Signify's two, which the package carries",
300
333
  },
301
334
  ],
302
335
  files: ["ca"],
@@ -304,7 +337,7 @@ export const manifest: Manifest<HueSnapshot> = {
304
337
  lines({ state }) {
305
338
  if (!state) return [];
306
339
  const { lightsOn } = view(state);
307
- return [`${lightsOn} lights on`, ...groups(state).map(onText)];
340
+ return [`${lightsOn} lights on`, ...groups(state).map(hueText)];
308
341
  },
309
342
  record: ({ state }) => (state ? view(state) : {}),
310
343
  };
@@ -6,6 +6,7 @@ import { z } from "zod";
6
6
  // all are optional and unknown fields pass through, so a schema change degrades, never throws.
7
7
 
8
8
  const ref = z.object({ rid: z.string(), rtype: z.string() });
9
+ const xy = z.looseObject({ x: z.number(), y: z.number() });
9
10
 
10
11
  /** The resource kinds this library reads. */
11
12
  export const KINDS = [
@@ -27,6 +28,31 @@ export const Resource = z.looseObject({
27
28
  metadata: z.looseObject({ name: z.string().optional() }).optional(),
28
29
  on: z.looseObject({ on: z.boolean() }).optional(),
29
30
  dimming: z.looseObject({ brightness: z.number() }).optional(),
31
+ // A light's colour and the triangle of colours it can show; absent on a white light. A shape this doesn't expect
32
+ // reads as absent, so one odd light never fails the whole read.
33
+ color: z
34
+ .looseObject({
35
+ xy: xy.optional().catch(undefined),
36
+ gamut: z
37
+ .looseObject({ red: xy, green: xy, blue: xy })
38
+ .optional()
39
+ .catch(undefined),
40
+ })
41
+ .optional()
42
+ .catch(undefined),
43
+ // mirek is null while the light shows a colour; the schema is this light's own range.
44
+ color_temperature: z
45
+ .looseObject({
46
+ mirek: z.number().nullable().optional(),
47
+ mirek_schema: z
48
+ .looseObject({
49
+ mirek_minimum: z.number().optional(),
50
+ mirek_maximum: z.number().optional(),
51
+ })
52
+ .optional(),
53
+ })
54
+ .optional()
55
+ .catch(undefined),
30
56
  children: z.array(ref).optional(),
31
57
  services: z.array(ref).optional(),
32
58
  group: ref.optional(), // scene: the room or zone it belongs to
@@ -1,3 +1,4 @@
1
+ import { type Gamut, mirekToKelvin, type XY, xyToHex } from "./color.ts";
1
2
  import type { HueEvent, Resource } from "./schemas.ts";
2
3
 
3
4
  /** 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. */
@@ -56,9 +57,17 @@ export type LightView = {
56
57
  on?: boolean;
57
58
  brightness?: number;
58
59
  reachable?: boolean;
60
+ /** White temperature in kelvin, while the light shows a white; its range is `kelvinRange` (warmest, coolest). */
61
+ kelvin?: number;
62
+ kelvinRange?: [number, number];
63
+ /** The colour it shows: the bridge's `xy`, and `hex` for a screen (the hue at full brightness). Colour lights only. */
64
+ xy?: XY;
65
+ hex?: string;
66
+ /** The colours it can show. Present on a colour light, so its presence says "takes a colour". */
67
+ gamut?: Gamut;
59
68
  };
60
69
 
61
- /** Every light, with on, brightness and reachability. */
70
+ /** Every light, with on, brightness, reachability, and its white temperature and colour where it has them. */
62
71
  export function lights(s: HueSnapshot): LightView[] {
63
72
  const zigbee = ofType(s, "zigbee_connectivity");
64
73
  return ofType(s, "light").map((l) => {
@@ -69,10 +78,27 @@ export function lights(s: HueSnapshot): LightView[] {
69
78
  on: l.on?.on,
70
79
  brightness: l.dimming?.brightness,
71
80
  reachable: link ? link.status === "connected" : undefined,
81
+ ...colorOf(l),
72
82
  };
73
83
  });
74
84
  }
75
85
 
86
+ /** A light's temperature and colour fields, only those the bridge reported. */
87
+ function colorOf(l: Resource): Partial<LightView> {
88
+ const out: Partial<LightView> = {};
89
+ const ct = l.color_temperature;
90
+ if (typeof ct?.mirek === "number") out.kelvin = mirekToKelvin(ct.mirek);
91
+ const { mirek_minimum: min, mirek_maximum: max } = ct?.mirek_schema ?? {};
92
+ if (min !== undefined && max !== undefined)
93
+ out.kelvinRange = [mirekToKelvin(max), mirekToKelvin(min)];
94
+ if (l.color?.xy) {
95
+ out.xy = l.color.xy;
96
+ out.hex = xyToHex(l.color.xy);
97
+ }
98
+ if (l.color?.gamut) out.gamut = l.color.gamut;
99
+ return out;
100
+ }
101
+
76
102
  /** A room or zone as a row shows it, with its grouped light's state. */
77
103
  export type GroupView = {
78
104
  id: string;
@@ -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
+ );