@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.
- package/.env.example +2 -2
- package/CHANGELOG.md +56 -1
- package/README.md +7 -2
- package/dist/config.d.ts +5 -3
- package/dist/config.js +5 -1
- package/dist/connector.d.ts +2 -0
- package/dist/connector.js +3 -0
- package/dist/connectors/amber/client.d.ts +1 -1
- package/dist/connectors/amber/client.js +1 -1
- package/dist/connectors/amber/index.js +2 -2
- package/dist/connectors/cast/index.d.ts +1 -1
- package/dist/connectors/cast/index.js +1 -1
- package/dist/connectors/cast/state.d.ts +17 -3
- package/dist/connectors/cast/state.js +26 -7
- package/dist/connectors/daikin/client.d.ts +45 -2
- package/dist/connectors/daikin/client.js +67 -2
- package/dist/connectors/daikin/index.d.ts +2 -2
- package/dist/connectors/daikin/index.js +4 -3
- package/dist/connectors/enphase/client.d.ts +1 -1
- package/dist/connectors/enphase/client.js +1 -1
- package/dist/connectors/enphase/index.js +2 -1
- package/dist/connectors/hue/ca.d.ts +2 -0
- package/dist/connectors/hue/ca.js +38 -0
- package/dist/connectors/hue/client.d.ts +81 -6
- package/dist/connectors/hue/client.js +217 -6
- package/dist/connectors/hue/color.d.ts +30 -0
- package/dist/connectors/hue/color.js +92 -0
- package/dist/connectors/hue/events.d.ts +0 -2
- package/dist/connectors/hue/events.js +1 -2
- package/dist/connectors/hue/index.d.ts +15 -4
- package/dist/connectors/hue/index.js +27 -10
- package/dist/connectors/hue/schemas.d.ts +27 -0
- package/dist/connectors/hue/schemas.js +26 -0
- package/dist/connectors/hue/state.d.ts +10 -1
- package/dist/connectors/hue/state.js +20 -1
- package/dist/connectors/printers/client.d.ts +41 -6
- package/dist/connectors/printers/client.js +81 -8
- package/dist/connectors/printers/codec.d.ts +17 -3
- package/dist/connectors/printers/codec.js +32 -8
- package/dist/connectors/printers/index.d.ts +2 -0
- package/dist/connectors/printers/index.js +4 -0
- package/dist/connectors/printers/raster.d.ts +30 -0
- package/dist/connectors/printers/raster.js +175 -0
- package/dist/connectors/printers/state.d.ts +2 -0
- package/dist/connectors/spotify/changes.d.ts +19 -5
- package/dist/connectors/spotify/changes.js +29 -7
- package/dist/connectors/spotify/index.d.ts +1 -1
- package/dist/connectors/spotify/index.js +20 -3
- package/dist/connectors/up/client.d.ts +1 -1
- package/dist/connectors/up/client.js +1 -1
- package/dist/connectors/up/index.js +2 -2
- package/dist/connectors/weather/google.js +2 -2
- package/dist/devices.d.ts +24 -0
- package/dist/devices.js +56 -2
- package/dist/discover.js +1 -1
- package/dist/finders-entry.d.ts +1 -1
- package/dist/finders-entry.js +1 -1
- package/dist/finders.d.ts +2 -1
- package/dist/finders.js +16 -5
- package/dist/home.d.ts +11 -2
- package/dist/home.js +98 -34
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/node-fetch.d.ts +2 -2
- package/dist/node-fetch.js +3 -2
- package/docs/cli.md +15 -7
- package/docs/config.md +11 -3
- package/docs/decisions.md +19 -0
- package/docs/hue.md +161 -0
- package/docs/ideas.md +2 -0
- package/docs/using.md +98 -3
- package/examples/home.jsonc +2 -1
- package/home.schema.json +10 -3
- package/llms.txt +19 -6
- package/package.json +1 -1
- package/skills/draw-home/README.md +2 -0
- package/skills/draw-home/SKILL.md +1 -1
- package/skills/use-home/SKILL.md +3 -3
- package/src/cli/cast.ts +51 -5
- package/src/cli/context.ts +2 -0
- package/src/cli/daikin.ts +71 -0
- package/src/cli/enphase.ts +31 -5
- package/src/cli/hue.ts +147 -59
- package/src/cli/main.ts +43 -13
- package/src/cli/printers.ts +254 -3
- package/src/cli/probe.ts +21 -17
- package/src/cli/status.ts +61 -10
- package/src/config.ts +8 -2
- package/src/connector.ts +5 -0
- package/src/connectors/amber/README.md +1 -1
- package/src/connectors/amber/client.ts +1 -1
- package/src/connectors/amber/index.ts +2 -2
- package/src/connectors/cast/README.md +6 -3
- package/src/connectors/cast/index.ts +7 -1
- package/src/connectors/cast/state.ts +32 -5
- package/src/connectors/daikin/README.md +16 -4
- package/src/connectors/daikin/client.ts +104 -3
- package/src/connectors/daikin/index.ts +8 -2
- package/src/connectors/enphase/README.md +3 -1
- package/src/connectors/enphase/client.ts +1 -1
- package/src/connectors/enphase/index.ts +2 -1
- package/src/connectors/hue/README.md +62 -17
- package/src/connectors/hue/ca.ts +39 -0
- package/src/connectors/hue/client.ts +362 -15
- package/src/connectors/hue/color.ts +123 -0
- package/src/connectors/hue/events.ts +1 -4
- package/src/connectors/hue/index.ts +46 -13
- package/src/connectors/hue/schemas.ts +26 -0
- package/src/connectors/hue/state.ts +27 -1
- package/src/connectors/printers/README.md +9 -4
- package/src/connectors/printers/client.ts +186 -12
- package/src/connectors/printers/codec.ts +44 -10
- package/src/connectors/printers/index.ts +17 -0
- package/src/connectors/printers/raster.ts +215 -0
- package/src/connectors/printers/state.ts +2 -0
- package/src/connectors/spotify/README.md +10 -2
- package/src/connectors/spotify/changes.ts +38 -9
- package/src/connectors/spotify/index.ts +21 -2
- package/src/connectors/up/README.md +1 -1
- package/src/connectors/up/client.ts +1 -1
- package/src/connectors/up/index.ts +2 -2
- package/src/connectors/weather/google.ts +6 -2
- package/src/devices.ts +83 -3
- package/src/discover.ts +1 -2
- package/src/finders-entry.ts +1 -1
- package/src/finders.ts +18 -6
- package/src/home.ts +99 -39
- package/src/index.ts +7 -1
- 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
|
-
|
|
7
|
-
|
|
7
|
+
type GroupView,
|
|
8
|
+
type HueApplied,
|
|
9
|
+
type HueRequest,
|
|
10
|
+
HueError,
|
|
8
11
|
hue,
|
|
9
12
|
hueClient,
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
/**
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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([
|
|
67
|
-
if (
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
95
|
-
const
|
|
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
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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 (
|
|
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 {
|
|
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
|
|
package/src/cli/printers.ts
CHANGED
|
@@ -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.
|
|
3
|
-
import {
|
|
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 {
|
|
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
|
+
};
|