@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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { errorText } from "../../connector.js";
|
|
2
2
|
import { writeLane } from "../../lane.js";
|
|
3
|
+
import { CAST_WAV, castWav, isWav, wavText } from "./wav.js";
|
|
3
4
|
import { NS } from "./codec.js";
|
|
4
5
|
import { MediaStatus, MultizoneStatus, ReceiverStatus, } from "./schemas.js";
|
|
5
6
|
import { castKey, DEFAULT_MEDIA_RECEIVER, findCast, } from "./state.js";
|
|
@@ -221,22 +222,51 @@ export function castActions(ctx) {
|
|
|
221
222
|
.get(id)
|
|
222
223
|
.send("level" in volume ? "level" : "muted", volume, (v) => receiver(live(find(target)), { type: "SET_VOLUME", volume: v }));
|
|
223
224
|
};
|
|
225
|
+
const toReceiver = (d, payload) => ({ device: d.name, channel: "receiver", payload });
|
|
226
|
+
/** A dry SET_VOLUME: the target found and connected, nothing sent. */
|
|
227
|
+
const dryVolume = (target, volume) => {
|
|
228
|
+
const d = find(target);
|
|
229
|
+
live(d);
|
|
230
|
+
return {
|
|
231
|
+
wouldSend: [toReceiver(d, { type: "SET_VOLUME", volume })],
|
|
232
|
+
};
|
|
233
|
+
};
|
|
234
|
+
/** Each WAV clip made here, and the format it came from, so a dry run can say what was converted. */
|
|
235
|
+
const fromWav = new WeakMap();
|
|
236
|
+
/** WAV bytes as 48 kHz 16-bit stereo, which audio-only speakers play (`wav.ts`); any other clip as it is. */
|
|
237
|
+
const playable = (clip) => {
|
|
238
|
+
if ("url" in clip || !isWav(clip.type) || fromWav.has(clip.bytes))
|
|
239
|
+
return clip;
|
|
240
|
+
const r = castWav(clip.bytes);
|
|
241
|
+
if (r.converted)
|
|
242
|
+
fromWav.set(r.bytes, r.from);
|
|
243
|
+
return { bytes: r.bytes, type: "audio/wav" };
|
|
244
|
+
};
|
|
224
245
|
const actions = {
|
|
225
|
-
async setVolume(target, level) {
|
|
246
|
+
async setVolume(target, level, options) {
|
|
226
247
|
checkLevel(level);
|
|
248
|
+
if (options?.dryRun)
|
|
249
|
+
return dryVolume(target, { level });
|
|
227
250
|
await setVolume(target, { level });
|
|
228
251
|
},
|
|
229
|
-
async setMuted(target, muted) {
|
|
252
|
+
async setMuted(target, muted, options) {
|
|
253
|
+
if (options?.dryRun)
|
|
254
|
+
return dryVolume(target, { muted });
|
|
230
255
|
await setVolume(target, { muted });
|
|
231
256
|
},
|
|
232
|
-
async stop(target) {
|
|
233
|
-
const
|
|
257
|
+
async stop(target, options) {
|
|
258
|
+
const d = find(target);
|
|
259
|
+
const s = live(d);
|
|
234
260
|
const app = (await receiver(s)).applications?.[0];
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
261
|
+
const stop = {
|
|
262
|
+
type: "STOP",
|
|
263
|
+
...(app?.sessionId ? { sessionId: app.sessionId } : {}),
|
|
264
|
+
};
|
|
265
|
+
const running = app && !app.isIdleScreen;
|
|
266
|
+
if (options?.dryRun)
|
|
267
|
+
return { wouldSend: running ? [toReceiver(d, stop)] : [] };
|
|
268
|
+
if (running)
|
|
269
|
+
await receiver(s, stop);
|
|
240
270
|
},
|
|
241
271
|
/**
|
|
242
272
|
* Read the target's state, set the volume if asked and unmute a muted target, launch the Default Media Receiver, play the clip to its end, then
|
|
@@ -244,11 +274,13 @@ export function castActions(ctx) {
|
|
|
244
274
|
* device gets STOP, then the volume (a group's own first, then member by member through each member's own socket). Another app
|
|
245
275
|
* (Spotify, YouTube) is interrupted and cannot be resumed from here; the result says so. Throws only before anything changed.
|
|
246
276
|
*/
|
|
247
|
-
async announce(target,
|
|
277
|
+
async announce(target, given, options = {}) {
|
|
248
278
|
if (options.volume !== undefined)
|
|
249
279
|
checkLevel(options.volume);
|
|
250
|
-
if (!("url" in
|
|
280
|
+
if (!("url" in given) && !ctx.serve)
|
|
251
281
|
throw new Error("a clip of bytes needs serve: serveClip from @khalido/home/cast/serve");
|
|
282
|
+
const clip = playable(given); // a bad WAV throws here, before anything changed
|
|
283
|
+
const wavFrom = "url" in clip ? undefined : fromWav.get(clip.bytes);
|
|
252
284
|
let d = find(target);
|
|
253
285
|
const s = d.kind === "group" ? await regroup(d) : live(d);
|
|
254
286
|
d = ctx.devices()[d.id] ?? d; // a group's host as discovery gives it now, for serving the clip on its network
|
|
@@ -266,6 +298,8 @@ export function castActions(ctx) {
|
|
|
266
298
|
restored: [],
|
|
267
299
|
notRestored: [],
|
|
268
300
|
};
|
|
301
|
+
if (options.dryRun)
|
|
302
|
+
return dryAnnounce();
|
|
269
303
|
const serving = new AbortController();
|
|
270
304
|
let served;
|
|
271
305
|
let volumeSet = false;
|
|
@@ -311,6 +345,52 @@ export function castActions(ctx) {
|
|
|
311
345
|
await restoreVolume();
|
|
312
346
|
}
|
|
313
347
|
return result;
|
|
348
|
+
/** What the announce would send, from the reads above; what it could not put back goes in `notRestored`. */
|
|
349
|
+
function dryAnnounce() {
|
|
350
|
+
const to = (device, payload) => ({
|
|
351
|
+
device: device.name,
|
|
352
|
+
channel: "receiver",
|
|
353
|
+
payload,
|
|
354
|
+
});
|
|
355
|
+
const media = (payload) => ({
|
|
356
|
+
device: d.name,
|
|
357
|
+
channel: "media",
|
|
358
|
+
payload,
|
|
359
|
+
});
|
|
360
|
+
const sends = [];
|
|
361
|
+
if (options.volume !== undefined)
|
|
362
|
+
sends.push(to(d, { type: "SET_VOLUME", volume: { level: options.volume } }));
|
|
363
|
+
if (unmute)
|
|
364
|
+
sends.push(to(d, { type: "SET_VOLUME", volume: { muted: false } }));
|
|
365
|
+
sends.push(to(d, { type: "LAUNCH", appId: DEFAULT_MEDIA_RECEIVER }), media({
|
|
366
|
+
type: "LOAD",
|
|
367
|
+
media: {
|
|
368
|
+
contentId: "url" in clip
|
|
369
|
+
? clip.url
|
|
370
|
+
: `(${clip.bytes.length} bytes of ${clip.type}${wavFrom ? `, converted from ${wavText(wavFrom)} to ${wavText(CAST_WAV)}` : ""}, served on the LAN)`,
|
|
371
|
+
contentType: clip.type ?? "audio/mpeg",
|
|
372
|
+
streamType: "BUFFERED",
|
|
373
|
+
},
|
|
374
|
+
autoplay: true,
|
|
375
|
+
}));
|
|
376
|
+
if (priorMedia)
|
|
377
|
+
sends.push(media({ type: "LOAD", ...priorMedia }));
|
|
378
|
+
else {
|
|
379
|
+
sends.push(to(d, { type: "STOP" }));
|
|
380
|
+
if (!idleApp(prior) && prior?.appId !== DEFAULT_MEDIA_RECEIVER)
|
|
381
|
+
result.notRestored.push({
|
|
382
|
+
name: d.name,
|
|
383
|
+
why: `${prior?.displayName ?? prior?.appId ?? "an app"} would be interrupted and cannot be resumed from here`,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
for (const step of volumeSteps())
|
|
387
|
+
if ("gap" in step)
|
|
388
|
+
result.notRestored.push({ name: step.name, why: step.gap });
|
|
389
|
+
else
|
|
390
|
+
for (const p of step.payloads)
|
|
391
|
+
sends.push(to(step, p));
|
|
392
|
+
return { ...result, why: "dry run", wouldSend: sends };
|
|
393
|
+
}
|
|
314
394
|
async function restoreApp() {
|
|
315
395
|
try {
|
|
316
396
|
// A LAUNCH that timed out may still have started: look before deciding nothing of ours runs.
|
|
@@ -346,78 +426,91 @@ export function castActions(ctx) {
|
|
|
346
426
|
});
|
|
347
427
|
}
|
|
348
428
|
}
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
muted: before.volume?.muted,
|
|
358
|
-
},
|
|
359
|
-
];
|
|
360
|
-
// A group keeps a level of its own, which its members' levels don't bring back (live, 2026-11-10: it stayed at the
|
|
361
|
-
// announcement's 30 with every member restored). First, so whatever it does to the members, their own levels land last.
|
|
429
|
+
/**
|
|
430
|
+
* The volume put back after the clip, as steps: a group's own level first (it keeps one of its own, which its members'
|
|
431
|
+
* levels don't bring back: live, 2026-11-10, it stayed at the announcement's 30 with every member restored), so
|
|
432
|
+
* whatever it does to the members, their own levels land last; then each member's, or the target's, through its own
|
|
433
|
+
* socket. A step it can't take is a gap with the reason. `restoreVolume` takes them; a dry run reports them.
|
|
434
|
+
*/
|
|
435
|
+
function volumeSteps() {
|
|
436
|
+
const steps = [];
|
|
362
437
|
if (d.kind === "group" && options.volume !== undefined) {
|
|
363
438
|
const level = before.volume?.level;
|
|
364
|
-
|
|
365
|
-
|
|
439
|
+
steps.push(level === undefined
|
|
440
|
+
? { name: d.name, gap: "its volume before was not reported" }
|
|
441
|
+
: {
|
|
366
442
|
name: d.name,
|
|
367
|
-
|
|
443
|
+
socket: s,
|
|
444
|
+
payloads: [{ type: "SET_VOLUME", volume: { level } }],
|
|
445
|
+
label: `${d.name}: volume`,
|
|
368
446
|
});
|
|
369
|
-
else
|
|
370
|
-
try {
|
|
371
|
-
await receiver(s, { type: "SET_VOLUME", volume: { level } });
|
|
372
|
-
result.restored.push(`${d.name}: volume`);
|
|
373
|
-
}
|
|
374
|
-
catch (error) {
|
|
375
|
-
result.notRestored.push({
|
|
376
|
-
name: d.name,
|
|
377
|
-
why: `volume not restored: ${errorText(error)}`,
|
|
378
|
-
});
|
|
379
|
-
}
|
|
380
447
|
}
|
|
448
|
+
const each = d.kind === "group"
|
|
449
|
+
? members
|
|
450
|
+
: options.volume !== undefined || unmute
|
|
451
|
+
? [
|
|
452
|
+
{
|
|
453
|
+
id: d.id,
|
|
454
|
+
name: d.name,
|
|
455
|
+
level: before.volume?.level,
|
|
456
|
+
muted: before.volume?.muted,
|
|
457
|
+
},
|
|
458
|
+
]
|
|
459
|
+
: [];
|
|
381
460
|
for (const m of each) {
|
|
382
461
|
const ms = m.id === d.id ? s : ctx.session(m.id);
|
|
383
462
|
if (m.level === undefined)
|
|
384
|
-
|
|
463
|
+
steps.push({
|
|
385
464
|
name: m.name,
|
|
386
|
-
|
|
465
|
+
gap: "its volume before was not reported",
|
|
387
466
|
});
|
|
388
467
|
else if (!ms)
|
|
389
|
-
|
|
468
|
+
steps.push({
|
|
390
469
|
name: m.name,
|
|
391
|
-
|
|
470
|
+
gap: "not connected, so its volume stays as the announcement set it",
|
|
392
471
|
});
|
|
393
472
|
else
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
}
|
|
473
|
+
steps.push({
|
|
474
|
+
name: m.name,
|
|
475
|
+
socket: ms,
|
|
476
|
+
payloads: [
|
|
477
|
+
{ type: "SET_VOLUME", volume: { level: m.level } },
|
|
478
|
+
...(m.muted
|
|
479
|
+
? [{ type: "SET_VOLUME", volume: { muted: true } }]
|
|
480
|
+
: []),
|
|
481
|
+
],
|
|
482
|
+
label: `${m.name}: volume${m.muted ? ", muted" : ""}`,
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
return steps;
|
|
486
|
+
}
|
|
487
|
+
async function restoreVolume() {
|
|
488
|
+
for (const step of volumeSteps()) {
|
|
489
|
+
if ("gap" in step) {
|
|
490
|
+
result.notRestored.push({ name: step.name, why: step.gap });
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
try {
|
|
494
|
+
for (const p of step.payloads)
|
|
495
|
+
await receiver(step.socket, p);
|
|
496
|
+
result.restored.push(step.label);
|
|
497
|
+
}
|
|
498
|
+
catch (error) {
|
|
499
|
+
result.notRestored.push({
|
|
500
|
+
name: step.name,
|
|
501
|
+
why: `volume not restored: ${errorText(error)}`,
|
|
502
|
+
});
|
|
503
|
+
}
|
|
412
504
|
}
|
|
413
505
|
}
|
|
414
506
|
},
|
|
415
|
-
async announceAll(targets,
|
|
507
|
+
async announceAll(targets, given, options = {}) {
|
|
416
508
|
// A caller's mistake is one error, not a failure per speaker.
|
|
417
509
|
if (options.volume !== undefined)
|
|
418
510
|
checkLevel(options.volume);
|
|
419
|
-
if (!("url" in
|
|
511
|
+
if (!("url" in given) && !ctx.serve)
|
|
420
512
|
throw new Error("a clip of bytes needs serve: serveClip from @khalido/home/cast/serve");
|
|
513
|
+
const clip = playable(given); // converted once, for every target
|
|
421
514
|
const id = (t) => {
|
|
422
515
|
try {
|
|
423
516
|
return find(t).id;
|
|
@@ -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">;
|