@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
package/src/cli/hue.ts CHANGED
@@ -1,16 +1,20 @@
1
- // `home lights off`, `home room`, `home scene`, `home pair` (also as `home hue <verb>`): one write to the bridge each.
1
+ // `home lights off`, `home light`, `home room`, `home scene`, `home pair` (also as `home hue <verb>`): one write to the bridge each,
2
+ // then what the bridge reports. The name lookups and the checks are the library's (`client.light`, `room`, `scene`).
2
3
  import { readFileSync } from "node:fs";
3
4
  import { hostname } from "node:os";
4
5
  import {
5
6
  bunPinnedFetch,
6
- groups,
7
- homeGroupedLightId,
7
+ type GroupView,
8
+ type HueApplied,
9
+ type HueRequest,
10
+ HueError,
8
11
  hue,
9
12
  hueClient,
10
- pair,
11
- scenes,
13
+ hueText,
14
+ type LightChange,
15
+ type LightView,
16
+ pairWhenPressed,
12
17
  } from "../connectors/hue/index.ts";
13
- import { snapshotFrom } from "../connectors/hue/state.ts";
14
18
  import { FINDERS } from "../finders.ts";
15
19
  import { env, fail, usage, type Verbs } from "./context.ts";
16
20
 
@@ -24,12 +28,9 @@ export async function bridge(): Promise<{ host: string; id: string }> {
24
28
  return { host: env.HUE_BRIDGE_HOST ?? b.host, id: env.HUE_BRIDGE_ID ?? b.id };
25
29
  }
26
30
 
31
+ /** HUE_CA_FILE when set; else undefined, and the package's own copy of Signify's roots is used. */
27
32
  export const ca = () =>
28
- env.HUE_CA_FILE
29
- ? readFileSync(env.HUE_CA_FILE, "utf8")
30
- : fail(
31
- "set HUE_CA_FILE to Signify's root CA (see src/connectors/hue/README.md)",
32
- );
33
+ env.HUE_CA_FILE ? readFileSync(env.HUE_CA_FILE, "utf8") : undefined;
33
34
 
34
35
  const key = () =>
35
36
  env.HUE_APPLICATION_KEY ??
@@ -50,72 +51,159 @@ async function client() {
50
51
  });
51
52
  }
52
53
 
