@khalido/home 0.2.0 → 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 (59) hide show
  1. package/CHANGELOG.md +16 -2
  2. package/dist/connector.d.ts +2 -0
  3. package/dist/connector.js +3 -0
  4. package/dist/connectors/amber/client.d.ts +1 -1
  5. package/dist/connectors/amber/client.js +1 -1
  6. package/dist/connectors/amber/index.js +2 -2
  7. package/dist/connectors/enphase/client.d.ts +1 -1
  8. package/dist/connectors/enphase/client.js +1 -1
  9. package/dist/connectors/enphase/index.js +2 -1
  10. package/dist/connectors/hue/events.d.ts +0 -2
  11. package/dist/connectors/hue/events.js +1 -2
  12. package/dist/connectors/hue/index.js +2 -2
  13. package/dist/connectors/printers/client.d.ts +41 -6
  14. package/dist/connectors/printers/client.js +81 -8
  15. package/dist/connectors/printers/codec.d.ts +17 -3
  16. package/dist/connectors/printers/codec.js +32 -8
  17. package/dist/connectors/printers/index.d.ts +2 -0
  18. package/dist/connectors/printers/index.js +4 -0
  19. package/dist/connectors/printers/raster.d.ts +30 -0
  20. package/dist/connectors/printers/raster.js +175 -0
  21. package/dist/connectors/printers/state.d.ts +2 -0
  22. package/dist/connectors/spotify/changes.d.ts +1 -1
  23. package/dist/connectors/spotify/changes.js +1 -1
  24. package/dist/connectors/spotify/index.js +12 -1
  25. package/dist/connectors/up/client.d.ts +1 -1
  26. package/dist/connectors/up/client.js +1 -1
  27. package/dist/connectors/up/index.js +2 -2
  28. package/dist/connectors/weather/google.js +2 -2
  29. package/docs/cli.md +2 -0
  30. package/docs/decisions.md +7 -0
  31. package/docs/ideas.md +2 -0
  32. package/package.json +1 -1
  33. package/skills/draw-home/README.md +2 -0
  34. package/skills/use-home/SKILL.md +1 -1
  35. package/src/cli/enphase.ts +31 -5
  36. package/src/cli/main.ts +8 -3
  37. package/src/cli/printers.ts +254 -3
  38. package/src/connector.ts +5 -0
  39. package/src/connectors/amber/README.md +1 -1
  40. package/src/connectors/amber/client.ts +1 -1
  41. package/src/connectors/amber/index.ts +2 -2
  42. package/src/connectors/enphase/README.md +3 -1
  43. package/src/connectors/enphase/client.ts +1 -1
  44. package/src/connectors/enphase/index.ts +2 -1
  45. package/src/connectors/hue/events.ts +1 -4
  46. package/src/connectors/hue/index.ts +2 -2
  47. package/src/connectors/printers/README.md +9 -4
  48. package/src/connectors/printers/client.ts +186 -12
  49. package/src/connectors/printers/codec.ts +44 -10
  50. package/src/connectors/printers/index.ts +17 -0
  51. package/src/connectors/printers/raster.ts +215 -0
  52. package/src/connectors/printers/state.ts +2 -0
  53. package/src/connectors/spotify/README.md +3 -1
  54. package/src/connectors/spotify/changes.ts +1 -1
  55. package/src/connectors/spotify/index.ts +12 -1
  56. package/src/connectors/up/README.md +1 -1
  57. package/src/connectors/up/client.ts +1 -1
  58. package/src/connectors/up/index.ts +2 -2
  59. package/src/connectors/weather/google.ts +6 -2
