@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.
- package/CHANGELOG.md +16 -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/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.js +12 -1
- 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/cli.md +2 -0
- package/docs/decisions.md +7 -0
- package/docs/ideas.md +2 -0
- package/package.json +1 -1
- package/skills/draw-home/README.md +2 -0
- package/skills/use-home/SKILL.md +1 -1
- package/src/cli/enphase.ts +31 -5
- package/src/cli/main.ts +8 -3
- package/src/cli/printers.ts +254 -3
- 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/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 +3 -1
- package/src/connectors/spotify/changes.ts +1 -1
- package/src/connectors/spotify/index.ts +12 -1
- 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/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,18 @@ All notable changes to home.ts (`@khalido/home` on npm), in [Keep a Changelog](h
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.2.1] - 2026-11-12
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **printers:** printing. `identifyPrinter`, `validateJob`, `printJob` and `cancelJob` over IPP, and `pwgRaster` for a printer that takes no PDF (8-bit grey PWG Raster). The snapshot's `Printer` gains `port` and `rp`, so a write can reach it. `home printers print <file.pdf> [printer]`, `identify` and `cancel`; `print --dry-run` has the printer validate the job and sends no job; a printer with no PDF needs poppler's `pdftoppm`. Live on a Brother HL-L2350DW.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- **cli, enphase:** a stale `IQ_GATEWAY_HOST` no longer leaves `home enphase` refused for good: with the gateway's serial from the address cache, a failed read is followed by mDNS discovery, and the line says where the gateway moved. Without `IQ_GATEWAY_HOST` the address comes from the cache, then from mDNS.
|
|
16
|
+
- **amber, enphase, up:** a 403 is a rejected key (`unavailable`, `reason: "auth"`), as a 401 already was and as Hue and Google weather already read both. It was `disconnected` and retried forever. One check, `isRejectedKey` in `src/connector.ts`, now serves every connector and the CLI.
|
|
17
|
+
- **spotify:** after the first-play lowering ([spotify/soloist#23](https://github.com/spotify/soloist/issues/23)), `anomaly` no longer says only "lowered it" when the volume is back above `maxVolume`: it adds `it is back at <n>`. Soloist puts its own level back about 90 ms after `playing` when it goes active (live, 2026-11-11), and the cap leaves that level alone.
|
|
18
|
+
|
|
7
19
|
## [0.2.0] - 2026-11-12
|
|
8
20
|
|
|
9
21
|
### Breaking
|
|
@@ -34,7 +46,7 @@ All notable changes to home.ts (`@khalido/home` on npm), in [Keep a Changelog](h
|
|
|
34
46
|
- **cast: `castFreeSpeakers(state)`**: every free speaker and display in one call, a whole group where all of it is free, never a TV; `free` goes straight to `announceAll`.
|
|
35
47
|
- **cast: `castFree(state, targets, { forms })`**: with `forms: ["speaker", "display"]` a TV is left out (`why: "form"`, only when `forms` is given), and a group holding one becomes its other free members.
|
|
36
48
|
- **`home.add(integration)` and `home.remove(name)`** on the home `createHome` returns: add one while the rest keep running (a bridge paired just now), replace one by name (the old run has ended before the new one starts; how an `unavailable` part comes back after a re-key), or stop one and drop its part. `start` now also waits for those added.
|
|
37
|
-
- **spotify: `playingElsewhere(state)`**, and **`spotifyElsewhere(snapshot)`** from `@khalido/home`: the names of the Cast devices and groups playing that track in Spotify, matched by the track's name since Soloist doesn't say where the music is. Empty for a phone or a laptop. `homeDevices` and `home devices` put it in the speaker's line (`silent · "Song" is playing on
|
|
49
|
+
- **spotify: `playingElsewhere(state)`**, and **`spotifyElsewhere(snapshot)`** from `@khalido/home`: the names of the Cast devices and groups playing that track in Spotify, matched by the track's name since Soloist doesn't say where the music is. Empty for a phone or a laptop. `homeDevices` and `home devices` put it in the speaker's line (`silent · "Song" is playing on Den speaker`), and `spotifyText(state, on)` takes it. Tested on built snapshots only.
|
|
38
50
|
- **hue: lights, rooms and scenes by name.** `client.light(name, change)`, `client.room(name, change)` and `client.scene(name, room?)`, each resolving `{ state, confirmed }` (`HueApplied`). `change` (`LightChange`) is `{ on?, brightness?, color?, kelvin?, transitionMs? }`: a colour as hex or `{ r, g, b }`, sent as `xy` inside the light's own gamut; a white temperature in kelvin, checked against the light's range. An unknown name or a value the light can't take throws `HueError` with `code: "invalid"` before anything is sent, and a light the bridge has lost throws with `code: "offline"`.
|
|
39
51
|
- **hue: colour in the state.** Each light's `kelvin` and `kelvinRange`, and on a colour light its `xy`, `hex` and `gamut`. `colorToXy`, `xyToHex`, `kelvinToMirek`, `mirekToKelvin` and `hueText` are exported.
|
|
40
52
|
- **`home light <name> …`**, and `home room` and `home scene` now take a brightness, a temperature (`2700k`) and a colour (`ff8800`) together, name a scene's room (`in <room>`), and print what the bridge then reported.
|
|
@@ -139,7 +151,9 @@ First release. Nine connectors: Hue, Daikin, Enphase, Google Cast, AirPrint prin
|
|
|
139
151
|
- **Agent skills** in `skills/`: `use-home` (reading safely, connecting, the rules for writes) and `draw-home` (an SVG plan of a home and its rooms). `llms.txt` points agents at the docs.
|
|
140
152
|
- **Node.** Hue and Enphase take the fetches in `@khalido/home/node`. The library runs on Bun 1.4+ and Node 24+; the `home` command needs Bun.
|
|
141
153
|
|
|
142
|
-
[Unreleased]: https://github.com/khalido/home.ts/compare/v0.1
|
|
154
|
+
[Unreleased]: https://github.com/khalido/home.ts/compare/v0.2.1...HEAD
|
|
155
|
+
[0.2.1]: https://github.com/khalido/home.ts/compare/v0.2.0...v0.2.1
|
|
156
|
+
[0.2.0]: https://github.com/khalido/home.ts/compare/v0.1.3...v0.2.0
|
|
143
157
|
[0.1.3]: https://github.com/khalido/home.ts/compare/v0.1.2...v0.1.3
|
|
144
158
|
[0.1.2]: https://github.com/khalido/home.ts/compare/v0.1.1...v0.1.2
|
|
145
159
|
[0.1.1]: https://github.com/khalido/home.ts/compare/v0.1.0...v0.1.1
|
package/dist/connector.d.ts
CHANGED
|
@@ -23,6 +23,8 @@ export declare class ConnectorError extends Error {
|
|
|
23
23
|
cause?: unknown;
|
|
24
24
|
});
|
|
25
25
|
}
|
|
26
|
+
/** A 401 or 403 from a device or service: the key or token is wrong or revoked. Every connector reads it as `reason: "auth"`. */
|
|
27
|
+
export declare const isRejectedKey: (error: unknown) => boolean;
|
|
26
28
|
/** `fetch` that gives up after `timeoutMs`, or as soon as `signal` (a run's) aborts. Returns the response whatever its status. */
|
|
27
29
|
export declare function fetchTimeout(fetcher: typeof fetch, url: string | URL, init?: Omit<RequestInit, "signal">, options?: {
|
|
28
30
|
timeoutMs?: number;
|
package/dist/connector.js
CHANGED
|
@@ -24,6 +24,9 @@ export class ConnectorError extends Error {
|
|
|
24
24
|
this.code = options.code;
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
|
+
/** A 401 or 403 from a device or service: the key or token is wrong or revoked. Every connector reads it as `reason: "auth"`. */
|
|
28
|
+
export const isRejectedKey = (error) => error instanceof ConnectorError &&
|
|
29
|
+
(error.status === 401 || error.status === 403);
|
|
27
30
|
/** `fetch` that gives up after `timeoutMs`, or as soon as `signal` (a run's) aborts. Returns the response whatever its status. */
|
|
28
31
|
export function fetchTimeout(fetcher, url, init = {}, options = {}) {
|
|
29
32
|
const timeout = AbortSignal.timeout(options.timeoutMs ?? TIMEOUT_MS);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { ConnectorError } from "../../connector.ts";
|
|
3
|
-
/** A failed Amber call. `status` 401 means the key is wrong or revoked; 429 means the rate limit (50 per 5 min). */
|
|
3
|
+
/** A failed Amber call. `status` 401 or 403 (`isRejectedKey`) means the key is wrong or revoked; 429 means the rate limit (50 per 5 min). */
|
|
4
4
|
export declare class AmberError extends ConnectorError {
|
|
5
5
|
}
|
|
6
6
|
/** One 5-minute price interval on one channel. Prices are c/kWh including GST, as a cost: negative feed-in means you are paid. */
|
|
@@ -2,7 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
import { ConnectorError, getJson } from "../../connector.js";
|
|
3
3
|
// Amber Electric's public API v1 (https://app.amber.com.au/developers; the published spec is 1.0, there is no v2). Shapes checked against live replies,
|
|
4
4
|
// 2026-10-13; every field optional so a change degrades a reading rather than throwing.
|
|
5
|
-
/** A failed Amber call. `status` 401 means the key is wrong or revoked; 429 means the rate limit (50 per 5 min). */
|
|
5
|
+
/** A failed Amber call. `status` 401 or 403 (`isRejectedKey`) means the key is wrong or revoked; 429 means the rate limit (50 per 5 min). */
|
|
6
6
|
export class AmberError extends ConnectorError {
|
|
7
7
|
}
|
|
8
8
|
/** One 5-minute price interval on one channel. Prices are c/kWh including GST, as a cost: negative feed-in means you are paid. */
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { asOf, realClock } from "../../clock.js";
|
|
2
|
-
import { errorText } from "../../connector.js";
|
|
2
|
+
import { errorText, isRejectedKey } from "../../connector.js";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { envName } from "../../home.js";
|
|
5
5
|
import { energyNow } from "../../energy.js";
|
|
@@ -41,7 +41,7 @@ export function amber(config) {
|
|
|
41
41
|
catch (error) {
|
|
42
42
|
if (signal.aborted)
|
|
43
43
|
return; // stopped mid-read: not a failure
|
|
44
|
-
if (error
|
|
44
|
+
if (isRejectedKey(error))
|
|
45
45
|
return update({
|
|
46
46
|
status: "unavailable",
|
|
47
47
|
reason: "auth",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ConnectorError } from "../../connector.ts";
|
|
2
|
-
/** A failed Enphase call. `status` 401 means the token is wrong or expired. */
|
|
2
|
+
/** A failed Enphase call. `status` 401 or 403 (`isRejectedKey`) means the token is wrong or expired. */
|
|
3
3
|
export declare class EnphaseError extends ConnectorError {
|
|
4
4
|
}
|
|
5
5
|
/** Solar right now and today. Watts and watt-hours as the gateway reports them, negatives included. */
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { ConnectorError, fetchTimeout, getJson } from "../../connector.js";
|
|
3
3
|
import { Login, Meters, Production, Readings, } from "./schemas.js";
|
|
4
|
-
/** A failed Enphase call. `status` 401 means the token is wrong or expired. */
|
|
4
|
+
/** A failed Enphase call. `status` 401 or 403 (`isRejectedKey`) means the token is wrong or expired. */
|
|
5
5
|
export class EnphaseError extends ConnectorError {
|
|
6
6
|
}
|
|
7
7
|
/** A daily counter equal to the lifetime one is the software 8 bug seen on KO's gateway (D8.3.5528): not today's energy. */
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { localDay, realClock } from "../../clock.js";
|
|
2
|
+
import { isRejectedKey } from "../../connector.js";
|
|
2
3
|
import { z } from "zod";
|
|
3
4
|
import { deviceRecords, filePath, } from "../../home.js";
|
|
4
5
|
import { lostDetail, relocator } from "../../discover.js";
|
|
@@ -113,7 +114,7 @@ export function enphase(config) {
|
|
|
113
114
|
catch (error) {
|
|
114
115
|
if (signal.aborted)
|
|
115
116
|
return; // stopped mid-read: not a failure
|
|
116
|
-
if (error
|
|
117
|
+
if (isRejectedKey(error))
|
|
117
118
|
return update({
|
|
118
119
|
status: "unavailable",
|
|
119
120
|
reason: "auth",
|
|
@@ -7,8 +7,6 @@ export type SseMessage = {
|
|
|
7
7
|
};
|
|
8
8
|
/** Split an SSE byte stream into messages, one per blank-line-separated block. Comment lines (`:`) are skipped. */
|
|
9
9
|
export declare function parseSse(body: ReadableStream<Uint8Array>): AsyncGenerator<SseMessage>;
|
|
10
|
-
/** A 401 or 403: the key is wrong or revoked. */
|
|
11
|
-
export declare const isRejectedKey: (error: unknown) => boolean;
|
|
12
10
|
/** What the stream yields: a (re)connection, a batch of events, or a drop with the wait before the next try. */
|
|
13
11
|
export type StreamItem = {
|
|
14
12
|
kind: "connected";
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { realClock } from "../../clock.js";
|
|
3
|
+
import { isRejectedKey } from "../../connector.js";
|
|
3
4
|
import { HueError } from "./client.js";
|
|
4
5
|
import { HueEvent } from "./schemas.js";
|
|
5
6
|
/** Split an SSE byte stream into messages, one per blank-line-separated block. Comment lines (`:`) are skipped. */
|
|
@@ -50,8 +51,6 @@ const safeJson = (text) => {
|
|
|
50
51
|
return undefined;
|
|
51
52
|
}
|
|
52
53
|
};
|
|
53
|
-
/** A 401 or 403: the key is wrong or revoked. */
|
|
54
|
-
export const isRejectedKey = (error) => error instanceof HueError && (error.status === 401 || error.status === 403);
|
|
55
54
|
/**
|
|
56
55
|
* A connection with no bytes for this long is reconnected: a silent half-close otherwise looks connected forever (aiohue
|
|
57
56
|
* uses 90 s). The bridge sends nothing on its own, so a quiet house trips it too; the reconnect is a silent probe.
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { asOf, realClock } from "../../clock.js";
|
|
2
|
-
import { errorText } from "../../connector.js";
|
|
2
|
+
import { errorText, isRejectedKey } from "../../connector.js";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { lostDetail, relocator } from "../../discover.js";
|
|
5
5
|
import { envName, filePath, deviceRecords, } from "../../home.js";
|
|
6
6
|
import { bunPinnedFetch, hueClient } from "./client.js";
|
|
7
|
-
import { hueEvents
|
|
7
|
+
import { hueEvents } from "./events.js";
|
|
8
8
|
import { groups, lights, reduce, snapshotFrom, } from "./state.js";
|
|
9
9
|
export { HUE_BRIDGE_ROOT_CA } from "./ca.js";
|
|
10
10
|
export { colorToXy, kelvinToMirek, mirekToKelvin, xyToHex, } from "./color.js";
|
|
@@ -17,12 +17,47 @@ export declare function printerUrl(a: PrinterAddress): {
|
|
|
17
17
|
secure: boolean;
|
|
18
18
|
path: string;
|
|
19
19
|
};
|
|
20
|
-
|
|
21
|
-
* Get-Printer-Attributes over HTTP, through `fetchTimeout`. A non-ok HTTP status or an IPP status of 0x0100 and up throws a
|
|
22
|
-
* ConnectorError saying which. Returns the whole response; `attributesOf` gives the printer's attributes.
|
|
23
|
-
*/
|
|
24
|
-
export declare function getPrinterAttributes(fetcher: typeof fetch, address: PrinterAddress, options?: {
|
|
20
|
+
type CallOptions = {
|
|
25
21
|
requestId?: number;
|
|
26
22
|
signal?: AbortSignal;
|
|
27
23
|
timeoutMs?: number;
|
|
28
|
-
}
|
|
24
|
+
};
|
|
25
|
+
/** Get-Printer-Attributes: the whole response; `attributesOf` gives the printer's attributes. `requested` replaces the poll's list. */
|
|
26
|
+
export declare const getPrinterAttributes: (fetcher: typeof fetch, address: PrinterAddress, options?: CallOptions & {
|
|
27
|
+
requested?: string[];
|
|
28
|
+
}) => Promise<IppMessage>;
|
|
29
|
+
/** Identify-Printer: the printer shows or sounds that it is the one (PWG 5100.13). No paper. */
|
|
30
|
+
export declare const identifyPrinter: (fetcher: typeof fetch, address: PrinterAddress, options?: CallOptions) => Promise<unknown>;
|
|
31
|
+
/**
|
|
32
|
+
* What a job says about itself. `format` is the document's MIME type and must be one the printer lists; `media` a PWG
|
|
33
|
+
* media name (`iso_a4_210x297mm`). `sides` defaults to `one-sided`, sent explicitly so a printer's duplex default
|
|
34
|
+
* never applies to a document made for one side.
|
|
35
|
+
*/
|
|
36
|
+
export type PrintJob = {
|
|
37
|
+
format: string;
|
|
38
|
+
name?: string;
|
|
39
|
+
user?: string;
|
|
40
|
+
media?: string;
|
|
41
|
+
sides?: "one-sided" | "two-sided-long-edge" | "two-sided-short-edge";
|
|
42
|
+
copies?: number;
|
|
43
|
+
};
|
|
44
|
+
/** A job the printer took: its id, and its state when it answered (RFC 8011: 3 pending, 5 processing, 9 completed). */
|
|
45
|
+
export type PrintedJob = {
|
|
46
|
+
jobId?: number;
|
|
47
|
+
state?: number;
|
|
48
|
+
reasons?: string[];
|
|
49
|
+
};
|
|
50
|
+
/** Validate-Job: the printer checks the job's attributes as Print-Job would, and prints nothing. */
|
|
51
|
+
export declare const validateJob: (fetcher: typeof fetch, address: PrinterAddress, job: PrintJob, options?: CallOptions) => Promise<unknown>;
|
|
52
|
+
/** No upload waits longer than this: a page of raster is a few hundred kB, and a printer can be slow to take it. */
|
|
53
|
+
export declare const PRINT_TIMEOUT_MS = 60000;
|
|
54
|
+
/**
|
|
55
|
+
* Print-Job: the job's attributes and the document in one request. A timeout leaves it unknown whether the printer took
|
|
56
|
+
* the job, so the error says not to send it again blindly.
|
|
57
|
+
*/
|
|
58
|
+
export declare function printJob(fetcher: typeof fetch, address: PrinterAddress, job: PrintJob, document: Uint8Array, options?: CallOptions): Promise<PrintedJob>;
|
|
59
|
+
/** Cancel-Job: stop a job by its id, from Print-Job's answer. */
|
|
60
|
+
export declare const cancelJob: (fetcher: typeof fetch, address: PrinterAddress, jobId: number, options?: CallOptions & {
|
|
61
|
+
user?: string;
|
|
62
|
+
}) => Promise<unknown>;
|
|
63
|
+
export {};
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { ConnectorError, fetchTimeout } from "../../connector.js";
|
|
2
|
-
import { attributesOf, decodeMessage, getPrinterAttributesRequest, } from "./codec.js";
|
|
3
|
-
// Pure
|
|
4
|
-
//
|
|
5
|
-
//
|
|
2
|
+
import { attributesOf, decodeMessage, getPrinterAttributesRequest, OPERATION, operationRequest, TAG, } from "./codec.js";
|
|
3
|
+
// Pure calls: a `fetch`, an address and the bytes to send come in, the printer's answer goes out. The reads are
|
|
4
|
+
// Get-Printer-Attributes; the writes Identify-Printer, Validate-Job, Print-Job and Cancel-Job. The document is the caller's,
|
|
5
|
+
// already in a format the printer lists (`document-format-supported`): this package makes no PDF into raster.
|
|
6
6
|
/** Everything the integration reads from one printer. */
|
|
7
7
|
export const REQUESTED = [
|
|
8
8
|
"printer-state",
|
|
@@ -51,15 +51,15 @@ const STATUS = {
|
|
|
51
51
|
0x0507: "server-error-busy",
|
|
52
52
|
};
|
|
53
53
|
/**
|
|
54
|
-
*
|
|
55
|
-
* ConnectorError saying which
|
|
54
|
+
* One IPP request over HTTP, through `fetchTimeout`. A non-ok HTTP status or an IPP status of 0x0100 and up throws a
|
|
55
|
+
* ConnectorError saying which (`code: "ipp"`, the IPP status in `status`).
|
|
56
56
|
*/
|
|
57
|
-
|
|
57
|
+
async function call(fetcher, address, body, options = {}) {
|
|
58
58
|
const url = printerUrl(address);
|
|
59
59
|
const res = await fetchTimeout(fetcher, url.http, {
|
|
60
60
|
method: "POST",
|
|
61
61
|
headers: { "content-type": "application/ipp", accept: "application/ipp" },
|
|
62
|
-
body:
|
|
62
|
+
body: body(url.ipp),
|
|
63
63
|
}, options);
|
|
64
64
|
if (!res.ok)
|
|
65
65
|
throw new ConnectorError(`HTTP ${res.status} on POST /${url.path}`, {
|
|
@@ -72,3 +72,76 @@ export async function getPrinterAttributes(fetcher, address, options = {}) {
|
|
|
72
72
|
}
|
|
73
73
|
return message;
|
|
74
74
|
}
|
|
75
|
+
/** Get-Printer-Attributes: the whole response; `attributesOf` gives the printer's attributes. `requested` replaces the poll's list. */
|
|
76
|
+
export const getPrinterAttributes = (fetcher, address, options = {}) => call(fetcher, address, (uri) => getPrinterAttributesRequest(uri, options.requested ?? REQUESTED, options.requestId), options);
|
|
77
|
+
/** Identify-Printer: the printer shows or sounds that it is the one (PWG 5100.13). No paper. */
|
|
78
|
+
export const identifyPrinter = (fetcher, address, options = {}) => call(fetcher, address, (uri) => operationRequest(OPERATION.identifyPrinter, uri, {
|
|
79
|
+
requestId: options.requestId,
|
|
80
|
+
}), options);
|
|
81
|
+
/** Who asks: RFC 8011 has a client send it on every operation. `home` unless the caller names someone. */
|
|
82
|
+
const userAttribute = (user = "home") => ({
|
|
83
|
+
name: "requesting-user-name",
|
|
84
|
+
tag: TAG.nameWithoutLanguage,
|
|
85
|
+
values: [user],
|
|
86
|
+
});
|
|
87
|
+
function jobAttributes(job) {
|
|
88
|
+
const attributes = [
|
|
89
|
+
userAttribute(job.user),
|
|
90
|
+
{
|
|
91
|
+
name: "job-name",
|
|
92
|
+
tag: TAG.nameWithoutLanguage,
|
|
93
|
+
values: [job.name ?? "home"],
|
|
94
|
+
},
|
|
95
|
+
{ name: "document-format", tag: TAG.mimeMediaType, values: [job.format] },
|
|
96
|
+
];
|
|
97
|
+
const attrs = [
|
|
98
|
+
{ name: "sides", tag: TAG.keyword, values: [job.sides ?? "one-sided"] },
|
|
99
|
+
];
|
|
100
|
+
if (job.media)
|
|
101
|
+
attrs.push({ name: "media", tag: TAG.keyword, values: [job.media] });
|
|
102
|
+
if (job.copies !== undefined)
|
|
103
|
+
attrs.push({ name: "copies", tag: TAG.integer, values: [job.copies] });
|
|
104
|
+
return { attributes, job: attrs };
|
|
105
|
+
}
|
|
106
|
+
/** Validate-Job: the printer checks the job's attributes as Print-Job would, and prints nothing. */
|
|
107
|
+
export const validateJob = (fetcher, address, job, options = {}) => call(fetcher, address, (uri) => operationRequest(OPERATION.validateJob, uri, {
|
|
108
|
+
...jobAttributes(job),
|
|
109
|
+
requestId: options.requestId,
|
|
110
|
+
}), options);
|
|
111
|
+
/** No upload waits longer than this: a page of raster is a few hundred kB, and a printer can be slow to take it. */
|
|
112
|
+
export const PRINT_TIMEOUT_MS = 60_000;
|
|
113
|
+
/**
|
|
114
|
+
* Print-Job: the job's attributes and the document in one request. A timeout leaves it unknown whether the printer took
|
|
115
|
+
* the job, so the error says not to send it again blindly.
|
|
116
|
+
*/
|
|
117
|
+
export async function printJob(fetcher, address, job, document, options = {}) {
|
|
118
|
+
let answer;
|
|
119
|
+
try {
|
|
120
|
+
answer = await call(fetcher, address, (uri) => operationRequest(OPERATION.printJob, uri, {
|
|
121
|
+
...jobAttributes(job),
|
|
122
|
+
data: document,
|
|
123
|
+
requestId: options.requestId,
|
|
124
|
+
}), { timeoutMs: PRINT_TIMEOUT_MS, ...options });
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
if (error instanceof Error && error.name === "TimeoutError")
|
|
128
|
+
throw new ConnectorError("the printer did not answer the print job in time; it may still print, so check the printer before sending it again", { code: "timeout", cause: error });
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
const a = attributesOf(answer, TAG.job);
|
|
132
|
+
const n = (v) => (typeof v === "number" ? v : undefined);
|
|
133
|
+
const reasons = a["job-state-reasons"]?.filter((r) => typeof r === "string");
|
|
134
|
+
return {
|
|
135
|
+
jobId: n(a["job-id"]?.[0]),
|
|
136
|
+
state: n(a["job-state"]?.[0]),
|
|
137
|
+
reasons: reasons?.length ? reasons : undefined,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
/** Cancel-Job: stop a job by its id, from Print-Job's answer. */
|
|
141
|
+
export const cancelJob = (fetcher, address, jobId, options = {}) => call(fetcher, address, (uri) => operationRequest(OPERATION.cancelJob, uri, {
|
|
142
|
+
requestId: options.requestId,
|
|
143
|
+
attributes: [
|
|
144
|
+
{ name: "job-id", tag: TAG.integer, values: [jobId] },
|
|
145
|
+
userAttribute(options.user), // printers often let only the job's owner cancel it
|
|
146
|
+
],
|
|
147
|
+
}), options);
|
|
@@ -23,9 +23,13 @@ export declare const TAG: {
|
|
|
23
23
|
readonly naturalLanguage: 72;
|
|
24
24
|
readonly mimeMediaType: 73;
|
|
25
25
|
};
|
|
26
|
-
/** The operation ids this package sends
|
|
26
|
+
/** The operation ids this package sends (RFC 8011; Identify-Printer from PWG 5100.13). */
|
|
27
27
|
export declare const OPERATION: {
|
|
28
|
+
readonly printJob: 2;
|
|
29
|
+
readonly validateJob: 4;
|
|
30
|
+
readonly cancelJob: 8;
|
|
28
31
|
readonly getPrinterAttributes: 11;
|
|
32
|
+
readonly identifyPrinter: 60;
|
|
29
33
|
};
|
|
30
34
|
export type IppRange = {
|
|
31
35
|
lower: number;
|
|
@@ -65,8 +69,18 @@ export type IppMessage = {
|
|
|
65
69
|
};
|
|
66
70
|
/** A message as bytes. Collections are not encoded (a Get-Printer-Attributes request has none). */
|
|
67
71
|
export declare function encodeMessage(m: IppMessage): Uint8Array<ArrayBuffer>;
|
|
68
|
-
/**
|
|
69
|
-
|
|
72
|
+
/**
|
|
73
|
+
* A request to one printer: IPP 2.0, utf-8, English, the printer's `ipp://` URI, then `attributes` in the operation
|
|
74
|
+
* group, `job` (when given) in a job group, and `data` (a document) after the end tag.
|
|
75
|
+
*/
|
|
76
|
+
export declare function operationRequest(code: number, printerUri: string, options?: {
|
|
77
|
+
attributes?: IppAttribute[];
|
|
78
|
+
job?: IppAttribute[];
|
|
79
|
+
data?: Uint8Array;
|
|
80
|
+
requestId?: number;
|
|
81
|
+
}): Uint8Array<ArrayBuffer>;
|
|
82
|
+
/** The Get-Printer-Attributes request: the attributes wanted. */
|
|
83
|
+
export declare const getPrinterAttributesRequest: (printerUri: string, requested: string[], requestId?: number) => Uint8Array<ArrayBuffer>;
|
|
70
84
|
/**
|
|
71
85
|
* A message's bytes as groups of attributes. An unknown or malformed value is kept as `IppUnknown`, never thrown; only a
|
|
72
86
|
* message that cannot be walked (cut short, a value before any attribute) throws. A collection and its members are skipped.
|
|
@@ -27,8 +27,14 @@ export const TAG = {
|
|
|
27
27
|
naturalLanguage: 0x48,
|
|
28
28
|
mimeMediaType: 0x49,
|
|
29
29
|
};
|
|
30
|
-
/** The operation ids this package sends
|
|
31
|
-
export const OPERATION = {
|
|
30
|
+
/** The operation ids this package sends (RFC 8011; Identify-Printer from PWG 5100.13). */
|
|
31
|
+
export const OPERATION = {
|
|
32
|
+
printJob: 0x0002,
|
|
33
|
+
validateJob: 0x0004,
|
|
34
|
+
cancelJob: 0x0008,
|
|
35
|
+
getPrinterAttributes: 0x000b,
|
|
36
|
+
identifyPrinter: 0x003c,
|
|
37
|
+
};
|
|
32
38
|
const STRING_TAGS = new Set([
|
|
33
39
|
TAG.textWithoutLanguage,
|
|
34
40
|
TAG.nameWithoutLanguage,
|
|
@@ -94,14 +100,21 @@ export function encodeMessage(m) {
|
|
|
94
100
|
});
|
|
95
101
|
}
|
|
96
102
|
out.push(TAG.end);
|
|
97
|
-
|
|
103
|
+
const bytes = new Uint8Array(out.length + (m.data?.length ?? 0));
|
|
104
|
+
bytes.set(out);
|
|
105
|
+
if (m.data)
|
|
106
|
+
bytes.set(m.data, out.length);
|
|
107
|
+
return bytes;
|
|
98
108
|
}
|
|
99
|
-
/**
|
|
100
|
-
|
|
109
|
+
/**
|
|
110
|
+
* A request to one printer: IPP 2.0, utf-8, English, the printer's `ipp://` URI, then `attributes` in the operation
|
|
111
|
+
* group, `job` (when given) in a job group, and `data` (a document) after the end tag.
|
|
112
|
+
*/
|
|
113
|
+
export function operationRequest(code, printerUri, options = {}) {
|
|
101
114
|
return encodeMessage({
|
|
102
115
|
version: [2, 0],
|
|
103
|
-
code
|
|
104
|
-
requestId,
|
|
116
|
+
code,
|
|
117
|
+
requestId: options.requestId ?? 1,
|
|
105
118
|
groups: [
|
|
106
119
|
{
|
|
107
120
|
tag: TAG.operation,
|
|
@@ -113,12 +126,23 @@ export function getPrinterAttributesRequest(printerUri, requested, requestId = 1
|
|
|
113
126
|
values: ["en"],
|
|
114
127
|
},
|
|
115
128
|
{ name: "printer-uri", tag: TAG.uri, values: [printerUri] },
|
|
116
|
-
|
|
129
|
+
...(options.attributes ?? []),
|
|
117
130
|
],
|
|
118
131
|
},
|
|
132
|
+
...(options.job?.length
|
|
133
|
+
? [{ tag: TAG.job, attributes: options.job }]
|
|
134
|
+
: []),
|
|
119
135
|
],
|
|
136
|
+
data: options.data,
|
|
120
137
|
});
|
|
121
138
|
}
|
|
139
|
+
/** The Get-Printer-Attributes request: the attributes wanted. */
|
|
140
|
+
export const getPrinterAttributesRequest = (printerUri, requested, requestId = 1) => operationRequest(OPERATION.getPrinterAttributes, printerUri, {
|
|
141
|
+
requestId,
|
|
142
|
+
attributes: [
|
|
143
|
+
{ name: "requested-attributes", tag: TAG.keyword, values: requested },
|
|
144
|
+
],
|
|
145
|
+
});
|
|
122
146
|
const fail = (why) => {
|
|
123
147
|
throw new Error(`ipp: ${why}`);
|
|
124
148
|
};
|
|
@@ -5,6 +5,8 @@ import { type DeviceRecord, type Integration, type Part } from "../../home.ts";
|
|
|
5
5
|
import type { Manifest } from "../../manifest.ts";
|
|
6
6
|
import { type Printer, type PrinterStatus, type PrintersState } from "./state.ts";
|
|
7
7
|
export { type Marker, type Printer, type PrintersState, type PrinterStatus, printerText, } from "./state.ts";
|
|
8
|
+
export { cancelJob, identifyPrinter, type PrintedJob, type PrinterAddress, type PrintJob, printJob, validateJob, } from "./client.ts";
|
|
9
|
+
export { type GreyPage, mediaPixels, pwgRaster, type RasterMedia, } from "./raster.ts";
|
|
8
10
|
/** One printer in a home config. Everything the printer reports about itself (model, markers, state) comes live; `host` and `port` are only where it last answered. */
|
|
9
11
|
export declare const configSchema: z.ZodObject<{
|
|
10
12
|
connector: z.ZodLiteral<"printers">;
|
|
@@ -8,6 +8,8 @@ import { getPrinterAttributes, printerUrl } from "./client.js";
|
|
|
8
8
|
import { attributesOf } from "./codec.js";
|
|
9
9
|
import { printerKey, printerText, toState, } from "./state.js";
|
|
10
10
|
export { printerText, } from "./state.js";
|
|
11
|
+
export { cancelJob, identifyPrinter, printJob, validateJob, } from "./client.js";
|
|
12
|
+
export { mediaPixels, pwgRaster, } from "./raster.js";
|
|
11
13
|
/** One printer in a home config. Everything the printer reports about itself (model, markers, state) comes live; `host` and `port` are only where it last answered. */
|
|
12
14
|
export const configSchema = z.looseObject({
|
|
13
15
|
connector: z.literal("printers"),
|
|
@@ -92,6 +94,8 @@ export function printers(config = {}) {
|
|
|
92
94
|
name: t.name,
|
|
93
95
|
model: good.model ?? t.foundModel,
|
|
94
96
|
host: t.host,
|
|
97
|
+
port: t.port,
|
|
98
|
+
rp: t.rp,
|
|
95
99
|
room: t.room,
|
|
96
100
|
...(offline ? { state: "offline" } : {}),
|
|
97
101
|
ipps: t.ipps,
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** One page: `pixels` is `width × height` bytes, row by row from the top, 0 black to 255 white. */
|
|
2
|
+
export type GreyPage = {
|
|
3
|
+
width: number;
|
|
4
|
+
height: number;
|
|
5
|
+
pixels: Uint8Array;
|
|
6
|
+
};
|
|
7
|
+
/** What every page is printed as: the printer's resolution and the PWG media name and size it lists. */
|
|
8
|
+
export type RasterMedia = {
|
|
9
|
+
/** PWG self-describing name, as the printer lists it in `media-supported`: `iso_a4_210x297mm`. */
|
|
10
|
+
name: string;
|
|
11
|
+
/** dots per inch, one of the printer's `pwg-raster-document-resolution-supported`. */
|
|
12
|
+
dpi: number;
|
|
13
|
+
};
|
|
14
|
+
/** 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. */
|
|
15
|
+
export declare function mediaPixels(media: RasterMedia): {
|
|
16
|
+
width: number;
|
|
17
|
+
height: number;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Pages as one PWG Raster document, `image/pwg-raster`. Every page must be `width × height` bytes. Pages may come one at
|
|
21
|
+
* a time (a generator), so only the packed document and the current page are held; then `total` is the page count.
|
|
22
|
+
*/
|
|
23
|
+
export declare function pwgRaster(pages: Iterable<GreyPage>, media: RasterMedia, total?: number): Uint8Array<ArrayBuffer>;
|
|
24
|
+
/** A PWG Raster document back as pages and their headers' key fields; for tests and for checking a file before it prints. */
|
|
25
|
+
export declare function readPwgRaster(file: Uint8Array): (GreyPage & {
|
|
26
|
+
dpi: number;
|
|
27
|
+
pageSizeName: string;
|
|
28
|
+
colorSpace: number;
|
|
29
|
+
bitsPerPixel: number;
|
|
30
|
+
})[];
|