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