@@ -0,0 +1,175 @@
1
+ // PWG Raster (PWG 5102.4), 8-bit grey only: what an AirPrint printer that lists `image/pwg-raster` takes when it lists no
2
+ // PDF. A file is "RaS2", then per page a 1796-byte big-endian header and the page's pixels, compressed line by line:
3
+ // a line-repeat byte (the line appears n + 1 times), then runs: 0 to 127 is one pixel repeated n + 1 times, 129 to 255 is
4
+ // 257 − n pixels as they are. Checked against a page macOS's own filters made for a Brother (test/fixtures/printers).
5
+ const HEADER = 1796;
6
+ const SGRAY = 18; // cupsColorSpace for sgray (PWG 5102.4 table 9)
7
+ const WHITE = 0xffffff; // AlternatePrimary: the white point, as PWG 5102.4 recommends
8
+ /** A PWG media name's size in inches: `iso_a4_210x297mm` is 8.27 × 11.69. */
9
+ function mediaInches(name) {
10
+ const m = /_(\d+(?:\.\d+)?)x(\d+(?:\.\d+)?)(mm|in)$/.exec(name);
11
+ if (!m)
12
+ throw new RangeError(`not a PWG media size: ${name}`);
13
+ const per = m[3] === "mm" ? 1 / 25.4 : 1;
14
+ return { width: Number(m[1]) * per, height: Number(m[2]) * per };
15
+ }
16
+ /** The pixels a page of this media is at its resolution, rounded down as macOS's filters do: A4 at 600 dpi is 4960 × 7015. */
17
+ export function mediaPixels(media) {
18
+ const i = mediaInches(media.name);
19
+ return {
20
+ width: Math.floor(i.width * media.dpi),
21
+ height: Math.floor(i.height * media.dpi),
22
+ };
23
+ }
24
+ function header(page, media, total) {
25
+ const h = new Uint8Array(HEADER);
26
+ const v = new DataView(h.buffer);
27
+ const u32 = (offset, n) => v.setUint32(offset, n);
28
+ const str = (offset, s) => h.set(new TextEncoder().encode(s).subarray(0, 63), offset);
29
+ const pt = mediaPixels({ name: media.name, dpi: 72 }); // PageSize is in points
30
+ str(0, "PwgRaster"); // MediaClass
31
+ u32(276, media.dpi); // HWResolution
32
+ u32(280, media.dpi);
33
+ u32(340, 1); // NumCopies: copies are the job's, not the page's
34
+ u32(352, pt.width); // PageSize, points
35
+ u32(356, pt.height);
36
+ u32(372, page.width); // Width, Height in pixels
37
+ u32(376, page.height);
38
+ u32(384, 8); // BitsPerColor
39
+ u32(388, 8); // BitsPerPixel
40
+ u32(392, page.width); // BytesPerLine
41
+ u32(400, SGRAY); // ColorSpace; ColorOrder (396) 0 is chunky
42
+ u32(420, 1); // NumColors
43
+ u32(452, total); // TotalPageCount
44
+ u32(456, 1); // CrossFeedTransform
45
+ u32(460, 1); // FeedTransform
46
+ u32(480, WHITE); // AlternatePrimary
47
+ str(1732, media.name); // PageSizeName
48
+ return h;
49
+ }
50
+ /** One line's runs, appended to `out`. */
51
+ function packLine(line, out) {
52
+ let i = 0;
53
+ while (i < line.length) {
54
+ let run = 1;
55
+ while (run < 128 && i + run < line.length && line[i + run] === line[i])
56
+ run++;
57
+ if (run > 1 || i + 1 === line.length) {
58
+ out.push(run - 1, line[i]);
59
+ i += run;
60
+ continue;
61
+ }
62
+ // Literal: up to 128 pixels, stopping before two equal pixels that would pack better as a repeat.
63
+ let n = 1;
64
+ while (n < 128 &&
65
+ i + n < line.length &&
66
+ !(i + n + 1 < line.length && line[i + n] === line[i + n + 1]))
67
+ n++;
68
+ if (n === 1) {
69
+ out.push(0, line[i]); // a lone pixel is a repeat of one: 257 − 256 can't be written
70
+ i += 1;
71
+ continue;
72
+ }
73
+ out.push(257 - n);
74
+ for (let k = 0; k < n; k++)
75
+ out.push(line[i + k]);
76
+ i += n;
77
+ }
78
+ }
79
+ /**
80
+ * Pages as one PWG Raster document, `image/pwg-raster`. Every page must be `width × height` bytes. Pages may come one at
81
+ * a time (a generator), so only the packed document and the current page are held; then `total` is the page count.
82
+ */
83
+ export function pwgRaster(pages, media, total = Array.isArray(pages) ? pages.length : 0) {
84
+ const chunks = [new TextEncoder().encode("RaS2")];
85
+ for (const page of pages) {
86
+ if (page.pixels.length !== page.width * page.height)
87
+ throw new RangeError(`a ${page.width} × ${page.height} page needs ${page.width * page.height} bytes, got ${page.pixels.length}`);
88
+ chunks.push(header(page, media, total));
89
+ let y = 0;
90
+ while (y < page.height) {
91
+ const line = page.pixels.subarray(y * page.width, (y + 1) * page.width);
92
+ let repeat = 0;
93
+ while (repeat < 255 &&
94
+ y + repeat + 1 < page.height &&
95
+ equal(line, page.pixels.subarray((y + repeat + 1) * page.width, (y + repeat + 2) * page.width)))
96
+ repeat++;
97
+ const out = [repeat]; // one line at a time: a page as a number[] would be hundreds of MB
98
+ packLine(line, out);
99
+ chunks.push(Uint8Array.from(out));
100
+ y += repeat + 1;
101
+ }
102
+ }
103
+ const size = chunks.reduce((n, c) => n + c.length, 0);
104
+ const file = new Uint8Array(size);
105
+ let at = 0;
106
+ for (const c of chunks)
107
+ (file.set(c, at), (at += c.length));
108
+ return file;
109
+ }
110
+ function equal(a, b) {
111
+ if (a.length !== b.length)
112
+ return false;
113
+ for (let i = 0; i < a.length; i++)
114
+ if (a[i] !== b[i])
115
+ return false;
116
+ return true;
117
+ }
118
+ /** A PWG Raster document back as pages and their headers' key fields; for tests and for checking a file before it prints. */
119
+ export function readPwgRaster(file) {
120
+ const text = new TextDecoder();
121
+ if (text.decode(file.subarray(0, 4)) !== "RaS2")
122
+ throw new Error("pwg raster: no RaS2 sync word");
123
+ const pages = [];
124
+ let at = 4;
125
+ while (at < file.length) {
126
+ const v = new DataView(file.buffer, file.byteOffset + at, HEADER);
127
+ const width = v.getUint32(372);
128
+ const height = v.getUint32(376);
129
+ const bitsPerPixel = v.getUint32(388);
130
+ const bytes = bitsPerPixel / 8;
131
+ const name = file.subarray(at + 1732, at + 1796);
132
+ const pageSizeName = text.decode(name.subarray(0, name.indexOf(0)));
133
+ const page = {
134
+ width,
135
+ height,
136
+ dpi: v.getUint32(276),
137
+ pageSizeName,
138
+ colorSpace: v.getUint32(400),
139
+ bitsPerPixel,
140
+ pixels: new Uint8Array(width * height * bytes),
141
+ };
142
+ at += HEADER;
143
+ const lineBytes = width * bytes;
144
+ let y = 0;
145
+ while (y < height) {
146
+ const repeat = file[at++] + 1;
147
+ const line = new Uint8Array(lineBytes);
148
+ let x = 0;
149
+ while (x < lineBytes) {
150
+ const c = file[at++];
151
+ if (c === 128) {
152
+ line.fill(255, x); // the rest of the line is white
153
+ break;
154
+ }
155
+ if (c < 128) {
156
+ const px = file.subarray(at, at + bytes);
157
+ for (let k = 0; k <= c; k++)
158
+ line.set(px, x + k * bytes);
159
+ at += bytes;
160
+ x += (c + 1) * bytes;
161
+ }
162
+ else {
163
+ const n = (257 - c) * bytes;
164
+ line.set(file.subarray(at, at + n), x);
165
+ at += n;
166
+ x += n;
167
+ }
168
+ }
169
+ for (let k = 0; k < repeat && y < height; k++, y++)
170
+ page.pixels.set(line, y * lineBytes);
171
+ }
172
+ pages.push(page);
173
+ }
174
+ return pages;
175
+ }
@@ -18,6 +18,8 @@ export type Printer = {
18
18
  name: string;
19
19
  model?: string;
20
20
  host?: string;
21
+ port?: number;
22
+ rp?: string;
21
23
  room?: string;
22
24
  state?: PrinterStatus;
23
25
  stateCode?: number;
@@ -41,6 +41,6 @@ export declare function spotifyChanges(before: Part<SpotifyState> | undefined, a
41
41
  /**
42
42
  * The speaker in a few words: `Kitchen speaker: playing "Song" by Band · 35%`, `…: waiting for someone to pick it`, or,
43
43
  * for music on another Spotify device, `…: silent · "Song" by Band is playing on another Spotify device` (no volume: it isn't this speaker's playing level).
44
- * `on` names where, when the caller knows (`spotifyElsewhere`): `… is playing on Front room speaker`.
44
+ * `on` names where, when the caller knows (`spotifyElsewhere`): `… is playing on Den speaker`.
45
45
  */
46
46
  export declare function spotifyText(s: SpotifyState, on?: string[]): string;
@@ -87,7 +87,7 @@ export function spotifyChanges(before, after) {
87
87
  /**
88
88
  * The speaker in a few words: `Kitchen speaker: playing "Song" by Band · 35%`, `…: waiting for someone to pick it`, or,
89
89
  * for music on another Spotify device, `…: silent · "Song" by Band is playing on another Spotify device` (no volume: it isn't this speaker's playing level).
90
- * `on` names where, when the caller knows (`spotifyElsewhere`): `… is playing on Front room speaker`.
90
+ * `on` names where, when the caller knows (`spotifyElsewhere`): `… is playing on Den speaker`.
91
91
  */
92
92
  export function spotifyText(s, on = []) {
93
93
  const who = s.deviceName ?? "Soloist";
@@ -228,6 +228,17 @@ export function spotify(config = {}) {
228
228
  * one raised before it is reported can't be told from the start's own jump, so it is lowered too.
229
229
  */
230
230
  const applyCap = () => {
231
+ // Lowered, then above the cap again in the same window: Soloist putting its own level back (seen live) or a person.
232
+ // The note says where it is now, not only that it was lowered.
233
+ if (capped && capNote && cap !== undefined && state.volume !== undefined) {
234
+ const base = capNote.replace(/; it is back at \d+$/, "");
235
+ const note = state.volume > cap + VOLUME_SLACK
236
+ ? `${base}; it is back at ${state.volume}`
237
+ : base;
238
+ if (note !== capNote)
239
+ ((capNote = note), publish());
240
+ return;
241
+ }
231
242
  if (cap === undefined ||
232
243
  capped ||
233
244
  capping ||
@@ -247,7 +258,7 @@ export function spotify(config = {}) {
247
258
  capped = true;
248
259
  if (state.status === "idle")
249
260
  return;
250
- capNote = `started at volume ${from}, above maxVolume ${cap}; lowered it (Soloist issue #23)`;
261
+ capNote = `started at volume ${from}, above maxVolume ${cap}; lowered it to ${cap} (Soloist issue #23)`;
251
262
  note = undefined;
252
263
  return publish();
253
264
  }
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { ConnectorError } from "../../connector.ts";
3
- /** A failed Up call. `status` 401 means the token is wrong or revoked; 429 the rate limit (unpublished: read `X-RateLimit-Remaining`). */
3
+ /** A failed Up call. `status` 401 or 403 (`isRejectedKey`) means the token is wrong or revoked; 429 the rate limit (unpublished: read `X-RateLimit-Remaining`). */
4
4
  export declare class UpError extends ConnectorError {
5
5
  }
6
6
  declare const TransactionResource: z.ZodObject<{
@@ -3,7 +3,7 @@ import { ConnectorError, fetchTimeout, getJson } from "../../connector.js";
3
3
  // Up Bank's API v1 (https://developer.up.com.au/, spec github.com/up-banking/api, v1/openapi.json). Field names checked
4
4
  // against one live read, 2026-10-16. Every field optional so a change degrades a reading rather than throwing.
5
5
  const BASE = "https://api.up.com.au/api/v1";
6
- /** A failed Up call. `status` 401 means the token is wrong or revoked; 429 the rate limit (unpublished: read `X-RateLimit-Remaining`). */
6
+ /** A failed Up call. `status` 401 or 403 (`isRejectedKey`) means the token is wrong or revoked; 429 the rate limit (unpublished: read `X-RateLimit-Remaining`). */
7
7
  export class UpError extends ConnectorError {
8
8
  }
9
9
  const Money = z.looseObject({
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { asOf, realClock } from "../../clock.js";
3
- import { errorText } from "../../connector.js";
3
+ import { errorText, isRejectedKey } from "../../connector.js";
4
4
  import { envName } from "../../home.js";
5
5
  import { poller } from "../../poll.js";
6
6
  import { upClient, UpError, } from "./client.js";
@@ -139,7 +139,7 @@ export function up(config) {
139
139
  catch (error) {
140
140
  if (signal.aborted)
141
141
  return; // stopped mid-read: not a failure
142
- if (error instanceof UpError && error.status === 401)
142
+ if (isRejectedKey(error))
143
143
  return update({
144
144
  status: "unavailable",
145
145
  reason: "auth",
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { ConnectorError, fetchTimeout } from "../../connector.js";
2
+ import { ConnectorError, fetchTimeout, isRejectedKey, } from "../../connector.js";
3
3
  import { RAIN_INTENSITIES } from "./rain.js";
4
4
  import { sunDisagrees } from "./sun.js";
5
5
  import { HOURLY_MINUTES, iso, nextPoll, num, RAIN_HORIZON_MINUTES, USER_AGENT, } from "./source.js";
@@ -360,7 +360,7 @@ export function google(options) {
360
360
  ? error
361
361
  : {};
362
362
  const said = `HTTP ${status}${why ? ` ${why}` : ""}`;
363
- if (status === 401 || status === 403 || why?.startsWith("API_KEY_"))
363
+ if (isRejectedKey(error) || why?.startsWith("API_KEY_"))
364
364
  return {
365
365
  stop: `google: the key was rejected (${said})`,
366
366
  stopReason: "auth",
package/docs/cli.md CHANGED
@@ -18,6 +18,8 @@ home discover [--json] [--passes n] # which smart-home devices answer here (mDN
18
18
  home cache [clear] # the addresses kept between runs, or delete them so the next run discovers afresh
19
19
  home config check [path] # validate a home config: what it would set up, secrets as set or unset by name, every problem
20
20
  home discover --write [path] # with a config (the path, else --config or HOME_CONFIG): match what answers by identity, add new devices, update moved hosts and renamed Cast names in place
21
+ home printers print <file.pdf> [printer] # PDF as is, or rendered to PWG Raster with pdftoppm when the printer takes no PDF; --dry-run
22
+ home printers identify | cancel <job> # the printer shows itself; stop a job
21
23
  home probe # read-only, no keys: Hue's public config, Daikin by UDP broadcast, the IQ Gateway's /info, each weather source, each Cast device's and printer's status
22
24
  home locate "<street address>" [--write [path]] # latitude and longitude from OpenStreetMap's Nominatim; --write sets them in the config's location (and timezone, when absent, to this machine's)
23
25
  home cast announce --to <name> [--to <name>…] (--say "<text>" | --file <path>) [--volume 0-100] # exit 1 if any didn't play; play a clip, then put back what was playing and the volume
package/docs/decisions.md CHANGED
@@ -165,3 +165,10 @@ KO's decisions on this package, newest block last. They override every other doc
165
165
  - **Spotify playing on another device reads as `elsewhere`, not `playing`** (KO, 2026-11-11; chota raised it three times). Soloist reports the account's playback, not the box's, so `playing` while inactive is a silent speaker. `speakerState`, `spotifyText` and `spotifyChanges` all go through one test, `playingElsewhere`. Paused while inactive is left as Soloist says: nothing live shows whose pause it is. Soloist never names the other device, so where is a join with Cast by track name (`spotifyElsewhere`), passed to `spotifyText(state, on)` by `homeDevices`, which sees both parts; the speaker's own part can't know it, so `home status` says "another Spotify device".
166
166
  - **`home.add` replaces by name and starts the part over** (KO, 2026-11-11; chota pairs Hue from a chat). The old run ends before the new one starts, so a device never has two streams; the new part is a bare `connecting`, since the old state belonged to another key or address. No type widening: a `loadHome` home is already typed for every connector, so `add` takes the home's own integration type. `remove` fires no `onChange`: a change needs an `after`.
167
167
  - **Dry run is an option on each write, not a separate client** (KO, 2026-11-12; chota asked, since KO's rule is no device write without his OK at the time). It runs the read and every check a real write runs, so a wrong name or an unreachable light fails the same way, and returns the request instead of sending it, in the same `{ state, confirmed }` shape with `wouldSend` added. Hue and Daikin first, chota's lights and aircon tools; Cast and Spotify when a tool needs them.
168
+
169
+ ## Printing (2026-11-12)
170
+
171
+ - **The library sends bytes; the CLI makes them.** `printJob` takes a document already in a format the printer lists. Many AirPrint printers (KO's Brother) take no PDF, only PWG Raster or Apple's URF, so a PDF has to be rendered. Rendering a PDF needs a PDF engine, which is not runtime-neutral; it lives in `src/cli/printers.ts` as poppler's `pdftoppm`, the one new tool. `pwgRaster` packs the pixels and stays in the library, so a consumer with its own renderer can print.
172
+ - **PWG Raster, not URF.** PWG 5102.4 is a published standard; URF is Apple's and undocumented. 8-bit grey only, until a colour printer is here.
173
+ - **Validate-Job before every Print-Job**, so a wrong attribute fails before paper moves; **`one-sided` sent explicitly**; **a timed-out Print-Job is never resent**, since the printer may have taken it.
174
+ - **No write lane yet.** The writes are one-shot calls on an address, not methods on the integration: nothing calls them in a loop. When a consumer prints from a long-running process, put them on the integration with a lane, slot per job so two prints never merge.
package/docs/ideas.md CHANGED
@@ -61,6 +61,8 @@ Two ideas from KO, 2026-10-14, not yet decided:
61
61
 
62
62
  ## Spotify search and library (KO, 2026-11-08)
63
63
 
64
+ A signed-in account's playlists and library are now [khalido/home.ts#2](https://github.com/khalido/home.ts/issues/2).
65
+
64
66
  Soloist plays URIs; it can't search or read a library. Starting music by name ("play some Seal", "my Soft 90s playlist") needs Spotify's cloud Web API beside it: search returns a URI, and the spotify connector's `play(uri)` plays it. **KO's call, 2026-11-08: search lives in chota-bot, not here.** This library turns a box into a speaker; the bot owns its music search tool. chota-bot checked live that catalogue search works with client credentials, no user sign-in, on a development-mode app, and that Soloist played the URI it found. The notes below stay as the research.
65
67
 
66
68
  - **Search without a user** (catalogue only): the Client Credentials flow reaches "only endpoints that do not access user information" ([client credentials](https://developer.spotify.com/documentation/web-api/tutorials/client-credentials-flow)). The search reference names no flow, but the scopes page lists search under `user-read-private`, so whether an app-only token can search is unsettled: one test call answers it. Search needs a `market`. Since February 2026 a development-mode app gets at most 10 results per search ([migration guide](https://developer.spotify.com/documentation/web-api/tutorials/february-2026-migration-guide)).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@khalido/home",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "home.ts: small, typed home automation for TypeScript: Hue, Daikin, Enphase, Google Cast, AirPrint printers and a Spotify Soloist speaker on the local network; Amber prices, Up Bank spending and the weather from the cloud.",
5
5
  "license": "MIT",
6
6
  "author": "Khalid Omar",
@@ -1,5 +1,7 @@
1
1
  # draw-home: references
2
2
 
3
+ Where the skill is going, from a floor plan PDF to a live view: [khalido/home.ts#3](https://github.com/khalido/home.ts/issues/3).
4
+
3
5
  Good drawing references to read before drawing a home, newest idea first. All are skills or kits for coding agents; read for craft, keep this skill's contract (`SKILL.md` section 3) where they disagree.
4
6
 
5
7
  | Reference | What it is | Use it for | Licence |
@@ -10,7 +10,7 @@ description: Use the `home` command and the @khalido/home library to see and run
10
10
  ## Rules
11
11
 
12
12
  - **Reads are free.** `home`, `home <connector>` with no verb, `home devices`, `home discover`, `home probe`, `home up spend` and every `--json` read touch nothing.
13
- - **Writes only on request.** Lights, AC, speakers, Up categories and tags, webhooks: run one only when the person asked for that change, then read back and report the state it left. Never write to "test" something: `--dry-run` on a Hue or Daikin write (`home room …`, `home scene …`, `home lights off`, `home daikin …`) checks it and prints what it would send, sending nothing; other commands refuse the flag.
13
+ - **Writes only on request.** Lights, AC, speakers, printing, Up categories and tags, webhooks: run one only when the person asked for that change, then read back and report the state it left. Never write to "test" something: `--dry-run` on a Hue or Daikin write (`home room …`, `home scene …`, `home lights off`, `home daikin …`, `home printers print …`) checks it and prints what it would send, sending nothing (a print's dry run also asks the printer to validate the job, which prints nothing); other commands refuse the flag.
14
14
  - **Never print a key, token or password**, not even part of one. Say "set" or "unset". Env names are fine; values are not.
15
15
  - **Nothing private into a public repo**: no IPs, MACs, serials, device or room names, Up transactions or balances. A real `home.jsonc`, `home.svg` and floor plan live in a private folder.
16
16
  - **Show values as read.** A missing field is unknown, not zero. An `anomaly` is something odd the device said: report it, never smooth it.
@@ -7,8 +7,16 @@ import {
7
7
  tokenExpiry,
8
8
  } from "../connectors/enphase/index.ts";
9
9
  import { daysLeft } from "../connectors/enphase/client.ts";
10
- import { writePrivate } from "../finders.ts";
11
- import { env, fail, homeTimeZone, TOKEN_FILE, type Verbs } from "./context.ts";
10
+ import { addressBook } from "../discover.ts";
11
+ import { FINDERS, writePrivate } from "../finders.ts";
12
+ import {
13
+ addresses,
14
+ env,
15
+ fail,
16
+ homeTimeZone,
17
+ TOKEN_FILE,
18
+ type Verbs,
19
+ } from "./context.ts";
12
20
 
13
21
  /** The cached Enphase token, or undefined if `home enphase login` has not run. A corrupt file throws, naming it. */
14
22
  export function enphaseToken(): { token: string; expiresAt: Date } | undefined {
@@ -31,12 +39,30 @@ export function enphaseToken(): { token: string; expiresAt: Date } | undefined {
31
39
  }
32
40
  }
33
41
 
34
- /** Enphase as an integration from IQ_GATEWAY_HOST and the token file, for `home status`. */
35
- export function enphaseFromEnv() {
42
+ /**
43
+ * Enphase as an integration for `home status`: IQ_GATEWAY_HOST, else the address cache, else the first gateway mDNS finds.
44
+ * With the gateway's serial from the cache, a read that fails is followed by discovery at once, so an IQ_GATEWAY_HOST left
45
+ * behind by a DHCP move still reads, and its detail says where the gateway went.
46
+ */
47
+ export async function enphaseFromEnv() {
36
48
  const t =
37
49
  enphaseToken() ?? fail("no Enphase token", "auth", "home enphase login");
50
+ const book = addressBook(addresses.cache);
51
+ const serial = Object.keys(book.entries())
52
+ .find((k) => k.startsWith("enphase:"))
53
+ ?.slice("enphase:".length);
54
+ const cached = serial ? book.fresh("enphase", serial) : undefined;
55
+ const host =
56
+ env.IQ_GATEWAY_HOST ??
57
+ cached?.host ??
58
+ (await FINDERS.enphase())[0]?.host ??
59
+ fail("no gateway found by mDNS; set IQ_GATEWAY_HOST", "network");
38
60
  return enphase({
39
- host: env.IQ_GATEWAY_HOST!,
61
+ host,
62
+ serial,
63
+ find: FINDERS.enphase,
64
+ seen: (h) => book.seen("enphase", serial, { host: h }),
65
+ hostCached: true, // an env address is as likely stale as a cached one: look again after one failure
40
66
  token: t.token,
41
67
  fetch: bunGatewayFetch(),
42
68
  timeZone: homeTimeZone(),
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";
@@ -19,6 +19,7 @@ import { discover } from "./discover.ts";
19
19
  import { enphaseVerbs } from "./enphase.ts";
20
20
  import { CHANGE_USAGE, hueVerbs } from "./hue.ts";
21
21
  import { locate } from "./locate.ts";
22
+ import { PRINTERS_USAGE, printersVerbs } from "./printers.ts";
22
23
  import { probe } from "./probe.ts";
23
24
  import { SPOTIFY_USAGE, spotifyVerbs } from "./spotify.ts";
24
25
  import { devices, status } from "./status.ts";
@@ -59,6 +60,11 @@ const VERBS: Record<
59
60
  cast: { verbs: castVerbs, usage: CAST_USAGE, options: CAST_OPTIONS },
60
61
  up: { verbs: upVerbs, usage: UP_USAGE, options: UP_OPTIONS },
61
62
  spotify: { verbs: spotifyVerbs, usage: SPOTIFY_USAGE },
63
+ printers: {
64
+ verbs: printersVerbs,
65
+ usage: PRINTERS_USAGE,
66
+ options: [DRY_RUN],
67
+ },
62
68
  };
63
69
 
64
70
  const cli = cac("home");
@@ -157,8 +163,7 @@ function describe(e: unknown): {
157
163
  if (e instanceof CliError) return { error, code: e.code, fix: e.fix };
158
164
  if (e instanceof Error && e.name === "CACError")
159
165
  return { error, code: "usage" }; // cac: a missing argument or an unknown option
160
- if (e instanceof ConnectorError && (e.status === 401 || e.status === 403))
161
- return { error, code: "auth" };
166
+ if (isRejectedKey(e)) return { error, code: "auth" };
162
167
  // A timeout, or a socket error Bun and Node tag with a code (ECONNREFUSED, ENOTFOUND). A bare TypeError is more likely a bug.
163
168
  const socket =
164
169
  typeof (e as { code?: unknown })?.code === "string" &&