53
- /** Find one room, zone or scene by name, case-insensitively; a miss lists what exists. */
54
- function byName<T extends { name?: string }>(
55
- items: T[],
56
- name: string,
57
- what: string,
58
- ): T {
59
- return (
60
- items.find((i) => i.name?.toLowerCase() === name.toLowerCase()) ??
61
- fail(`no ${what} "${name}"; have: ${items.map((i) => i.name).join(", ")}`)
54
+ /** What a write left, in a line: the light or room as the bridge then reported it, or that it has not reported the change yet. */
55
+ const said = ({
56
+ state,
57
+ confirmed,
58
+ wouldSend,
59
+ }: HueApplied<LightView | GroupView>) =>
60
+ console.log(
61
+ wouldSend
62
+ ? dryLine(wouldSend, hueText(state))
63
+ : confirmed
64
+ ? hueText(state)
65
+ : `sent; the bridge has not reported it yet (${hueText(state)})`,
62
66
  );
67
+ const dryLine = (r: HueRequest, now: string) =>
68
+ `dry run, nothing sent: would PUT ${r.path} ${JSON.stringify(r.body)} (now ${now})`;
69
+
70
+ export const CHANGE_USAGE = "[on|off] [<1-100>] [<kelvin>k] [<hex colour>]";
71
+ /** The words after a name as a change: `on`, `off`, `40` (brightness), `2700k` (white), `ff8800` (colour), in any order. */
72
+ export function changeFrom(words: string[], usageLine: string): LightChange {
73
+ const change: LightChange = {};
74
+ for (const w of words) {
75
+ if (w === "on" || w === "off") {
76
+ if (change.on !== undefined && change.on !== (w === "on"))
77
+ usage(`on or off, not both; ${usageLine}`);
78
+ change.on = w === "on";
79
+ } else if (/^\d{1,3}%?$/.test(w))
80
+ change.brightness = Number.parseInt(w, 10);
81
+ else if (/^\d{4,5}k$/i.test(w)) change.kelvin = Number.parseInt(w, 10);
82
+ else if (/^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.test(w)) change.color = w;
83
+ else
84
+ usage(
85
+ `"${w}" is not on, off, a brightness, a temperature like 2700k or a colour like ff8800; ${usageLine}`,
86
+ );
87
+ }
88
+ if (!words.length) usage(usageLine);
89
+ return change;
63
90
  }
64
91
 
92
+ /** A change the bridge can't make (an unknown name, a value off the light's range) is a usage error; anything else passes through. */
93
+ const orUsage = (e: unknown): never => {
94
+ if (e instanceof HueError && e.code === "invalid") usage(e.message);
95
+ throw e;
96
+ };
97
+
98
+ /** How long `home pair` waits for the button: time to walk to the bridge. */
99
+ const PAIR_WAIT_MS = 60_000;
100
+
65
101
  export const hueVerbs: Verbs = {
66
- async lights([state]) {
67
- if (state !== "off") usage("usage: home lights off");
68
- const c = await client();
69
- const id =
70
- homeGroupedLightId(snapshotFrom(await c.get(), "")) ??
71
- fail("the bridge has no home group");
72
- await c.put("grouped_light", id, { on: { on: false } });
102
+ async lights([word], o) {
103
+ if (word !== "off") usage("usage: home lights off");
104
+ const { state, confirmed, wouldSend } = await (
105
+ await client()
106
+ )
107
+ .allOff({ dryRun: !!o.dryRun })
108
+ .catch(orUsage);
109
+ const on = state.filter((l) => l.on && l.reachable !== false).length;
110
+ console.log(
111
+ wouldSend
112
+ ? dryLine(wouldSend, `${on} of ${state.length} on`)
113
+ : confirmed
114
+ ? `${state.length} lights off`
115
+ : `sent; the bridge still reports ${on} of ${state.length} on`,
116
+ );
73
117
  },
74
118
 
75
- async room([name, value]) {
76
- if (!name || !value) usage("usage: home room <name> on|off|<1-100>");
77
- const c = await client();
78
- const room = byName(
79
- groups(snapshotFrom(await c.get(), "")),
80
- name!,
81
- "room or zone",
119
+ async light([name, ...words], o) {
120
+ const line = `usage: home light <name> ${CHANGE_USAGE}`;
121
+ if (!name) usage(line);
122
+ const change = changeFrom(words, line);
123
+ said(
124
+ await (
125
+ await client()
126
+ )
127
+ .light(name!, change, { dryRun: !!o.dryRun })
128
+ .catch(orUsage),
82
129
  );
83
- if (!room.groupedLightId) fail(`${room.name} has no grouped light`);
84
- const level = Number(value);
85
- if (value !== "on" && value !== "off" && !(level >= 1 && level <= 100))
86
- usage(`brightness must be 1-100, got ${value}`);
87
- const body =
88
- value === "on" || value === "off"
89
- ? { on: { on: value === "on" } }
90
- : { on: { on: true }, dimming: { brightness: level } };
91
- await c.put("grouped_light", room.groupedLightId!, body);
92
130
  },
93
131
 
94
- async scene(words) {
95
- const name = words.join(" ") || usage("usage: home scene <name>");
132
+ async room([name, ...words], o) {
133
+ const line = `usage: home room <name> ${CHANGE_USAGE}`;
134
+ if (!name) usage(line);
135
+ const change = changeFrom(words, line);
136
+ said(
137
+ await (
138
+ await client()
139
+ )
140
+ .room(name!, change, { dryRun: !!o.dryRun })
141
+ .catch(orUsage),
142
+ );
143
+ },
144
+
145
+ async scene(words, o) {
146
+ const line = "usage: home scene <name> [in <room>]";
147
+ if (!words.length) usage(line);
96
148
  const c = await client();
97
- await c.recallScene(
98
- byName(scenes(snapshotFrom(await c.get(), "")), name, "scene").id,
149
+ // "Relax in Bedroom" is the scene Relax in that room, unless a scene is named all of it.
150
+ const at = words.lastIndexOf("in");
151
+ const dry = { dryRun: !!o.dryRun };
152
+ const whole = () => c.scene(words.join(" "), undefined, dry);
153
+ said(
154
+ await (
155
+ at > 0 && at < words.length - 1
156
+ ? whole().catch(() =>
157
+ c.scene(
158
+ words.slice(0, at).join(" "),
159
+ words.slice(at + 1).join(" "),
160
+ dry,
161
+ ),
162
+ )
163
+ : whole()
164
+ ).catch(orUsage),
99
165
  );
100
166
  },
101
167
 
102
- async pair() {
168
+ async pair(_, o) {
169
+ if (o.dryRun) usage("home pair has no dry run");
103
170
  const b = await bridge();
104
- const { key } = await pair({
171
+ console.error(`Hue bridge ${b.id.slice(-6).toUpperCase()} at ${b.host}.`);
172
+ // The countdown redraws one line on a terminal; piped, it is one line said once.
173
+ const live = process.stderr.isTTY;
174
+ const until = Date.now() + PAIR_WAIT_MS;
175
+ const prompt = () =>
176
+ `Press the link button on the bridge: ${Math.max(0, Math.ceil((until - Date.now()) / 1000))} s left`;
177
+ let tick: ReturnType<typeof setInterval> | undefined;
178
+ const { key } = await pairWhenPressed({
105
179
  host: b.host,
106
180
  fetch: bunPinnedFetch(b.id, ca()),
107
181
  deviceType: `home#${hostname().slice(0, 19)}`,
108
- }).catch((e) =>
109
- e.code === "link_button"
110
- ? fail(
111
- "press the link button on the bridge, then pair again within 30 s",
112
- "auth",
113
- "home pair",
114
- )
115
- : Promise.reject(e),
116
- );
182
+ timeoutMs: PAIR_WAIT_MS,
183
+ onWaiting() {
184
+ if (!live) return console.error(prompt());
185
+ process.stderr.write(prompt());
186
+ tick = setInterval(
187
+ () => process.stderr.write(`\r\x1b[K${prompt()}`),
188
+ 1000,
189
+ );
190
+ },
191
+ })
192
+ .finally(() => {
193
+ clearInterval(tick);
194
+ if (tick) process.stderr.write("\r\x1b[K");
195
+ })
196
+ .catch((e) =>
197
+ e.code === "link_button"
198
+ ? fail(
199
+ `no button press detected in ${PAIR_WAIT_MS / 1000} s; nothing was changed`,
200
+ "auth",
201
+ "home pair",
202
+ )
203
+ : Promise.reject(e),
204
+ );
117
205
  console.error(
118
- "Paired. Put this in your shell environment (never in a repo):",
206
+ "Paired. Put these in your shell environment or .env (never in a repo):",
119
207
  );
120
208
  console.log(
121
209
  `HUE_APPLICATION_KEY=${key}\nHUE_BRIDGE_HOST=${b.host}\nHUE_BRIDGE_ID=${b.id}`,
package/src/cli/main.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  // `home`: the terminal layer. Commands come from cac; one `home <connector>` per manifest, its verbs from src/cli/<connector>.ts.
3
3
  import { cac } from "cac";
4
- import { ConnectorError, errorText } from "../connector.ts";
4
+ import { errorText, isRejectedKey } from "../connector.ts";
5
5
  import { MANIFESTS } from "../manifests.ts";
6
6
  import { cacheCommand } from "./cache.ts";
7
7
  import { CAST_OPTIONS, CAST_USAGE, castVerbs } from "./cast.ts";
@@ -14,23 +14,32 @@ import {
14
14
  usage,
15
15
  type Verbs,
16
16
  } from "./context.ts";
17
+ import { DAIKIN_USAGE, daikinVerbs } from "./daikin.ts";
17
18
  import { discover } from "./discover.ts";
18
19
  import { enphaseVerbs } from "./enphase.ts";
19
- import { hueVerbs } from "./hue.ts";
20
+ import { CHANGE_USAGE, hueVerbs } from "./hue.ts";
20
21
  import { locate } from "./locate.ts";
22
+ import { PRINTERS_USAGE, printersVerbs } from "./printers.ts";
21
23
  import { probe } from "./probe.ts";
22
24
  import { SPOTIFY_USAGE, spotifyVerbs } from "./spotify.ts";
23
- import { status } from "./status.ts";
25
+ import { devices, status } from "./status.ts";
24
26
  import { UP_OPTIONS, UP_USAGE, upVerbs } from "./up.ts";
25
27
 
26
28
  const HUE_HELP: Record<string, string> = {
27
29
  lights: "lights off: one write to the whole-home group",
28
- room: "room <name> on|off|<1-100>",
29
- scene: "scene <name>",
30
- pair: "pair: press the bridge's button first; prints the env lines to set",
30
+ light: `light <name> ${CHANGE_USAGE}`,
31
+ room: `room <name> ${CHANGE_USAGE}: every light in a room or zone, one write`,
32
+ scene: "scene <name> [in <room>]",
33
+ pair: "pair: waits 60 s for the bridge's button, counting down; prints the env lines to set",
31
34
  };
32
35
 
33
36
  /** Each connector's verbs beyond its status, the usage a wrong one prints, and any options of its own. */
37
+ /** Only where a write reads it: on any other command cac refuses it, so `--dry-run` never lets a real write through. */
38
+ const DRY_RUN: [string, string] = [
39
+ "--dry-run",
40
+ "check the write and print what would be sent; send nothing",
41
+ ];
42
+
34
43
  const VERBS: Record<
35
44
  string,
36
45
  {
@@ -41,14 +50,21 @@ const VERBS: Record<
41
50
  > = {
42
51
  hue: {
43
52
  verbs: hueVerbs,
53
+ options: [DRY_RUN],
44
54
  usage: `usage: home hue ${Object.values(HUE_HELP)
45
55
  .map((h) => h.split(":")[0])
46
56
  .join(" | ")}`,
47
57
  },
58
+ daikin: { verbs: daikinVerbs, usage: DAIKIN_USAGE, options: [DRY_RUN] },
48
59
  enphase: { verbs: enphaseVerbs, usage: "usage: home enphase login" },
49
60
  cast: { verbs: castVerbs, usage: CAST_USAGE, options: CAST_OPTIONS },
50
61
  up: { verbs: upVerbs, usage: UP_USAGE, options: UP_OPTIONS },
51
62
  spotify: { verbs: spotifyVerbs, usage: SPOTIFY_USAGE },
63
+ printers: {
64
+ verbs: printersVerbs,
65
+ usage: PRINTERS_USAGE,
66
+ options: [DRY_RUN],
67
+ },
52
68
  };
53
69
 
54
70
  const cli = cac("home");
@@ -66,6 +82,12 @@ const statusCommand = cli
66
82
  .alias("doctor")
67
83
  .alias("!") // cac's marker for the default command: `home` alone runs status
68
84
  .action((o: Options) => status(o));
85
+ cli
86
+ .command(
87
+ "devices",
88
+ "every device the connectors read, one line each: its state, its room, and why when it isn't available",
89
+ )
90
+ .action((o: Options) => devices(o));
69
91
  cli
70
92
  .command(
71
93
  "discover",
@@ -123,10 +145,11 @@ for (const m of MANIFESTS) {
123
145
  );
124
146
  }
125
147
  // Hue's verbs also stand alone, as typed before the CLI had `home hue`: `home lights off`, `home room …`, `home scene …`, `home pair`.
126
- for (const [verb, run] of Object.entries(hueVerbs))
127
- cli
128
- .command(`${verb} [...args]`, `Hue: ${HUE_HELP[verb]}`)
129
- .action((args: string[], o: Options) => run(args, o));
148
+ for (const [verb, run] of Object.entries(hueVerbs)) {
149
+ const c = cli.command(`${verb} [...args]`, `Hue: ${HUE_HELP[verb]}`);
150
+ if (verb !== "pair") c.option(...DRY_RUN);
151
+ c.action((args: string[], o: Options) => run(args, o));
152
+ }
130
153
 
131
154
  cli.help();
132
155
 
@@ -140,8 +163,7 @@ function describe(e: unknown): {
140
163
  if (e instanceof CliError) return { error, code: e.code, fix: e.fix };
141
164
  if (e instanceof Error && e.name === "CACError")
142
165
  return { error, code: "usage" }; // cac: a missing argument or an unknown option
143
- if (e instanceof ConnectorError && (e.status === 401 || e.status === 403))
144
- return { error, code: "auth" };
166
+ if (isRejectedKey(e)) return { error, code: "auth" };
145
167
  // A timeout, or a socket error Bun and Node tag with a code (ECONNREFUSED, ENOTFOUND). A bare TypeError is more likely a bug.
146
168
  const socket =
147
169
  typeof (e as { code?: unknown })?.code === "string" &&
@@ -150,7 +172,15 @@ function describe(e: unknown): {
150
172
  e instanceof Error &&
151
173
  (e.name === "TimeoutError" || e.name === "AbortError" || socket)
152
174
  )
153
- return { error, code: "network" };
175
+ return {
176
+ // macOS answers "no route to host" for a LAN address when the terminal app lacks the Local Network permission.
177
+ error:
178
+ (e as { code?: string }).code === "EHOSTUNREACH" &&
179
+ process.platform === "darwin"
180
+ ? `${error} If the device is up, allow this terminal under System Settings > Privacy & Security > Local Network.`
181
+ : error,
182
+ code: "network",
183
+ };
154
184
  return { error, code: "error" };
155
185
  }
156
186
 
@@ -1,9 +1,34 @@
1
1
  // Printers without a config: what `home status`, `home printers` and `home probe` run. Cached addresses first, then
2
- // discovery (AirPrint over mDNS) finds the rest. Read-only; the integration has no writes yet.
3
- import { printers } from "../connectors/printers/index.ts";
2
+ // discovery (AirPrint over mDNS) finds the rest.
3
+ import { spawnSync } from "node:child_process";
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import { basename } from "node:path";
6
+ import {
7
+ cancelJob,
8
+ getPrinterAttributes,
9
+ identifyPrinter,
10
+ type PrinterAddress,
11
+ type PrintJob,
12
+ printJob,
13
+ validateJob,
14
+ } from "../connectors/printers/client.ts";
15
+ import { attributesOf } from "../connectors/printers/codec.ts";
16
+ import {
17
+ printerList,
18
+ printers,
19
+ type PrintersState,
20
+ } from "../connectors/printers/index.ts";
21
+ import {
22
+ type GreyPage,
23
+ mediaPixels,
24
+ pwgRaster,
25
+ type RasterMedia,
26
+ } from "../connectors/printers/raster.ts";
27
+ import { printerKey } from "../connectors/printers/state.ts";
4
28
  import { addressBook } from "../discover.ts";
5
29
  import { FINDERS } from "../finders.ts";
6
- import { addresses } from "./context.ts";
30
+ import { settle } from "../home.ts";
31
+ import { addresses, fail, usage, type Verbs } from "./context.ts";
7
32
 
8
33
  /** Every printer the address cache knows plus whatever discovery finds, read once. */
9
34
  export function printersNow() {
@@ -19,3 +44,229 @@ export function printersNow() {
19
44
  seen: (uuid, a) => book.seen("printers", uuid, a),
20
45
  });
21
46
  }
47
+
48
+ // `home printers identify | print | cancel`: the writes. A printer that lists no PDF gets PWG Raster, rendered here with
49
+ // poppler's pdftoppm (the one tool this needs; `brew install poppler`, `apt install poppler-utils`).
50
+ // Live 2026-11-12 on a Brother HL-L2350DW (Bun 1.4.4): identify, validate and one A4 page.
51
+
52
+ export const PRINTERS_USAGE =
53
+ "usage: home printers identify [printer] | print <file.pdf> [printer] | cancel <job> [printer]";
54
+
55
+ /** One printer by name or UUID, or the only one there is; with its address. */
56
+ async function onePrinter(name: string | undefined) {
57
+ const part = (await settle([printersNow()], { ms: 15_000 })).printers!;
58
+ const all = printerList(
59
+ (part.state as PrintersState | undefined) ?? { printers: {}, asOf: "" },
60
+ );
61
+ const p = name
62
+ ? (all.find((x) => x.uuid === printerKey(name)) ??
63
+ all.find((x) => x.name.toLowerCase() === name.toLowerCase()))
64
+ : all.length === 1
65
+ ? all[0]
66
+ : undefined;
67
+ if (!p)
68
+ return fail(
69
+ name
70
+ ? `no printer named ${name}; \`home printers\` lists them`
71
+ : all.length
72
+ ? `${all.length} printers: name one (${all.map((x) => x.name).join(", ")})`
73
+ : "no printer found; `home discover` looks for them",
74
+ name || !all.length ? "error" : "usage",
75
+ );
76
+ if (p.state === "offline" || !p.host)
77
+ return fail(`${p.name} is offline`, "network");
78
+ const address: PrinterAddress = {
79
+ host: p.host,
80
+ port: p.port,
81
+ rp: p.rp,
82
+ secure: p.ipps,
83
+ };
84
+ return { printer: p, address };
85
+ }
86
+
87
+ /** What the printer takes, asked for once before a print. */
88
+ async function capabilities(address: PrinterAddress) {
89
+ const a = attributesOf(
90
+ await getPrinterAttributes(fetch, address, {
91
+ requested: [
92
+ "document-format-supported",
93
+ "pwg-raster-document-resolution-supported",
94
+ "pwg-raster-document-type-supported",
95
+ "media-ready",
96
+ "media-default",
97
+ ],
98
+ }),
99
+ );
100
+ const strings = (k: string) =>
101
+ (a[k] ?? []).filter((v): v is string => typeof v === "string");
102
+ const dpis = (a["pwg-raster-document-resolution-supported"] ?? []).flatMap(
103
+ (r) =>
104
+ typeof r === "object" && "x" in r && r.units === "dpi" && r.x === r.y
105
+ ? [r.x]
106
+ : [],
107
+ );
108
+ return {
109
+ formats: strings("document-format-supported"),
110
+ rasterTypes: strings("pwg-raster-document-type-supported"),
111
+ dpis,
112
+ media: strings("media-ready")[0] ?? strings("media-default")[0],
113
+ };
114
+ }
115
+
116
+ /**
117
+ * A PDF's pages as 8-bit grey, one at a time, each fitted to the media and centred on white, through pdfinfo and
118
+ * pdftoppm. A page's `/Rotate` is applied, as pdftoppm does; a landscape page is shrunk to fit, not turned.
119
+ */
120
+ function renderPdf(
121
+ file: string,
122
+ media: RasterMedia,
123
+ ): { count: number; pages: Iterable<GreyPage> } {
124
+ const run = (command: string, args: string[]) => {
125
+ const r = spawnSync(command, args, { maxBuffer: 1 << 30 });
126
+ if (r.error)
127
+ fail(
128
+ `${command} is not installed: it renders the PDF for a printer that takes no PDF`,
129
+ "error",
130
+ process.platform === "darwin"
131
+ ? "brew install poppler"
132
+ : "sudo apt install poppler-utils",
133
+ );
134
+ if (r.status !== 0) fail(`${command}: ${r.stderr.toString().trim()}`);
135
+ return r.stdout;
136
+ };
137
+ const info = run("pdfinfo", ["-l", "9999", file]).toString();
138
+ const turned = new Map(
139
+ [...info.matchAll(/^Page\s+(\d+) rot:\s+(\d+)/gm)].map((m) => [
140
+ Number(m[1]),
141
+ Number(m[2]) % 180 === 90,
142
+ ]),
143
+ );
144
+ const sizes = [
145
+ ...info.matchAll(/^Page\s+(\d+) size:\s+([\d.]+) x ([\d.]+) pts/gm),
146
+ ].map((m) =>
147
+ turned.get(Number(m[1]))
148
+ ? { w: Number(m[3]), h: Number(m[2]) }
149
+ : { w: Number(m[2]), h: Number(m[3]) },
150
+ );
151
+ if (!sizes.length) fail(`${file}: pdfinfo found no pages`);
152
+ const out = mediaPixels(media);
153
+ function* pages(): Generator<GreyPage> {
154
+ for (const [i, size] of sizes.entries()) {
155
+ // The largest resolution at which the page fits the media, so nothing is cut off; never above the printer's.
156
+ const r = Math.min(
157
+ media.dpi,
158
+ (out.width / size.w) * 72,
159
+ (out.height / size.h) * 72,
160
+ );
161
+ const pgm = run("pdftoppm", [
162
+ "-gray",
163
+ "-r",
164
+ r.toFixed(3),
165
+ "-f",
166
+ String(i + 1),
167
+ "-l",
168
+ String(i + 1),
169
+ "-singlefile",
170
+ file,
171
+ ]);
172
+ const head = /^P5\s+(\d+)\s+(\d+)\s+255\s/.exec(
173
+ pgm.subarray(0, 64).toString("latin1"),
174
+ );
175
+ if (!head)
176
+ return fail(`pdftoppm: page ${i + 1} is not an 8-bit grey image`);
177
+ const w = Number(head[1]);
178
+ const h = Number(head[2]);
179
+ // A pixel over is rounding; more means the size was misread, and a clipped page must not print.
180
+ if (w > out.width + 1 || h > out.height + 1)
181
+ return fail(
182
+ `page ${i + 1} rendered at ${w} × ${h}, larger than ${media.name} (${out.width} × ${out.height}); nothing sent`,
183
+ );
184
+ const pixels = new Uint8Array(out.width * out.height).fill(255);
185
+ const left = Math.max(0, Math.floor((out.width - w) / 2));
186
+ const top = Math.max(0, Math.floor((out.height - h) / 2));
187
+ const from = pgm.subarray(head[0].length);
188
+ for (let y = 0; y < Math.min(h, out.height); y++)
189
+ pixels.set(
190
+ from.subarray(y * w, y * w + Math.min(w, out.width)),
191
+ (top + y) * out.width + left,
192
+ );
193
+ yield { ...out, pixels };
194
+ }
195
+ }
196
+ return { count: sizes.length, pages: pages() };
197
+ }
198
+
199
+ const STATES: Record<number, string> = {
200
+ 3: "pending",
201
+ 4: "held",
202
+ 5: "printing",
203
+ 6: "stopped",
204
+ 7: "cancelled",
205
+ 8: "aborted",
206
+ 9: "completed",
207
+ };
208
+
209
+ export const printersVerbs: Verbs = {
210
+ async identify([name, extra], o) {
211
+ if (o.dryRun) usage("home printers identify has no dry run");
212
+ if (extra) usage(PRINTERS_USAGE);
213
+ const { printer, address } = await onePrinter(name);
214
+ await identifyPrinter(fetch, address);
215
+ console.log(`${printer.name}: identify sent`);
216
+ },
217
+
218
+ async print([file, name, extra], o) {
219
+ if (!file || extra) return usage(PRINTERS_USAGE);
220
+ if (!/\.pdf$/i.test(file)) return usage(`${file}: only a PDF prints`);
221
+ if (!existsSync(file)) return usage(`${file}: no such file`);
222
+ const { printer, address } = await onePrinter(name);
223
+ const can = await capabilities(address);
224
+ const job: PrintJob = {
225
+ format: "application/pdf",
226
+ name: basename(file),
227
+ media: can.media,
228
+ };
229
+ let document: Uint8Array | undefined;
230
+ let what = `${basename(file)} as PDF`;
231
+ if (!can.formats.includes("application/pdf")) {
232
+ const dpi = can.dpis.includes(600) ? 600 : can.dpis[0];
233
+ if (!can.formats.includes("image/pwg-raster") || !dpi)
234
+ return fail(
235
+ `${printer.name} takes neither PDF nor PWG Raster (it lists ${can.formats.join(", ") || "nothing"})`,
236
+ );
237
+ if (!can.rasterTypes.includes("sgray_8"))
238
+ return fail(
239
+ `${printer.name}'s PWG Raster is ${can.rasterTypes.join(", ")}; only sgray_8 is built`,
240
+ );
241
+ if (!can.media)
242
+ return fail(`${printer.name} reports no media loaded or default`);
243
+ const { count, pages } = renderPdf(file, { name: can.media, dpi });
244
+ document = pwgRaster(pages, { name: can.media, dpi }, count);
245
+ job.format = "image/pwg-raster";
246
+ what = `${count} page${count === 1 ? "" : "s"} of ${can.media} at ${dpi} dpi as PWG Raster (${Math.round(document.length / 1024)} kB)`;
247
+ }
248
+ await validateJob(fetch, address, job); // prints nothing: a dry run checks the job as the printer would
249
+ if (o.dryRun)
250
+ return console.log(
251
+ `dry run, no job sent: the printer accepts it; would print ${what} on ${printer.name}`,
252
+ );
253
+ const done = await printJob(
254
+ fetch,
255
+ address,
256
+ job,
257
+ document ?? readFileSync(file),
258
+ );
259
+ console.log(
260
+ `${printer.name}: job ${done.jobId ?? "?"} ${STATES[done.state ?? 0] ?? "sent"} · ${what}`,
261
+ );
262
+ },
263
+
264
+ async cancel([id, name, extra], o) {
265
+ if (o.dryRun) usage("home printers cancel has no dry run");
266
+ const job = Number(id);
267
+ if (!Number.isInteger(job) || extra) return usage(PRINTERS_USAGE);
268
+ const { printer, address } = await onePrinter(name);
269
+ await cancelJob(fetch, address, job);
270
+ console.log(`${printer.name}: job ${job} cancelled`);
271
+ },
272
+ };