@khalido/home 0.2.0 → 0.2.2

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 (76) hide show
  1. package/CHANGELOG.md +24 -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/cast/index.d.ts +2 -1
  8. package/dist/connectors/cast/index.js +1 -0
  9. package/dist/connectors/cast/wav.d.ts +31 -0
  10. package/dist/connectors/cast/wav.js +149 -0
  11. package/dist/connectors/cast/writes.d.ts +25 -6
  12. package/dist/connectors/cast/writes.js +155 -62
  13. package/dist/connectors/enphase/client.d.ts +1 -1
  14. package/dist/connectors/enphase/client.js +1 -1
  15. package/dist/connectors/enphase/index.js +2 -1
  16. package/dist/connectors/hue/events.d.ts +0 -2
  17. package/dist/connectors/hue/events.js +1 -2
  18. package/dist/connectors/hue/index.js +2 -2
  19. package/dist/connectors/printers/client.d.ts +41 -6
  20. package/dist/connectors/printers/client.js +81 -8
  21. package/dist/connectors/printers/codec.d.ts +17 -3
  22. package/dist/connectors/printers/codec.js +32 -8
  23. package/dist/connectors/printers/index.d.ts +2 -0
  24. package/dist/connectors/printers/index.js +4 -0
  25. package/dist/connectors/printers/raster.d.ts +30 -0
  26. package/dist/connectors/printers/raster.js +175 -0
  27. package/dist/connectors/printers/state.d.ts +2 -0
  28. package/dist/connectors/spotify/changes.d.ts +1 -1
  29. package/dist/connectors/spotify/changes.js +1 -1
  30. package/dist/connectors/spotify/index.d.ts +28 -14
  31. package/dist/connectors/spotify/index.js +89 -48
  32. package/dist/connectors/up/client.d.ts +1 -1
  33. package/dist/connectors/up/client.js +1 -1
  34. package/dist/connectors/up/index.js +2 -2
  35. package/dist/connectors/weather/google.js +2 -2
  36. package/docs/cast.md +1 -1
  37. package/docs/cli.md +3 -1
  38. package/docs/decisions.md +7 -0
  39. package/docs/ideas.md +2 -0
  40. package/docs/using.md +1 -1
  41. package/llms.txt +2 -2
  42. package/package.json +1 -1
  43. package/skills/draw-home/README.md +2 -0
  44. package/skills/use-home/SKILL.md +1 -1
  45. package/src/cli/cast.ts +41 -11
  46. package/src/cli/context.ts +1 -1
  47. package/src/cli/enphase.ts +31 -5
  48. package/src/cli/main.ts +14 -5
  49. package/src/cli/printers.ts +254 -3
  50. package/src/cli/spotify.ts +16 -12
  51. package/src/connector.ts +5 -0
  52. package/src/connectors/amber/README.md +1 -1
  53. package/src/connectors/amber/client.ts +1 -1
  54. package/src/connectors/amber/index.ts +2 -2
  55. package/src/connectors/cast/README.md +2 -0
  56. package/src/connectors/cast/index.ts +4 -0
  57. package/src/connectors/cast/wav.ts +181 -0
  58. package/src/connectors/cast/writes.ts +211 -71
  59. package/src/connectors/enphase/README.md +3 -1
  60. package/src/connectors/enphase/client.ts +1 -1
  61. package/src/connectors/enphase/index.ts +2 -1
  62. package/src/connectors/hue/events.ts +1 -4
  63. package/src/connectors/hue/index.ts +2 -2
  64. package/src/connectors/printers/README.md +9 -4
  65. package/src/connectors/printers/client.ts +186 -12
  66. package/src/connectors/printers/codec.ts +44 -10
  67. package/src/connectors/printers/index.ts +17 -0
  68. package/src/connectors/printers/raster.ts +215 -0
  69. package/src/connectors/printers/state.ts +2 -0
  70. package/src/connectors/spotify/README.md +5 -1
  71. package/src/connectors/spotify/changes.ts +1 -1
  72. package/src/connectors/spotify/index.ts +233 -106
  73. package/src/connectors/up/README.md +1 -1
  74. package/src/connectors/up/client.ts +1 -1
  75. package/src/connectors/up/index.ts +2 -2
  76. package/src/connectors/weather/google.ts +6 -2
