@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.
- package/CHANGELOG.md +24 -2
- 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 +2 -1
- package/dist/connectors/cast/index.js +1 -0
- package/dist/connectors/cast/wav.d.ts +31 -0
- package/dist/connectors/cast/wav.js +149 -0
- package/dist/connectors/cast/writes.d.ts +25 -6
- package/dist/connectors/cast/writes.js +155 -62
- 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/events.d.ts +0 -2
- package/dist/connectors/hue/events.js +1 -2
- package/dist/connectors/hue/index.js +2 -2
- 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 +1 -1
- package/dist/connectors/spotify/changes.js +1 -1
- package/dist/connectors/spotify/index.d.ts +28 -14
- package/dist/connectors/spotify/index.js +89 -48
- 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/docs/cast.md +1 -1
- package/docs/cli.md +3 -1
- package/docs/decisions.md +7 -0
- package/docs/ideas.md +2 -0
- package/docs/using.md +1 -1
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/skills/draw-home/README.md +2 -0
- package/skills/use-home/SKILL.md +1 -1
- package/src/cli/cast.ts +41 -11
- package/src/cli/context.ts +1 -1
- package/src/cli/enphase.ts +31 -5
- package/src/cli/main.ts +14 -5
- package/src/cli/printers.ts +254 -3
- package/src/cli/spotify.ts +16 -12
- 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 +2 -0
- package/src/connectors/cast/index.ts +4 -0
- package/src/connectors/cast/wav.ts +181 -0
- package/src/connectors/cast/writes.ts +211 -71
- 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/events.ts +1 -4
- package/src/connectors/hue/index.ts +2 -2
- 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 +5 -1
- package/src/connectors/spotify/changes.ts +1 -1
- package/src/connectors/spotify/index.ts +233 -106
- 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/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
|
|
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
|
|
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.
|
|
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 |
|
package/skills/use-home/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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: (
|
|
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
|
-
|
|
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(", ")}`);
|
package/src/cli/context.ts
CHANGED
|
@@ -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
|
|
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;
|
package/src/cli/enphase.ts
CHANGED
|
@@ -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 {
|
|
11
|
-
import {
|
|
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
|
-
/**
|
|
35
|
-
|
|
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
|
|
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 {
|
|
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: {
|
|
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 (
|
|
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" &&
|
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
|
+
};
|
package/src/cli/spotify.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
62
|
-
? spotifyText(state)
|
|
63
|
-
:
|
|
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
|
};
|