package/docs/cast.md CHANGED
@@ -109,7 +109,7 @@ No host: a group's host changes with its leader. All entries fold into one `cast
109
109
  - `announce(target, clip, { volume?, timeoutMs? })`, target a device or group, `clip` a URL or bytes with their type (KO's own dictation as recorded, or TTS output; the library never synthesises) served by `serveClip`. The result names each member it could not restore. Read status, set volume, launch Default Media Receiver (`CC1AD845`), `LOAD` the clip, wait for `IDLE`/`FINISHED` (with a timeout), then restore: a prior Default Media Receiver `contentId` is re-loaded at its `currentTime`, an idle device gets `STOP`, then the volume. Spotify, YouTube and other apps cannot be resumed generically (our launch ends their session); the result says so.
110
110
  - `castUrl(name, url)` launches DashCast (`84912283`, pychromecast's list) with the URL. HA casts its dashboard with its own registered receiver (`A078F6B0`), a hint at DashCast's limits. KO's own receiver costs a one-off Cast SDK fee and must be published (unpublished ones run only on serial-registered devices). Try DashCast first.
111
111
 
112
- **TTS.** The library plays a URL, never synthesises. The speaker fetches it, so it must be HTTP on the LAN, not loopback, and in a format Default Media Receiver plays (MP3 or AAC; not AIFF; a 24 kHz mono WAV failed on audio-only Nest speakers, live 2026-11-09): `say -o msg.m4a --file-format=m4af --data-format=aac "…"` (m4af alone holds uncompressed PCM). Smallest server: a ~25-line `serveClip(bytes, type)` over `node:http` beside `tls.ts`, bound to the Mac's LAN address, random port, unguessable path, closed when the clip has played or after a minute (not after the first fetch, so a probe before it does no harm). The consumer runs `say`; `serveClip` picks the address on the target's network; the sidecar will raise macOS's local-network prompt once. A cloud voice later is MP3 bytes served the same way.
112
+ **TTS.** The library plays a URL, never synthesises. The speaker fetches it, so it must be HTTP on the LAN, not loopback, and in a format Default Media Receiver plays (MP3 or AAC; not AIFF; a 24 kHz mono WAV failed on audio-only Nest speakers, live 2026-11-09; `announce` now rewrites WAV bytes as 48 kHz 16-bit stereo, which played on every audio-only speaker and display here, 2026-11-12): `say -o msg.m4a --file-format=m4af --data-format=aac "…"` (m4af alone holds uncompressed PCM). Smallest server: a ~25-line `serveClip(bytes, type)` over `node:http` beside `tls.ts`, bound to the Mac's LAN address, random port, unguessable path, closed when the clip has played or after a minute (not after the first fetch, so a probe before it does no harm). The consumer runs `say`; `serveClip` picks the address on the target's network; the sidecar will raise macOS's local-network prompt once. A cloud voice later is MP3 bytes served the same way.
113
113
 
114
114
  **CLI.** `home status`: `cast 7 · Display A idle 47% · TV B standby · Whole house (3, led by Display A) idle`. `home probe`: this probe.
115
115
 
package/docs/cli.md CHANGED
@@ -12,12 +12,14 @@ home scene <name> [in <room>] # a scene; name the room when several have one by
12
12
  home pair # waits 60 s for the bridge's button, counting down; prints the env lines to set
13
13
  home daikin on | off # power only: the AC comes back as it was last left
14
14
  home daikin cool | heat | auto [°C] | dry | fan # mode, on, and the setpoint: the one given, else cool 25 and heat 27; prints what the unit then reports. Live on KO's unit: on, and dry to cool at 25 (2026-11-11); the rest untried
15
- # --dry-run on a hue or daikin write (lights, light, room, scene, daikin …): check it and print what would be sent; nothing is sent. Refused on every other command
15
+ # --dry-run on a write (lights, light, room, scene, daikin, cast, spotify, printers print): check it and print what would be sent; nothing is sent. Refused on every other command
16
16
  home enphase login # once a year: mints the Enphase owner token into ~/.config/home/ (0600)
17
17
  home discover [--json] [--passes n] # which smart-home devices answer here (mDNS + Daikin UDP), which this package supports, then with a config how they match it; two passes 5 s apart
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/docs/using.md CHANGED
@@ -74,7 +74,7 @@ A write changes someone's house: make one only when a person asked for it. Write
74
74
  | hue | `.client.put(kind, id, body)`, `.client.recallScene(id)` | the bridge's answer, for what the typed writes don't cover |
75
75
 
76
76
  - **`confirmed`** is true once the device reported what was asked. False is a result, not an error: the write was sent and the device hasn't shown it yet, and `state` is how it stood.
77
- - **Dry run.** `opts` is `{ dryRun: true }` on Hue's and the Daikin's writes: every check made before sending runs (the name, the range, a lost light; a colour off the gamut shows moved in the body) and nothing is sent. The device can still refuse a real write that a dry run passed. The result has `wouldSend`, the request it would make (Hue: `{ method, path, body }`; Daikin adds `sets`, `{ on?, mode?, targetC? }`), `state` as things stand now, and `confirmed: false`, so check `wouldSend` first. A wrong name still throws with the names that exist. For testing a bot end to end with people home; `home … --dry-run` does the same from the terminal.
77
+ - **Dry run.** `opts` is `{ dryRun: true }` on Hue's, the Daikin's, Cast's and Spotify's writes: every check made before sending runs (the name, the range, a lost light; a colour off the gamut shows moved in the body) and nothing is sent. The device can still refuse a real write that a dry run passed. The result has `wouldSend`, the request it would make (Hue: `{ method, path, body }`; Daikin adds `sets`, `{ on?, mode?, targetC? }`; Spotify: the WebSocket commands in order), `state` as things stand now, and `confirmed: false`, so check `wouldSend` first. Cast's `setVolume`, `setMuted` and `stop` resolve `{ wouldSend }` (each `{ device, channel, payload }`), and `announce` its usual result with `played: false`, `why: "dry run"`, `wouldSend` and, in `notRestored`, what the real one could not put back (an app it would interrupt); nothing is served. Spotify's `duck` and `interrupt` run `fn`, touch nothing, and say in `why` what they would have done, so `duck(() => cast.announce(…, { dryRun: true }), { to: 20, dryRun: true })` tests an announcement end to end without a sound. A wrong name still throws with the names that exist. For testing a bot end to end with people home; `home … --dry-run` does the same from the terminal.
78
78
  - **Use the returned state.** The Daikin's part in the snapshot is from its last poll and can be 120 s behind, unless you call `home.refresh("daikin")`.
79
79
  - **Daikin defaults.** `mode("cool")` is 25 °C and `mode("heat")` 27 (`DEFAULT_TARGET_C`); `mode("cool", 23)` for another. `power(true)` brings the unit back as it was last left.
80
80
  - **A target** for Cast is a device's or group's name or id. A clip is `{ url, type? }` or `{ bytes, type }`; bytes need `castServe`.
package/llms.txt CHANGED
@@ -11,8 +11,8 @@ The library never reads the environment or a file: callers pass addresses, keys
11
11
  - **Why a part is down:** `part.status` (`connected`, `disconnected`, `unavailable`) and, when `unavailable`, `part.reason` (`auth`, `config`, `crashed`). Errors are `ConnectorError` with a typed `code`.
12
12
  - **Hue** (`@khalido/home/hue`): `pairWhenPressed` waits for the bridge's button and returns `{ key }`; Signify's root certificates are in the package, so `hue({ host, bridgeId, key })` needs no file. Writes by name: `client.light(name, change)`, `client.room(name, change)`, `client.scene(name, room?)`, `client.allOff()`, with `change` `{ on?, brightness?, color?, kelvin?, transitionMs? }`, each resolving `{ state, confirmed }`. `{ dryRun: true }` as the last argument checks and returns `wouldSend` without sending.
13
13
  - **Daikin** (`@khalido/home/daikin`): `client.power(on)` and `client.mode(mode, targetC?)` resolve `{ state, confirmed }`, and take `{ dryRun: true }` last; cool defaults to 25 °C and heat to 27.
14
- - **Cast** (`@khalido/home/cast`): `castStatus(state)` and `castFree(state, targets, { forms? })`, on the Cast part's state, say what is playing and where an announcement would disturb nothing; `castFreeSpeakers(state)` is the one call for every free speaker and display; `announce`, `announceAll`, `setVolume`, `setMuted`, `stop`.
15
- - **Spotify Soloist** (`@khalido/home/spotify`): `play`, `pause`, `setVolume` and the rest resolve `{ state, confirmed }`; `duck` and `interrupt` wrap an announcement. `speakerState(part)` is `elsewhere` when the music is on another Spotify device.
14
+ - **Cast** (`@khalido/home/cast`): `castStatus(state)` and `castFree(state, targets, { forms? })`, on the Cast part's state, say what is playing and where an announcement would disturb nothing; `castFreeSpeakers(state)` is the one call for every free speaker and display; `announce`, `announceAll`, `setVolume`, `setMuted`, `stop`. Its writes take `{ dryRun: true }`.
15
+ - **Spotify Soloist** (`@khalido/home/spotify`): `play`, `pause`, `setVolume` and the rest resolve `{ state, confirmed }`; `duck` and `interrupt` wrap an announcement. Every write takes `{ dryRun: true }`. `speakerState(part)` is `elsewhere` when the music is on another Spotify device.
16
16
  - **Discovery** (`@khalido/home/finders`, Node-side): `FINDERS`, one finder per connector, and `fileCache`. **Node** needs the fetches in `@khalido/home/node`.
17
17
  - **The command:** `home`, `home devices`, `home <connector>`, and writes such as `home daikin cool`, `home room <name> on`, `home cast announce`.
18
18
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@khalido/home",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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 write (`home room …`, `home scene …`, `home lights off`, `home daikin …`, `home cast …`, `home spotify …`, `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.
package/src/cli/cast.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  resolvedFrom,
14
14
  } from "../connectors/cast/index.ts";
15
15
  import { findCast } from "../connectors/cast/state.ts";
16
+ import type { CastDryRun, CastRequest } from "../connectors/cast/writes.ts";
16
17
  import { serveClip } from "../connectors/cast/serve.ts";
17
18
  import { tlsConnect } from "../connectors/cast/tls.ts";
18
19
  import { addressBook } from "../discover.ts";
@@ -114,14 +115,32 @@ export const CAST_OPTIONS: [string, string, { type: unknown[] }?][] = [
114
115
  /** How long a write waits for the device to report the change before saying it was only sent. */
115
116
  const SHOWN_MS = 2000;
116
117
 
117
- /** One write, then the device's line once it reports what was asked, or that it has not yet. */
118
+ /** A dry run's messages, one line each: `would send <device> <channel> <payload>`. */
119
+ const dryLines = (sends: CastRequest[], who = "") =>
120
+ sends.length
121
+ ? sends.forEach((r) =>
122
+ console.log(
123
+ `${who}would send ${r.device} ${r.channel} ${JSON.stringify(r.payload)}`,
124
+ ),
125
+ )
126
+ : console.log(`${who}nothing to send`);
127
+
128
+ /** One write, then the device's line once it reports what was asked, or that it has not yet. Dry: what would be sent. */
118
129
  const wrote = (
119
130
  name: string,
120
- write: (c: ReturnType<typeof castNow>) => Promise<void>,
131
+ write: (
132
+ c: ReturnType<typeof castNow>,
133
+ options: { dryRun: boolean },
134
+ ) => Promise<CastDryRun | undefined>,
121
135
  shown: (d: CastDevice) => boolean,
136
+ dryRun: boolean,
122
137
  ) =>
123
138
  withCast(async (c, state) => {
124
- await write(c);
139
+ const dry = await write(c, { dryRun });
140
+ if (dry) {
141
+ console.log("dry run, nothing sent:");
142
+ return dryLines(dry.wouldSend);
143
+ }
125
144
  const now = () => {
126
145
  const s = state();
127
146
  const d = s && findCast(s.devices, name);
@@ -140,29 +159,32 @@ const wrote = (
140
159
  });
141
160
 
142
161
  export const castVerbs: Verbs = {
143
- async volume([name, value]) {
162
+ async volume([name, value], o) {
144
163
  if (!name) usage(CAST_USAGE);
145
164
  const level = percentLevel(value);
146
165
  await wrote(
147
166
  name!,
148
- (c) => c.setVolume(name!, level),
167
+ (c, dry) => c.setVolume(name!, level, dry),
149
168
  (d) => d.volume !== undefined && Math.abs(d.volume - level) < 0.006,
169
+ !!o.dryRun,
150
170
  );
151
171
  },
152
- async mute([name, value]) {
172
+ async mute([name, value], o) {
153
173
  if (!name || (value !== "on" && value !== "off")) usage(CAST_USAGE);
154
174
  await wrote(
155
175
  name!,
156
- (c) => c.setMuted(name!, value === "on"),
176
+ (c, dry) => c.setMuted(name!, value === "on", dry),
157
177
  (d) => d.muted === (value === "on"),
178
+ !!o.dryRun,
158
179
  );
159
180
  },
160
- async stop([name]) {
181
+ async stop([name], o) {
161
182
  if (!name) usage(CAST_USAGE);
162
183
  await wrote(
163
184
  name!,
164
- (c) => c.stop(name!),
185
+ (c, dry) => c.stop(name!, dry),
165
186
  (d) => d.idle === true,
187
+ !!o.dryRun,
166
188
  );
167
189
  },
168
190
  async announce(_, o) {
@@ -209,13 +231,21 @@ export const castVerbs: Verbs = {
209
231
  type = "audio/mp4";
210
232
  }
211
233
  const results = await withCast((c) =>
212
- c.announceAll(to, { bytes, type }, { volume }),
234
+ c.announceAll(to, { bytes, type }, { volume, dryRun: !!o.dryRun }),
213
235
  );
214
- if (results.some((r) => !r.played)) process.exitCode = 1; // docs/cli.md: 1 is a failure, so a script can tell
236
+ const failed = results.some((r) => (o.dryRun ? !r.wouldSend : !r.played));
237
+ if (failed) process.exitCode = 1; // docs/cli.md: 1 is a failure, so a script can tell
215
238
  if (o.json) return printJson(to.length === 1 ? results[0] : results);
216
239
  const named = results.length > 1;
240
+ if (o.dryRun) console.log("dry run, nothing sent:");
217
241
  for (const r of results) {
218
242
  const who = named ? `${r.target}: ` : "";
243
+ if (r.wouldSend) {
244
+ dryLines(r.wouldSend, who);
245
+ for (const n of r.notRestored)
246
+ console.log(`${who}would not restore: ${n.name}: ${n.why}`);
247
+ continue;
248
+ }
219
249
  console.log(`${who}${r.played ? "played" : `not played: ${r.why}`}`);
220
250
  if (r.restored.length)
221
251
  console.log(`${who}restored: ${r.restored.join(", ")}`);
@@ -38,7 +38,7 @@ export const usage = (message: string): never => fail(message, "usage");
38
38
  /** The options cac parses: the global ones, and each command's own. */
39
39
  export type Options = {
40
40
  json?: boolean;
41
- /** `--dry-run`: Hue and Daikin writes check and say what they would send, and send nothing. */
41
+ /** `--dry-run`: Hue, Daikin, Cast, Spotify and printer writes check and say what they would send, and send nothing. */
42
42
  dryRun?: boolean;
43
43
  config?: string;
44
44
  write?: string | true;
@@ -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";
@@ -56,9 +57,18 @@ const VERBS: Record<
56
57
  },
57
58
  daikin: { verbs: daikinVerbs, usage: DAIKIN_USAGE, options: [DRY_RUN] },
58
59
  enphase: { verbs: enphaseVerbs, usage: "usage: home enphase login" },
59
- cast: { verbs: castVerbs, usage: CAST_USAGE, options: CAST_OPTIONS },
60
+ cast: {
61
+ verbs: castVerbs,
62
+ usage: CAST_USAGE,
63
+ options: [...CAST_OPTIONS, DRY_RUN],
64
+ },
60
65
  up: { verbs: upVerbs, usage: UP_USAGE, options: UP_OPTIONS },
61
- spotify: { verbs: spotifyVerbs, usage: SPOTIFY_USAGE },
66
+ spotify: { verbs: spotifyVerbs, usage: SPOTIFY_USAGE, options: [DRY_RUN] },
67
+ printers: {
68
+ verbs: printersVerbs,
69
+ usage: PRINTERS_USAGE,
70
+ options: [DRY_RUN],
71
+ },
62
72
  };
63
73
 
64
74
  const cli = cac("home");
@@ -157,8 +167,7 @@ function describe(e: unknown): {
157
167
  if (e instanceof CliError) return { error, code: e.code, fix: e.fix };
158
168
  if (e instanceof Error && e.name === "CACError")
159
169
  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" };
170
+ if (isRejectedKey(e)) return { error, code: "auth" };
162
171
  // A timeout, or a socket error Bun and Node tag with a code (ECONNREFUSED, ENOTFOUND). A bare TypeError is more likely a bug.
163
172
  const socket =
164
173
  typeof (e as { code?: unknown })?.code === "string" &&
@@ -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
+ };
@@ -7,7 +7,7 @@ import {
7
7
  type SpotifyState,
8
8
  spotifyText,
9
9
  } from "../connectors/spotify/index.ts";
10
- import { env, fail, usage, type Verbs } from "./context.ts";
10
+ import { env, fail, type Options, usage, type Verbs } from "./context.ts";
11
11
 
12
12
  /** SOLOIST_MAX_VOLUME as a number 0–100, or undefined when unset. */
13
13
  function maxVolume(): number | undefined {
@@ -55,23 +55,27 @@ async function withSpotify<T>(
55
55
  }
56
56
  }
57
57
 
58
- /** What a write left, in a line: the speaker as Soloist then reported it, or that it has not reported the change yet. */
59
- const said = ({ state, confirmed }: Applied) =>
58
+ /** What a write left, in a line: the speaker as Soloist then reported it, that it has not reported the change yet, or what a dry run would send. */
59
+ const said = ({ state, confirmed, wouldSend }: Applied) =>
60
60
  console.log(
61
- confirmed
62
- ? spotifyText(state)
63
- : `sent; Soloist has not reported it yet (${spotifyText(state)})`,
61
+ wouldSend
62
+ ? `dry run, nothing sent: ${wouldSend.length ? `would send ${wouldSend.map((r) => JSON.stringify(r)).join(", then ")}` : "nothing to send"} (now ${spotifyText(state)})`
63
+ : confirmed
64
+ ? spotifyText(state)
65
+ : `sent; Soloist has not reported it yet (${spotifyText(state)})`,
64
66
  );
65
67
 
68
+ const dry = (o: Options) => ({ dryRun: !!o.dryRun });
69
+
66
70
  export const spotifyVerbs: Verbs = {
67
- activate: () => withSpotify((s) => s.activate()).then(said),
68
- pause: () => withSpotify((s) => s.pause()).then(said),
69
- next: () => withSpotify((s) => s.next()).then(said),
70
- play: ([link]) => withSpotify((s) => s.play(link)).then(said),
71
- async volume([value]) {
71
+ activate: (_, o) => withSpotify((s) => s.activate(dry(o))).then(said),
72
+ pause: (_, o) => withSpotify((s) => s.pause(dry(o))).then(said),
73
+ next: (_, o) => withSpotify((s) => s.next(dry(o))).then(said),
74
+ play: ([link], o) => withSpotify((s) => s.play(link, dry(o))).then(said),
75
+ async volume([value], o) {
72
76
  const n = Number(value);
73
77
  if (value === undefined || value.trim() === "" || !(n >= 0 && n <= 100))
74
78
  usage(SPOTIFY_USAGE);
75
- said(await withSpotify((s) => s.setVolume(n)));
79
+ said(await withSpotify((s) => s.setVolume(n, dry(o))));
76
80
  },
77
81
  };