@khalido/home 0.1.0 → 0.1.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 +23 -1
- package/dist/connectors/cast/index.js +3 -1
- package/dist/connectors/cast/serve.d.ts +8 -1
- package/dist/connectors/cast/tls.js +1 -1
- package/dist/connectors/spotify/index.js +13 -4
- package/dist/connectors/weather/index.js +16 -3
- package/dist/discover.d.ts +3 -1
- package/dist/discover.js +8 -2
- package/dist/energy.js +1 -1
- package/dist/finders.d.ts +3 -0
- package/dist/finders.js +6 -3
- package/docs/cli.md +3 -1
- package/docs/config.md +2 -2
- package/docs/google.md +1 -1
- package/package.json +2 -2
- package/skills/draw-home/README.md +5 -6
- package/skills/draw-home/SKILL.md +3 -5
- package/src/cli/discover.ts +8 -1
- package/src/connectors/cast/README.md +3 -2
- package/src/connectors/cast/index.ts +2 -1
- package/src/connectors/cast/serve.ts +11 -1
- package/src/connectors/cast/tls.ts +1 -1
- package/src/connectors/daikin/README.md +1 -1
- package/src/connectors/spotify/README.md +7 -4
- package/src/connectors/spotify/index.ts +12 -4
- package/src/connectors/weather/README.md +1 -1
- package/src/connectors/weather/index.ts +19 -3
- package/src/discover.ts +8 -4
- package/src/energy.ts +1 -1
- package/src/finders.ts +8 -5
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,27 @@ All notable changes to home.ts (`@khalido/home` on npm), in [Keep a Changelog](h
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.1.1] - 2026-11-09
|
|
8
|
+
|
|
9
|
+
Fixes from chota-bot's first live reads, on a Raspberry Pi and a Mac. No breaking changes.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **`discover`:** a `stopOn` option, for an error no later pass could get past.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- **cast:** `tlsConnect` gives up after 3 s, down from 10 s, so one offline device holds up a one-shot read for less. A device whose cached address fails and that discovery can't find no longer gets a second try before it shows as disconnected. `home cast` with one device down: 13.3 s to 2.3 s (Bun 1.4.4, macOS, 2026-11-09).
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- **spotify:** `maxVolume`'s first-play window now opens on a start through `buffering` too: idle → buffering → playing escaped it.
|
|
22
|
+
- **weather:** the text's chance of rain for today is the highest of the hours still to come, once that's lower than the day's figure and from the same source, and it says "the rest of the day". `forecast.today.rainChance` stays as the source gave it.
|
|
23
|
+
- **`home discover`:** without `avahi-browse` or `dns-sd` it fails at once, naming the package, instead of after both passes.
|
|
24
|
+
- **spotify:** a frame or open event from a socket that had already ended (dropped, or closed after 10 s with no message) no longer changes the state or the next socket's status.
|
|
25
|
+
- **cast:** `@khalido/home/cast/serve`'s types no longer name `NodeJS.Dict`, so they compile without `@types/node`.
|
|
26
|
+
- **Docs:** corrected the config parser, the printers exception to one device per connector, Daikin's `home status` address, and the weather user agent. `skills/draw-home` no longer points at private paths.
|
|
27
|
+
|
|
7
28
|
## [0.1.0] - 2026-11-08
|
|
8
29
|
|
|
9
30
|
First release. Nine connectors: 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. Each connector's README says what has run against a real device.
|
|
@@ -21,5 +42,6 @@ First release. Nine connectors: Hue, Daikin, Enphase, Google Cast, AirPrint prin
|
|
|
21
42
|
- **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.
|
|
22
43
|
- **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.
|
|
23
44
|
|
|
24
|
-
[Unreleased]: https://github.com/khalido/home.ts/compare/v0.1.
|
|
45
|
+
[Unreleased]: https://github.com/khalido/home.ts/compare/v0.1.1...HEAD
|
|
46
|
+
[0.1.1]: https://github.com/khalido/home.ts/compare/v0.1.0...v0.1.1
|
|
25
47
|
[0.1.0]: https://github.com/khalido/home.ts/releases/tag/v0.1.0
|
|
@@ -297,7 +297,9 @@ export function cast(config) {
|
|
|
297
297
|
// A cached address that fails is only stale: discover now and try where it answers, still "connecting".
|
|
298
298
|
cached = false;
|
|
299
299
|
await discover();
|
|
300
|
-
|
|
300
|
+
// Unless discovery can't find it: then the address is the one that just failed, and a second try only waits out the timeout. The backoff still retries.
|
|
301
|
+
if (!lastFound || lastFound.has(id))
|
|
302
|
+
continue;
|
|
301
303
|
}
|
|
302
304
|
const why = errorText(error);
|
|
303
305
|
// A cached address the last discovery did not find says so, as a polling connector's does.
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import type { ServeClip } from "./writes.ts";
|
|
2
2
|
/** How long a clip stays served when nothing fetches it. */
|
|
3
3
|
export declare const SERVE_MS = 60000;
|
|
4
|
+
type NetworkAddress = {
|
|
5
|
+
family: string;
|
|
6
|
+
address: string;
|
|
7
|
+
netmask: string;
|
|
8
|
+
internal: boolean;
|
|
9
|
+
};
|
|
4
10
|
/** This machine's IPv4 on the subnet that holds `host`, or with no host, its first non-internal IPv4. */
|
|
5
|
-
export declare function lanAddress(host?: string, interfaces?:
|
|
11
|
+
export declare function lanAddress(host?: string, interfaces?: Record<string, NetworkAddress[] | undefined>): string;
|
|
6
12
|
/**
|
|
7
13
|
* Serve `bytes`: a random port, an unguessable path, the given `Content-Type` and its `Content-Length`, the whole body
|
|
8
14
|
* to every GET (a player may probe before it fetches). It closes on `close()` (`announce` calls it when the clip has
|
|
9
15
|
* played), when `signal` aborts, or after `SERVE_MS`. `host` is the device that will fetch it, so the address is on its network.
|
|
10
16
|
*/
|
|
11
17
|
export declare const serveClip: ServeClip;
|
|
18
|
+
export {};
|
|
@@ -6,7 +6,7 @@ import { encode, framer, } from "./codec.js";
|
|
|
6
6
|
// Verification is off: every Cast device presents a self-signed certificate that rotates every 48 hours, so there is
|
|
7
7
|
// nothing to pin. The integration instead checks the certificate's name (`peer`, the device's id) against discovery.
|
|
8
8
|
/** Open a Cast socket: TLS to host:port within `timeoutMs`, framed CastMessages in and out. */
|
|
9
|
-
export const tlsConnect = ({ host, port, signal, timeoutMs =
|
|
9
|
+
export const tlsConnect = ({ host, port, signal, timeoutMs = 3000, }) => new Promise((resolve, reject) => {
|
|
10
10
|
const socket = connect({ host, port, rejectUnauthorized: false });
|
|
11
11
|
let timer;
|
|
12
12
|
const fail = (error) => (clearTimeout(timer),
|
|
@@ -87,6 +87,7 @@ export function spotify(config = {}) {
|
|
|
87
87
|
let signedIn = false; // signed out → in on this socket: the first status after it counts as a start if it is playing
|
|
88
88
|
let capUntil = -Infinity;
|
|
89
89
|
let capped = false; // the cap has been sent in this window
|
|
90
|
+
let startBuffering = false; // a start from idle is still buffering
|
|
90
91
|
let capNote; // the last first-play lowering, in `anomaly` until the speaker goes idle
|
|
91
92
|
let frameNote; // a frame the schema rejected, or an error nothing was waiting for
|
|
92
93
|
let note; // a write the integration made itself that failed (the cap, interrupt's resume), in `detail`
|
|
@@ -206,7 +207,7 @@ export function spotify(config = {}) {
|
|
|
206
207
|
frameNote = `${name} failed: ${errorText(error)}`;
|
|
207
208
|
publish();
|
|
208
209
|
});
|
|
209
|
-
/** Within the window after
|
|
210
|
+
/** Within the window after a start from idle, a volume above the cap is lowered to it, once (issue #23). */
|
|
210
211
|
const applyCap = () => {
|
|
211
212
|
if (cap === undefined ||
|
|
212
213
|
capped ||
|
|
@@ -274,10 +275,14 @@ export function spotify(config = {}) {
|
|
|
274
275
|
pending.finish();
|
|
275
276
|
if ((f.type === "playback_state" || f.type === "playback_changed") &&
|
|
276
277
|
f.status !== undefined) {
|
|
277
|
-
// A start is idle → playing, or a sign-in whose first status is
|
|
278
|
-
|
|
278
|
+
// A start is idle → playing or buffering, or a sign-in whose first status is either: a phone picked the speaker and played (issue #23).
|
|
279
|
+
// Soloist's jump to 100 comes about 100 ms after `playing` (issue #23), so a start through buffering opens the window again then.
|
|
280
|
+
const started = ((f.status === "playing" || f.status === "buffering") &&
|
|
281
|
+
(statusKnown ? before === "idle" : signedIn)) ||
|
|
282
|
+
(f.status === "playing" && startBuffering);
|
|
279
283
|
if (started)
|
|
280
284
|
((capUntil = clock.now() + CAP_WINDOW_MS), (capped = false));
|
|
285
|
+
startBuffering = f.status === "buffering" && (started || startBuffering);
|
|
281
286
|
signedIn = false;
|
|
282
287
|
if (f.status === "idle")
|
|
283
288
|
capNote = undefined;
|
|
@@ -321,6 +326,7 @@ export function spotify(config = {}) {
|
|
|
321
326
|
queueAsked = false;
|
|
322
327
|
statusKnown = false;
|
|
323
328
|
signedIn = false;
|
|
329
|
+
startBuffering = false;
|
|
324
330
|
pending?.finish(offline());
|
|
325
331
|
for (const w of [...waiters])
|
|
326
332
|
w(); // a write waiting to confirm stops waiting
|
|
@@ -329,7 +335,10 @@ export function spotify(config = {}) {
|
|
|
329
335
|
};
|
|
330
336
|
const stop = () => (end("stopped"), s.close());
|
|
331
337
|
signal.addEventListener("abort", stop, { once: true });
|
|
338
|
+
// An ended socket's late events belong to no session: they must not touch the next socket's state.
|
|
332
339
|
s.onopen = () => {
|
|
340
|
+
if (ended)
|
|
341
|
+
return;
|
|
333
342
|
open = true;
|
|
334
343
|
down = undefined;
|
|
335
344
|
note = undefined; // a new socket: what failed on the last one is history
|
|
@@ -343,7 +352,7 @@ export function spotify(config = {}) {
|
|
|
343
352
|
((whole = true), publish()); // signed in, no playback_state: connected with what there is
|
|
344
353
|
});
|
|
345
354
|
};
|
|
346
|
-
s.onmessage = (ev) => handle(ev.data);
|
|
355
|
+
s.onmessage = (ev) => void (ended || handle(ev.data));
|
|
347
356
|
s.onerror = () => { }; // onclose follows and says it
|
|
348
357
|
s.onclose = () => end("Soloist closed the socket");
|
|
349
358
|
});
|
|
@@ -151,17 +151,30 @@ export function weather(config) {
|
|
|
151
151
|
}
|
|
152
152
|
/** "20:20" in the home's time zone (the runtime's when unset), or "?". */
|
|
153
153
|
const hhmmIn = (iso, timeZone) => iso ? localTime(Date.parse(iso), timeZone) : "?";
|
|
154
|
-
const day = (d) => d
|
|
154
|
+
const day = (d, rainLeft) => d
|
|
155
155
|
? [
|
|
156
156
|
d.minC === undefined && d.maxC === undefined
|
|
157
157
|
? ""
|
|
158
158
|
: `${d.minC ?? "?"}–${d.maxC ?? "?"} °C`,
|
|
159
159
|
d.text?.replace(/\.$/, "").toLowerCase() ?? "",
|
|
160
|
-
d.rainChance === undefined
|
|
160
|
+
d.rainChance === undefined
|
|
161
|
+
? ""
|
|
162
|
+
: rainLeft !== undefined && rainLeft < d.rainChance
|
|
163
|
+
? `${rainLeft}% chance of rain the rest of the day`
|
|
164
|
+
: `${d.rainChance}% chance of rain`,
|
|
161
165
|
]
|
|
162
166
|
.filter(Boolean)
|
|
163
167
|
.join(" ")
|
|
164
168
|
: "?";
|
|
169
|
+
/** The highest hourly chance left today, all 24 hours of it, when the hourly forecast is the same source and day as the forecast: sources are never mixed. */
|
|
170
|
+
const rainLeftToday = (w, at) => {
|
|
171
|
+
if (!w.hourly || w.hourly.source !== w.forecast?.source)
|
|
172
|
+
return undefined;
|
|
173
|
+
const rest = dayBlocks(w.hourly, at, w.timeZone, [
|
|
174
|
+
{ name: "rest", from: 0, to: 24 },
|
|
175
|
+
])?.today[0];
|
|
176
|
+
return rest?.date === w.forecast.today?.date ? rest?.rainChance : undefined;
|
|
177
|
+
};
|
|
165
178
|
/** "rain in 30 min, ends in 55 min, 70%, 0.8 mm, moderate (google)", "raining, ends in 25 min", or "no rain for 2 h"; nothing when the forecast covers none of the window. */
|
|
166
179
|
export function rainText(rain, r) {
|
|
167
180
|
if (!r || !rain)
|
|
@@ -206,7 +219,7 @@ export function weatherText(w, sun) {
|
|
|
206
219
|
: "";
|
|
207
220
|
const f = w.forecast;
|
|
208
221
|
const forecast = f
|
|
209
|
-
? `today ${day(f.today)}, tomorrow ${day(f.tomorrow)} (${f.source}${f.modelled ? " modelled" : ""})`
|
|
222
|
+
? `today ${day(f.today, rainLeftToday(w, Date.parse(sun.at)))}, tomorrow ${day(f.tomorrow)} (${f.source}${f.modelled ? " modelled" : ""})`
|
|
210
223
|
: "";
|
|
211
224
|
const k = w.sky;
|
|
212
225
|
const skyParts = k
|
package/dist/discover.d.ts
CHANGED
|
@@ -119,7 +119,8 @@ export declare function addressBook(cache: AddressCache | undefined, options?: {
|
|
|
119
119
|
/**
|
|
120
120
|
* Run one discovery `passes` times, `gapMs` apart, and return the union (first answer wins). `onPass` sees each pass's
|
|
121
121
|
* additions as they come; `done` ends it early once what was looked for has answered. A pass that throws adds nothing;
|
|
122
|
-
* when every pass threw, discovery never ran, so it throws the last error rather than reporting nothing found.
|
|
122
|
+
* when every pass threw, discovery never ran, so it throws the last error rather than reporting nothing found. An error
|
|
123
|
+
* `stopOn` accepts is thrown at once: no later pass could do better.
|
|
123
124
|
*/
|
|
124
125
|
export declare function discover<T = Found>(pass: () => Promise<T[]>, options: {
|
|
125
126
|
clock: Clock;
|
|
@@ -127,6 +128,7 @@ export declare function discover<T = Found>(pass: () => Promise<T[]>, options: {
|
|
|
127
128
|
gapMs?: number;
|
|
128
129
|
key?: (t: T) => string;
|
|
129
130
|
done?: (all: T[]) => boolean;
|
|
131
|
+
stopOn?: (error: unknown) => boolean;
|
|
130
132
|
onPass?: (n: number, added: T[]) => void;
|
|
131
133
|
signal?: AbortSignal;
|
|
132
134
|
}): Promise<T[]>;
|
package/dist/discover.js
CHANGED
|
@@ -258,7 +258,8 @@ export function addressBook(cache, options = {}) {
|
|
|
258
258
|
/**
|
|
259
259
|
* Run one discovery `passes` times, `gapMs` apart, and return the union (first answer wins). `onPass` sees each pass's
|
|
260
260
|
* additions as they come; `done` ends it early once what was looked for has answered. A pass that throws adds nothing;
|
|
261
|
-
* when every pass threw, discovery never ran, so it throws the last error rather than reporting nothing found.
|
|
261
|
+
* when every pass threw, discovery never ran, so it throws the last error rather than reporting nothing found. An error
|
|
262
|
+
* `stopOn` accepts is thrown at once: no later pass could do better.
|
|
262
263
|
*/
|
|
263
264
|
export async function discover(pass, options) {
|
|
264
265
|
const key = options.key ?? foundKey;
|
|
@@ -272,7 +273,12 @@ export async function discover(pass, options) {
|
|
|
272
273
|
if (options.signal?.aborted)
|
|
273
274
|
break;
|
|
274
275
|
ran++;
|
|
275
|
-
const answers = await pass().catch((error) =>
|
|
276
|
+
const answers = await pass().catch((error) => {
|
|
277
|
+
if (options.stopOn?.(error))
|
|
278
|
+
throw error;
|
|
279
|
+
errors.push(error);
|
|
280
|
+
return [];
|
|
281
|
+
});
|
|
276
282
|
const added = answers.filter((t) => !all.has(key(t)) && all.set(key(t), t));
|
|
277
283
|
options.onPass?.(n, added);
|
|
278
284
|
if (options.done?.([...all.values()]))
|
package/dist/energy.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { kw } from "./manifest.js";
|
|
1
2
|
/**
|
|
2
3
|
* Within this many watts either way the grid flow reads as "balanced": a display rule for the word and the animation,
|
|
3
4
|
* so a meter wobbling around zero does not flip between importing and exporting. `gridW` and `surplusW` stay as read.
|
|
@@ -77,7 +78,6 @@ export function solarLevel(now, capacityW) {
|
|
|
77
78
|
* "balanced (-10 W)", or "" with no grid reading. Rounded for reading only; the numbers above stay as read.
|
|
78
79
|
*/
|
|
79
80
|
export function gridText(now) {
|
|
80
|
-
const kw = (w) => `${(w / 1000).toFixed(1)} kW`;
|
|
81
81
|
const rate = (c, positive, negative) => c === undefined
|
|
82
82
|
? ""
|
|
83
83
|
: `, ${c >= 0 ? positive : negative} ${Math.round(Math.abs(c))} c/h`;
|
package/dist/finders.d.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { type AddressCache, type Connector, type Finder, type Found } from "./discover.ts";
|
|
2
|
+
/** The system's mDNS tool is not installed: discovery can't run until it is. */
|
|
3
|
+
export declare class MissingTool extends Error {
|
|
4
|
+
}
|
|
2
5
|
/**
|
|
3
6
|
* Run a long-lived `dns-sd` query (or `avahi-browse`, by `command`) and return what it printed. It never exits by itself, so it is stopped once `done`
|
|
4
7
|
* says the answer is in, or `quietMs` after the last output, or at `maxMs`. The system's mDNS cache usually answers in
|
package/dist/finders.js
CHANGED
|
@@ -12,6 +12,9 @@ import { parseAvahi, parseAvahiBrowse, parseBrowse, parseResolve, } from "./disc
|
|
|
12
12
|
// nothing here binds UDP 5353 beside it; a slow device (the Enphase gateway took 4 s to answer a fresh query; Bun 1.4.3, 2026-10-16)
|
|
13
13
|
// does not slow a scan. Without the tool the mDNS finders throw, naming it, rather than "nothing answered".
|
|
14
14
|
// `fileCache` keeps the addresses discovery found in a file between runs, so the next run connects straight away.
|
|
15
|
+
/** The system's mDNS tool is not installed: discovery can't run until it is. */
|
|
16
|
+
export class MissingTool extends Error {
|
|
17
|
+
}
|
|
15
18
|
/**
|
|
16
19
|
* Run a long-lived `dns-sd` query (or `avahi-browse`, by `command`) and return what it printed. It never exits by itself, so it is stopped once `done`
|
|
17
20
|
* says the answer is in, or `quietMs` after the last output, or at `maxMs`. The system's mDNS cache usually answers in
|
|
@@ -38,9 +41,9 @@ export function dnsSd(argv, options = {}) {
|
|
|
38
41
|
resolve(out));
|
|
39
42
|
proc.on("error", (error) => {
|
|
40
43
|
(clearTimeout(timer), clearTimeout(quiet));
|
|
41
|
-
reject(
|
|
42
|
-
? `${command} not found; mDNS discovery needs it${install ? ` (${install})` : ""}`
|
|
43
|
-
: `${command}: ${error.message}`));
|
|
44
|
+
reject(error.code === "ENOENT"
|
|
45
|
+
? new MissingTool(`${command} not found; mDNS discovery needs it${install ? ` (${install})` : ""}`)
|
|
46
|
+
: new Error(`${command}: ${error.message}`));
|
|
44
47
|
});
|
|
45
48
|
proc.on("close", finish);
|
|
46
49
|
});
|
package/docs/cli.md
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
```sh
|
|
4
4
|
home <connector> [--json] # one connector's lines: amber, cast, daikin, enphase, hue, printers, spotify, up, weather
|
|
5
5
|
home [status|doctor] [--json] # each integration: connected or not, which env names it still needs, rooms
|
|
6
|
+
home up transactions | categories | uncategorised | spend | categorise | tag | webhooks # Up Bank's lists and reports; categorise and tag write
|
|
6
7
|
home lights off # one write to the whole-home group
|
|
7
8
|
home room <name> on|off|<1-100>
|
|
8
9
|
home scene <name>
|
|
@@ -12,7 +13,7 @@ home discover [--json] [--passes n] # which smart-home devices answer here (mDN
|
|
|
12
13
|
home cache [clear] # the addresses kept between runs, or delete them so the next run discovers afresh
|
|
13
14
|
home config check [path] # validate a home config: what it would set up, secrets as set or unset by name, every problem
|
|
14
15
|
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
|
|
15
|
-
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 status
|
|
16
|
+
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
|
|
16
17
|
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)
|
|
17
18
|
home cast announce --to <name> (--say "<text>" | --file <path>) [--volume 0-100] # play a clip, then put back what was playing and the volume
|
|
18
19
|
home cast volume <name> <0-100>
|
|
@@ -24,6 +25,7 @@ home spotify pause | play [link] | next | volume <0-100> # the Soloist speaker
|
|
|
24
25
|
## Output
|
|
25
26
|
|
|
26
27
|
- **stdout is data, stderr everything else.** Notes such as "nothing to categorise" or "no webhooks" go to stderr, so `home … | jq` never sees them.
|
|
28
|
+
- **Bun on PATH.** The command starts with `#!/usr/bin/env bun`. A non-login SSH shell may not have `~/.bun/bin` on its PATH: run `PATH="$HOME/.bun/bin:$PATH" home`.
|
|
27
29
|
- **`--json` shapes.** `home --json` is one object keyed by connector, with `now` (`energyNow`) first. `home <connector> --json` is that connector's object: `status`, `needs`, `access`, `detail`, `anomaly`, then its state (the weather adds `line`, its text). Lists (`home up transactions`, `uncategorised`, `spend --by merchant`) are arrays, `[]` when empty.
|
|
28
30
|
- **Errors under `--json`** are one object on stderr: `{"error": "…", "code": "usage" | "auth" | "network" | "error", "fix": "home enphase login"}`; `fix` is there only when one command resolves it. In text: `home: <error> (run \`<fix>\`)`.
|
|
29
31
|
- **Exit codes.** 0 success, empty results included. 1 a failure, and `home <connector>` when that connector is not `connected`, so a script can test one. 2 a wrong command, verb or argument. `home` alone lists every connector and exits 0 whatever their state.
|
package/docs/config.md
CHANGED
|
@@ -24,11 +24,11 @@ const home = createHome(
|
|
|
24
24
|
- **A device entry is only how to reach it and where it is**: its identity (the Hue `bridgeId`, a Daikin's `mac`, the Enphase `serial`, a Cast `castId`), its last known `host`, a key's env name, a file's path, its `room`. What a device reports about itself (model, firmware) comes live; a note about it is a comment.
|
|
25
25
|
- **Secrets are env variable names** (`"key": "AMBER_KEY"`), resolved against the `env` the caller passes; a pasted key fails the schema. Files (`ca`, `token`) are paths the caller reads, relative to the config; the library reads nothing.
|
|
26
26
|
- **Problems name the field**: an unknown connector, a missing env name or a device in a missing room is an error, never a skip. `checkHome` returns them all; `loadHome` throws them all. Unknown keys pass through for a consumer's own settings.
|
|
27
|
-
- **One device per connector** for now (each integration is named by its connector), except Cast: one entry per device and speaker group (`name`, the Google Home name, and `castId`, the mDNS id), all read by one `cast` integration that finds every address by discovery, since a group's moves with its leader. A `spotify` entry is only its Soloist `url` (and `maxVolume`): a socket on the same machine, with nothing to discover.
|
|
27
|
+
- **One device per connector** for now (each integration is named by its connector), except printers (one entry each, by `uuid`) and Cast: one entry per device and speaker group (`name`, the Google Home name, and `castId`, the mDNS id), all read by one `cast` integration that finds every address by discovery, since a group's moves with its leader. A `spotify` entry is only its Soloist `url` (and `maxVolume`): a socket on the same machine, with nothing to discover.
|
|
28
28
|
- **Identity, not address.** A device is recognised by the id its own discovery reports; `host` is a cache. `home discover` lists what answers (mDNS, Daikin's UDP broadcast), in two passes 5 s apart since both drop answers now and then: the first pass prints at once, the second only what it adds (`--passes 1` for a quick look; `--json` waits for both); with a config (`--config` or `HOME_CONFIG`) it then matches those against it by identity and prints new devices, moved addresses and conflicts (a different device at an entry's host). `home discover --write <file>` adds the new ones (each with an id no entry has), updates moved hosts and refreshes the `name` of a Cast entry renamed in the Google Home app through `jsonc-parser`'s edits, every comment and other line left as it was, and writes only a result that passes `checkPlan`. Nothing else writes the file; the library never does. Two devices with one id are a config problem.
|
|
29
29
|
- **A device that moves is found again, without periodic scans**, when the caller passes `finders` to `loadHome` (`FINDERS` from `@khalido/home/finders`, Node-side like `weather/ftp`; the CLI does, and jaldi and chota-bot can). After three failed reads in a row (Hue: three failed reconnects of its event stream), Hue, Daikin and Enphase run their discovery (two passes, the second skipped when the first finds the device), switch to the address their identity now answers at and say "moved from a to b" in `detail` (persist it with `home discover --write`); otherwise they look again at 1, 5 and 15 minutes, then hourly for as long as reads fail, so a device back from the wall socket reads again without a restart. The detail says what discovery found ("not found by discovery", or "discovery finds it at its address but reads fail", or "discovery failed: …" when it could not run), and "no answer for a day" once a day has passed; the part stays `disconnected`, since `unavailable` is for a rejected key. `refresh()` resets the wait. Amber and the weather have no discovery and do not take part. Cast always needs `finders.cast`: discovery is how it finds every address.
|
|
30
30
|
- **Discovery is rare: addresses are cached between runs.** `loadHome(config, { cache, configAt })` takes an `AddressCache` (`read()` and `write(entries)`, keyed by connector and identity, each entry `{ host, port?, name?, seenAt }`). A cached address newer than the config (`configAt`, the file's modified time) is tried first, a failure there scans at once rather than after three, and each address that answers is written back when it changed or predates the config, so a config edit is re-trusted at the first good read. Nothing evicts an entry but `home cache clear`: a device gone for good keeps its entry, and `home status` without a config, which reads every Cast entry from the cache, shows it as not found until then. The consumer persists it: the CLI keeps `$XDG_CACHE_HOME/home/addresses.json` (default `~/.cache/home/addresses.json`, 0600) through `fileCache` from `@khalido/home/finders`, written at the end of `status`, `probe` and `discover`; jaldi's sidecar and chota-bot pass `FINDERS` and their own cache the same way. The config's `host` stays the human-set fallback and `home discover --write` the way to change the config. `home status` from a Cast config: 0.33–0.44 s cold, 0.13 s with a warm cache, 1.4 s once when a cached address has gone stale (Bun 1.4.2, measured 2026-10-13).
|
|
31
|
-
- **Parsing** is
|
|
31
|
+
- **Parsing** is through `jsonc-parser` (comments and trailing commas), on Bun and Node alike; a syntax error throws. `bun run schema` rewrites `home.schema.json` from the zod schema, and a test fails if they drift.
|
|
32
32
|
- `createHome` stays for code-first use.
|
|
33
33
|
|
|
34
34
|
## Drawing the home
|
package/docs/google.md
CHANGED
|
@@ -61,7 +61,7 @@ chota's morning print needs an hourly slice (rain chance, mm, condition) for lin
|
|
|
61
61
|
|
|
62
62
|
**Auth.** The caller passes `getToken(): Promise<string>`. The library stores no token. chota passes its better-auth token. A `home calendar login` in the CLI (loopback OAuth, token file in `~/.config/home/`, 0600, like `home enphase login`) comes later and needs an OAuth client in Google Cloud.
|
|
63
63
|
|
|
64
|
-
**State.** The next 14 days per calendar, trimmed to id, title, start, end, all-day, location. Not kept by default: description, attendees, links. All-day events are dates, not instants; they use the home's `timeZone` from the config, never the machine's. Weather
|
|
64
|
+
**State.** The next 14 days per calendar, trimmed to id, title, start, end, all-day, location. Not kept by default: description, attendees, links. All-day events are dates, not instants; they use the home's `timeZone` from the config, never the machine's. Weather's `hhmmIn` does the same.
|
|
65
65
|
|
|
66
66
|
**Helpers**, pure: `eventsOn(day)`, `next()`, `search(text)`. One owner for "what is today".
|
|
67
67
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@khalido/home",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.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",
|
|
@@ -98,7 +98,7 @@
|
|
|
98
98
|
}
|
|
99
99
|
},
|
|
100
100
|
"bin": {
|
|
101
|
-
"home": "
|
|
101
|
+
"home": "src/cli/main.ts"
|
|
102
102
|
},
|
|
103
103
|
"files": [
|
|
104
104
|
"CHANGELOG.md",
|
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
# draw-home: references
|
|
2
2
|
|
|
3
|
-
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.
|
|
3
|
+
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
4
|
|
|
5
5
|
| Reference | What it is | Use it for | Licence |
|
|
6
6
|
| ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
|
|
7
|
-
| [iso-glow](https://isoglow.dev/) (April Zhu
|
|
8
|
-
| [Anatomy](https://skills.wheresryan.sh/anatomy) ([wheresryan22/anatomy](https://github.com/wheresryan22/anatomy)
|
|
9
|
-
| [Hairline](https://github.com/lucasmarkes/hairline)
|
|
10
|
-
| [ai-iso-skill](https://github.com/MrBongoC/ai-iso-skill) `iso-figure`
|
|
7
|
+
| [iso-glow](https://isoglow.dev/) (April Zhu) | A skill that turns a description of an object, device or building into one HTML file: a fine-line isometric drawing where active parts glow and interactive parts respond, with optional orbitable 3D | A live home view: devices that glow when on, pulses when a reading lands; "Glow kit" and "Lessons" sections | MIT |
|
|
8
|
+
| [Anatomy](https://skills.wheresryan.sh/anatomy) ([wheresryan22/anatomy](https://github.com/wheresryan22/anatomy)) | Solid-figure isometric explanatory drawings, with an iso kit and six worked examples | An isometric cutaway of the house: craft rules, `kit/iso-kit.mjs` (in the Anatomy repo), `examples/desk-computer/` (in the Anatomy repo) | MIT |
|
|
9
|
+
| [Hairline](https://github.com/lucasmarkes/hairline) | Line-sketch drawings with a small kernel and a validator | A light, hand-drawn plan style | MIT |
|
|
10
|
+
| [ai-iso-skill](https://github.com/MrBongoC/ai-iso-skill) `iso-figure` | Flat-faced isometric figures on a box, ~10 KB pages | The lightest isometric option | MIT |
|
|
11
11
|
| [casa-serra](https://github.com/Sam1983Aing/casa-serra) (Sam1983Aing; live demo linked from the README, three.js + GSAP ScrollTrigger + Lenis, no build step) | "A flat floor plan that becomes a house as you scroll": the plan "is not a picture. It is the real 3D model, cut at 1.2 m and seen from straight above through a very long lens"; scrolling raises the cut so the walls rise, rooms stay a line drawing until the camera reaches them, geometry built in code, a still and a clip for weak devices | The strongest version of the "SVG with a camera" option (`SKILL.md`): one model in metres, the flat plan as the top view of it, zoom and orbit as a redraw. Added by KO 2026-10-13. | No licence file (checked 2026-10-13): approach only, never paste |
|
|
12
|
-
| sfl-mes `device-drawing` (`~/code/sfl-mes/.claude/skills/device-drawing/`, private) | KO's skill for drawing one machine that moves with its live reading | Sources-first discipline, the live contract (no data, never clamp, fit the host), the review step | KO's |
|
|
13
12
|
|
|
14
13
|
Default stays a flat plan (`examples/home.svg`): it is the fastest to draw, reads at card size, and is easy for an app to draw state on. Pick iso-glow or Anatomy when the home view is the point rather than a panel beside it.
|
|
15
14
|
|
|
@@ -12,8 +12,6 @@ The library does not draw. This guide is for the coding agent that does: turn wh
|
|
|
12
12
|
|
|
13
13
|
Start from `examples/home.svg` and `examples/home.jsonc`. Never start from a blank file. If the person already has a `home.jsonc`, edit its `rooms` and `plan` in place, keeping its comments, and leave every other field alone.
|
|
14
14
|
|
|
15
|
-
This is the home-sized version of the sfl-mes `device-drawing` skill (`~/code/sfl-mes/.claude/skills/device-drawing/`), which draws single machines; read it when a rule here seems thin.
|
|
16
|
-
|
|
17
15
|
## 1. Sources, before any geometry
|
|
18
16
|
|
|
19
17
|
Make a **room table** first, in the SVG's header comment: each room, its floor, its name as the person says it, and the source (plan sheet and page, or photo). Nothing is drawn that has no row.
|
|
@@ -27,7 +25,7 @@ Make a **room table** first, in the SVG's header comment: each room, its floor,
|
|
|
27
25
|
|
|
28
26
|
- Rooms as rectangles, or simple paths for L-shapes; walls, the front door, stairs. Front of the house in the same place on every floor; one floor per band of the canvas, labelled, ground below.
|
|
29
27
|
- **Style: a flat plan by default.** `README.md` beside this file lists the drawing references (iso-glow, Anatomy, Hairline, iso-figure) with what each is good for.
|
|
30
|
-
- **Isometric options.** A glowing fine-line drawing in the [iso-glow](https://isoglow.dev/) style suits a live view; an isometric cutaway in the **Anatomy** style (solid figures, MIT,
|
|
28
|
+
- **Isometric options.** A glowing fine-line drawing in the [iso-glow](https://isoglow.dev/) style suits a live view; an isometric cutaway in the **Anatomy** style (solid figures, MIT, [skills.wheresryan.sh/anatomy](https://skills.wheresryan.sh/anatomy)) is an option when the person asks for it: read its `SKILL.md` craft rules and `examples/desk-computer/` (in the Anatomy repo), keep this contract, and draw rooms as low boxes with the markers on their floors.
|
|
31
29
|
|
|
32
30
|
## 3. The contract (an app draws on this; keep it exact)
|
|
33
31
|
|
|
@@ -38,7 +36,7 @@ Make a **room table** first, in the SVG's header comment: each room, its floor,
|
|
|
38
36
|
5. The SVG fills its host (`width: 100%; height: 100%`; the viewBox holds everything).
|
|
39
37
|
6. **Zoomable**: one root `<g id="plan">` holding every floor, no fixed pixel sizes, labels sized so they still read when a single room fills the screen. Each room group's `getBBox()` is its zoom target, so keep a room's label and marker inside its shape.
|
|
40
38
|
|
|
41
|
-
**What an app does with it
|
|
39
|
+
**What an app does with it:** it draws each device's state into its room's marker from `home.snapshot()`. A missing part is "no data": grey marker, no value, never a stale number shown as live. Values are shown as read, never clamped; odd ones (`Part.anomaly`) get a flag. Hover names the device. Controls live in the app's side panel, not in the SVG.
|
|
42
40
|
|
|
43
41
|
**Energy on the plan** (the numbers are the library's, the drawing and animation are the app's):
|
|
44
42
|
|
|
@@ -48,7 +46,7 @@ Make a **room table** first, in the SVG's header comment: each room, its floor,
|
|
|
48
46
|
|
|
49
47
|
**Interactive, in the app**: wheel or pinch zooms and drag pans (animate the `viewBox`, or a transform on `#plan`); clicking a room zooms to its bounding box and opens that room's devices in the side panel; Escape or double-click returns to the whole house; arrow keys move between rooms. Respect reduced motion (jump, do not animate). A flat plan only zooms and pans.
|
|
50
48
|
|
|
51
|
-
**SVG with a camera** (the interactive option, Anatomy's approach
|
|
49
|
+
**SVG with a camera** (the interactive option, Anatomy's approach): build the house once as a model in metres (each room a box: x, y, width, depth, floor height; walls and doors on it), and render it to SVG through a camera `{ azimuth, elevation, target, zoom }` with Anatomy's `fitProjection` (`kit/iso-kit.mjs` in the Anatomy repo). The scene is a pure function of the camera, so zooming, orbiting and "fly to this room" are a redraw with a new camera, eased; drag orbits, wheel zooms, click a room sets the target to its centre. The rendered SVG still meets the contract above: one `<g class="room">` per room and a marker projected to the room's top corner on every redraw. Keep the model in its own file named by `plan.json` in the config (sizes are data, not hard-coded in the drawing), and spring back to the default camera on Escape.
|
|
52
50
|
|
|
53
51
|
## 4. home.jsonc
|
|
54
52
|
|
package/src/cli/discover.ts
CHANGED
|
@@ -14,7 +14,13 @@ import {
|
|
|
14
14
|
KNOWN_SERVICES,
|
|
15
15
|
matchFound,
|
|
16
16
|
} from "../discover.ts";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
browse,
|
|
19
|
+
daikinFound,
|
|
20
|
+
FINDERS,
|
|
21
|
+
findDaikins,
|
|
22
|
+
MissingTool,
|
|
23
|
+
} from "../finders.ts";
|
|
18
24
|
import {
|
|
19
25
|
addresses,
|
|
20
26
|
configPathOf,
|
|
@@ -83,6 +89,7 @@ export async function discover(o: Options): Promise<void> {
|
|
|
83
89
|
clock: realClock,
|
|
84
90
|
passes,
|
|
85
91
|
key: (x) => (x.found ? foundKey(x.found) : `${x.what}:${x.name}`),
|
|
92
|
+
stopOn: (error) => error instanceof MissingTool,
|
|
86
93
|
onPass: (n, added) => {
|
|
87
94
|
if (o.json) return;
|
|
88
95
|
const tty = process.stderr.isTTY;
|
|
@@ -4,7 +4,7 @@ Nest speakers, Nest Hubs, Chromecasts, Google TV and speaker groups, read over C
|
|
|
4
4
|
|
|
5
5
|
## Built
|
|
6
6
|
|
|
7
|
-
- `cast({ resolve, connect })` keeps every Cast device and group current as one integration. `resolve()` returns what mDNS `_googlecast._tcp` found (`id`, `name` from TXT `fn`, `model` from `md`, host, port, `group`); the CLI runs `dns-sd`. `connect` is `tlsConnect` from `@khalido/home/cast/tls`. `expect` lists the config's entries, so one discovery misses shows as disconnected rather than vanishing. From a config, `resolve` is the shared two-pass discovery, done after the first pass once every entry has answered. An entry with a cached address (the address cache, `loadHome`'s `cache`) connects at once with a 1 s timeout; discovery runs only when an entry has none or a cached address fails, so a stale one still reads as `connecting` until discovery has had its say. A cached port other than 8009 marks a group, since devices always listen on 8009.
|
|
7
|
+
- `cast({ resolve, connect })` keeps every Cast device and group current as one integration. `resolve()` returns what mDNS `_googlecast._tcp` found (`id`, `name` from TXT `fn`, `model` from `md`, host, port, `group`); the CLI runs `dns-sd`. `connect` is `tlsConnect` from `@khalido/home/cast/tls`. `expect` lists the config's entries, so one discovery misses shows as disconnected rather than vanishing. From a config, `resolve` is the shared two-pass discovery, done after the first pass once every entry has answered. An entry with a cached address (the address cache, `loadHome`'s `cache`) connects at once with a 1 s timeout; discovery runs only when an entry has none or a cached address fails, so a stale one still reads as `connecting` until discovery has had its say. If discovery can't find the device either, it shows as disconnected at once rather than waiting on the same address again; the backoff still retries. A cached port other than 8009 marks a group, since devices always listen on 8009.
|
|
8
8
|
- Per socket: `CONNECT` to `receiver-0`, receiver `GET_STATUS`, multizone `GET_STATUS` (a group's members; on a device's own port, the device alone, by its current name), then whatever the device pushes. A running app with the media namespace gets a `CONNECT` and media `GET_STATUS` on its `transportId`, as Home Assistant does. A device's `PING` is answered with `PONG`; a socket silent for three heartbeats (15 s), or without a receiver status 10 s after connecting, is closed and the detail says which.
|
|
9
9
|
- A dropped socket reconnects with backoff (1 s doubling to 60 s) at whatever address discovery last gave. The drop starts one shared `resolve()` at once (a group's address changes with its leader); while anything stays down or missing, that one discovery repeats at 1, 5 and 15 minutes, then hourly, as the relocator does, never once per socket. `refresh()` re-asks every open socket and nudges the discovery, which also finds devices when nothing was found at start.
|
|
10
10
|
- The part is `connecting` until every device has answered or failed, then `connected` while any is up; `detail` names the ones down. A payload of a known type the schema rejects is an `anomaly`, never thrown.
|
|
@@ -26,7 +26,8 @@ CastV2 is TLS to port 8009 (a group: its own port), carrying length-prefixed pro
|
|
|
26
26
|
- **Groups.** A group is its own mDNS entry, at its leader's address on a high port, and presents **its leader's certificate**. Multizone status on the group's port lists the members (by `deviceId`, the same dashed id) with their volumes; on a device's own port it lists only that device, so membership is read from group sockets only. TXT `rm` does not mark membership. `GET_CASTING_GROUPS` is refused.
|
|
27
27
|
- **Receiver status**: hubs and the Chromecast Ultra run Backdrop (`E8C28D3C`) with `isIdleScreen: true`; the speakers report no application; the TV Streamer reports `isStandBy: true`, `isActiveInput: false` and a `fixed` volume; the Ultra's volume is `attenuation`.
|
|
28
28
|
- No device sent a `PING` in 12 s of listening, so our 5 s ping is what keeps the socket's silence check honest.
|
|
29
|
-
-
|
|
29
|
+
- Media status first seen live 2026-11-09 (Bun 1.4.4): a paused title and artist on a TV.
|
|
30
|
+
- Not seen yet: a group playing, a leader change, member changes, and Node's `tls` (no Node caller yet).
|
|
30
31
|
|
|
31
32
|
## TLS: verification off, the name checked
|
|
32
33
|
|
|
@@ -401,7 +401,8 @@ export function cast(
|
|
|
401
401
|
// A cached address that fails is only stale: discover now and try where it answers, still "connecting".
|
|
402
402
|
cached = false;
|
|
403
403
|
await discover();
|
|
404
|
-
|
|
404
|
+
// Unless discovery can't find it: then the address is the one that just failed, and a second try only waits out the timeout. The backoff still retries.
|
|
405
|
+
if (!lastFound || lastFound.has(id)) continue;
|
|
405
406
|
}
|
|
406
407
|
const why = errorText(error);
|
|
407
408
|
// A cached address the last discovery did not find says so, as a polling connector's does.
|
|
@@ -15,10 +15,20 @@ const ipv4 = (a: string) =>
|
|
|
15
15
|
const network = (address: string, netmask: string) =>
|
|
16
16
|
ipv4(address) - (ipv4(address) % (2 ** 32 - ipv4(netmask)));
|
|
17
17
|
|
|
18
|
+
type NetworkAddress = {
|
|
19
|
+
family: string;
|
|
20
|
+
address: string;
|
|
21
|
+
netmask: string;
|
|
22
|
+
internal: boolean;
|
|
23
|
+
};
|
|
24
|
+
|
|
18
25
|
/** This machine's IPv4 on the subnet that holds `host`, or with no host, its first non-internal IPv4. */
|
|
19
26
|
export function lanAddress(
|
|
20
27
|
host?: string,
|
|
21
|
-
interfaces
|
|
28
|
+
interfaces: Record<
|
|
29
|
+
string,
|
|
30
|
+
NetworkAddress[] | undefined
|
|
31
|
+
> = networkInterfaces(),
|
|
22
32
|
): string {
|
|
23
33
|
const all = Object.values(interfaces)
|
|
24
34
|
.flatMap((list) => list ?? [])
|
|
@@ -4,7 +4,7 @@ Reads one Daikin AC over its local JSON `dsiot` API. `iot_class`: **local_pollin
|
|
|
4
4
|
|
|
5
5
|
- `client.ts`: `daikinClient({ host, fetch }).read()` returns `DaikinState` (power, mode, the selected mode's setpoint and fan speed, room °C, humidity, outside °C, `anomaly`). `setPower`, `setMode`, `setTarget`, `setFan` build write requests; `client.write(request)` sends one, through a write lane: 1 s apart and at most 20 a minute (guesses), a waiting write merging the next one field by field (`mergeWrites`). Every request times out at 10 s.
|
|
6
6
|
- `index.ts`: `daikin({ host, fetch, clock })` reads now, then every **120 s** (Home Assistant's `update_interval`). A failed poll is `disconnected` and keeps the last reading with `asOf`; a 404 on `/dsiot/multireq` is a failed read too, saying "not a dsiot Daikin": the address may now be another device's, so discovery looks for the unit. There is no key, so nothing is terminal. Writes go through `daikin().client.write(…)`: one client for the integration's life, at the unit's current address.
|
|
7
|
-
- `home status` reads `DAIKIN_HOST`,
|
|
7
|
+
- `home status` reads `DAIKIN_HOST`, or a home config's address, with discovery by `mac` if the unit moves. There is no CLI command that writes.
|
|
8
8
|
|
|
9
9
|
## The protocol
|
|
10
10
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Spotify (Soloist)
|
|
2
2
|
|
|
3
|
-
What a [Spotify Soloist](https://developer.spotify.com/documentation/soloist) speaker is playing (start with its [basic integration](https://developer.spotify.com/documentation/soloist/howtos/basic-integration)), and control over it, over Soloist's local WebSocket. `iot_class`: **local_push**.
|
|
3
|
+
What a [Spotify Soloist](https://developer.spotify.com/documentation/soloist) speaker is playing (start with its [basic integration](https://developer.spotify.com/documentation/soloist/howtos/basic-integration)), and control over it, over Soloist's local WebSocket. `iot_class`: **local_push**. Reads seen live on a real Soloist (2026-11-09); writes tested against a fake socket only.
|
|
4
4
|
|
|
5
5
|
Soloist is Spotify's own headless Spotify Connect client (Linux arm64, armv7, x86_64, Apple Silicon, Windows x86_64). The machine running it is a speaker in every Spotify app on the network; this connector is how a program on the same machine sees and steers it. It doesn't search Spotify or read a library: Soloist plays URIs and reports its own session. Search belongs to the consumer (KO, 2026-11-08; `docs/ideas.md`).
|
|
6
6
|
|
|
@@ -22,7 +22,7 @@ Soloist is Spotify's own headless Spotify Connect client (Linux arm64, armv7, x8
|
|
|
22
22
|
- `setRepeat` is two commands in the docs' order (clear the other mode, then set this one); if the second is refused, repeat may be left off.
|
|
23
23
|
- A failed write the library made itself stays in `detail` until a write works or the socket reconnects.
|
|
24
24
|
|
|
25
|
-
**`maxVolume`** holds requests (rounded to a whole percent). Within 10 s of idle
|
|
25
|
+
**`maxVolume`** holds requests (rounded to a whole percent). Within 10 s of a start from idle (to playing or buffering, or the first such status after a sign-in; a start through buffering opens the window again at `playing`), a reported volume above it is lowered once and `anomaly` says so until the speaker goes idle: [#23](https://github.com/spotify/soloist/issues/23), the first play on PipeWire starts at 100%. `--initial-volume` is ignored there. Not on resuming from pause, not on connecting to a song already playing, and a person turning it up again keeps it.
|
|
26
26
|
|
|
27
27
|
**`duck(fn, { to })` and `interrupt(fn)`** turn the music down, or pause it, around `fn`, then put it back. Both resolve `{ value, music, why? }`, `music` one of:
|
|
28
28
|
|
|
@@ -33,7 +33,7 @@ Soloist is Spotify's own headless Spotify Connect client (Linux arm64, armv7, x8
|
|
|
33
33
|
|
|
34
34
|
The restore runs even if `fn` throws: the error is `fn`'s, and a failed restore goes to `detail`. `duck` restores the earlier volume even above `maxVolume`. One aside at a time: never call one inside another's `fn` (it waits forever), and put two announcements in one `fn`. With Cast: `speaker.duck(() => cast.announce("Kitchen", clip), { to: 20 })`. Cast's own `announce` restores what a Cast device was playing; these are for Soloist's output.
|
|
35
35
|
|
|
36
|
-
**Reading it.** `speakerState(part)`: `offline`, `connecting`, `waiting`, then Soloist's status (`idle`, `playing`, `paused`, `buffering`, or its own string); signed in with no status yet is `idle`. `spotifyChanges(before, after)`, for `onChange`, gives events in this order: `offline`, `online`, `waiting`, `picked`, `started` (from idle), `resumed` (from paused), `paused`, `stopped`, `track`, `stalled`, `recovered`. Buffering counts as playing. Nothing on the first report, so a restart announces nothing; no volume or queue events. Playback events during an aside carry `own: true`; connection, sign-in and stall events never do.
|
|
36
|
+
**Reading it.** `speakerState` takes the Part; `spotifyText` and `positionMs` take its `state`. `speakerState(part)`: `offline`, `connecting`, `waiting`, then Soloist's status (`idle`, `playing`, `paused`, `buffering`, or its own string); signed in with no status yet is `idle`. `spotifyChanges(before, after)`, for `onChange`, gives events in this order: `offline`, `online`, `waiting`, `picked`, `started` (from idle), `resumed` (from paused), `paused`, `stopped`, `track`, `stalled`, `recovered`. Buffering counts as playing. Nothing on the first report, so a restart announces nothing; no volume or queue events. Playback events during an aside carry `own: true`; connection, sign-in and stall events never do.
|
|
37
37
|
|
|
38
38
|
**Stalls** ([#5](https://github.com/spotify/soloist/issues/5): playback stops mid-track, often the first after idle, while Soloist still says `playing`). While playing, one `get_state` every 20 s (`stallCheckMs`, code only; 0 turns it off).
|
|
39
39
|
|
|
@@ -56,14 +56,17 @@ Not built: `activate`/`deactivate`, `available_actions`. An ad (`entity_type: "a
|
|
|
56
56
|
|
|
57
57
|
## Unverified
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
Writes through this connector, and what a live read hasn't shown. In particular:
|
|
60
60
|
|
|
61
61
|
- **Stalls:** whether the reported position freezes in one (if not, only an overrun is caught), whether `get_state` re-anchors the position, and whether a seek or a pause and play restarts it.
|
|
62
62
|
- **Writes:** that each confirming event arrives within 2 s; which events a `play` of an album or playlist sends; `play` of a new URI with no phone around; seek on playback a phone started.
|
|
63
|
+
- **The cap:** the order of statuses at a start. Soloist's docs list `buffering` but not when it comes; #23 reports the 100% about 100 ms after `playing`, on the first play after the process starts.
|
|
63
64
|
- **The rest:** `get_queue` answered by `queue_changed`; `play` with nothing loaded; whole-number volumes; the order of `auth_state` and `playback_state`; the 100% first play and its lowering; Family-plan members picking the speaker; whether the session survives a Soloist restart; a USB speaker's standby dropping it mid-play.
|
|
64
65
|
|
|
65
66
|
## Live findings
|
|
66
67
|
|
|
68
|
+
**2026-11-09, Raspberry Pi 5, Bun 1.4.4, through this connector** (chota-bot's session, reads only). `home spotify` took 140 ms. In code: connected at 11 ms, second report at 55 ms, 51 MB RSS, a clean exit after abort. Track, volume and `paused` read correctly, with no anomaly. `spotifyChanges` gave nothing on the first report.
|
|
69
|
+
|
|
67
70
|
**2026-11-08, Raspberry Pi 5, Pi OS Lite Trixie arm64, via `soloist ctl`** (chota-bot's session). `soloist 1.3.10.51 build 1793988108 (20261106)` as a systemd user unit with `-w 127.0.0.1:9091`. It logs the days left on the build (88), the WebSocket listening, then "waiting for login". Builds expire 90 days after they're made (exit code 10); chota-bot's units refresh weekly.
|
|
68
71
|
|
|
69
72
|
- Not logged in until a phone picked the speaker: the normal waiting state. After: logged in, active device.
|
|
@@ -199,6 +199,7 @@ export function spotify(
|
|
|
199
199
|
let signedIn = false; // signed out → in on this socket: the first status after it counts as a start if it is playing
|
|
200
200
|
let capUntil = -Infinity;
|
|
201
201
|
let capped = false; // the cap has been sent in this window
|
|
202
|
+
let startBuffering = false; // a start from idle is still buffering
|
|
202
203
|
let capNote: string | undefined; // the last first-play lowering, in `anomaly` until the speaker goes idle
|
|
203
204
|
let frameNote: string | undefined; // a frame the schema rejected, or an error nothing was waiting for
|
|
204
205
|
let note: string | undefined; // a write the integration made itself that failed (the cap, interrupt's resume), in `detail`
|
|
@@ -352,7 +353,7 @@ export function spotify(
|
|
|
352
353
|
publish();
|
|
353
354
|
});
|
|
354
355
|
|
|
355
|
-
/** Within the window after
|
|
356
|
+
/** Within the window after a start from idle, a volume above the cap is lowered to it, once (issue #23). */
|
|
356
357
|
const applyCap = () => {
|
|
357
358
|
if (
|
|
358
359
|
cap === undefined ||
|
|
@@ -429,10 +430,14 @@ export function spotify(
|
|
|
429
430
|
(f.type === "playback_state" || f.type === "playback_changed") &&
|
|
430
431
|
f.status !== undefined
|
|
431
432
|
) {
|
|
432
|
-
// A start is idle → playing, or a sign-in whose first status is
|
|
433
|
+
// A start is idle → playing or buffering, or a sign-in whose first status is either: a phone picked the speaker and played (issue #23).
|
|
434
|
+
// Soloist's jump to 100 comes about 100 ms after `playing` (issue #23), so a start through buffering opens the window again then.
|
|
433
435
|
const started =
|
|
434
|
-
f.status === "playing"
|
|
436
|
+
((f.status === "playing" || f.status === "buffering") &&
|
|
437
|
+
(statusKnown ? before === "idle" : signedIn)) ||
|
|
438
|
+
(f.status === "playing" && startBuffering);
|
|
435
439
|
if (started) ((capUntil = clock.now() + CAP_WINDOW_MS), (capped = false));
|
|
440
|
+
startBuffering = f.status === "buffering" && (started || startBuffering);
|
|
436
441
|
signedIn = false;
|
|
437
442
|
if (f.status === "idle") capNote = undefined;
|
|
438
443
|
statusKnown = true;
|
|
@@ -472,6 +477,7 @@ export function spotify(
|
|
|
472
477
|
queueAsked = false;
|
|
473
478
|
statusKnown = false;
|
|
474
479
|
signedIn = false;
|
|
480
|
+
startBuffering = false;
|
|
475
481
|
pending?.finish(offline());
|
|
476
482
|
for (const w of [...waiters]) w(); // a write waiting to confirm stops waiting
|
|
477
483
|
seen = undefined;
|
|
@@ -479,7 +485,9 @@ export function spotify(
|
|
|
479
485
|
};
|
|
480
486
|
const stop = () => (end("stopped"), s.close());
|
|
481
487
|
signal.addEventListener("abort", stop, { once: true });
|
|
488
|
+
// An ended socket's late events belong to no session: they must not touch the next socket's state.
|
|
482
489
|
s.onopen = () => {
|
|
490
|
+
if (ended) return;
|
|
483
491
|
open = true;
|
|
484
492
|
down = undefined;
|
|
485
493
|
note = undefined; // a new socket: what failed on the last one is history
|
|
@@ -493,7 +501,7 @@ export function spotify(
|
|
|
493
501
|
if (!whole) ((whole = true), publish()); // signed in, no playback_state: connected with what there is
|
|
494
502
|
});
|
|
495
503
|
};
|
|
496
|
-
s.onmessage = (ev) => handle(ev.data);
|
|
504
|
+
s.onmessage = (ev) => void (ended || handle(ev.data));
|
|
497
505
|
s.onerror = () => {}; // onclose follows and says it
|
|
498
506
|
s.onclose = () => end("Soloist closed the socket");
|
|
499
507
|
});
|
|
@@ -68,7 +68,7 @@ Plain `fetch` cannot read `ftp://` (Bun 1.4.2: "protocol must be http:, https: o
|
|
|
68
68
|
|
|
69
69
|
**Sun** (`sun.ts`). `sunNow` with [suncalc](https://github.com/mourner/suncalc) 2.1.1, which bundles its own types (degrees, azimuth clockwise from north; `@types/suncalc` describes v1 and is wrong for it). Times are for the local solar day containing `at`. On 2026-10-13 it matched BOM's own Sydney sunrise and sunset to the second. Dawn and dusk are civil (6° below the horizon), `nightEnd` and `night` astronomical (18°), `goldenHour` starts the evening's and `goldenHourEnd` ends the morning's (6° above). `rising` is before solar noon, which in suncalc's solar day means from solar midnight; `daylight` is altitude above 0°. **`sunNow(latitude, longitude, now)` is the owner**: the snapshot's `sun` is recomputed every 5 minutes, so a consumer showing `rising`, `daylight`, `next` or the position calls `sunNow` with its own clock, as `home status` does. `next` holds each of dawn, sunrise, sunset and dusk at its next occurrence after `at` (today's while still ahead, else tomorrow's), missing on a day that has none. The computed sun is the owner: Google's daily `sunEvents` are parsed only to check it, and a sunrise or sunset more than 2 minutes from the computed one (`sunDisagrees`, comparing within the solar day containing Google's time) goes into Google's `anomaly`, never into the sun.
|
|
70
70
|
|
|
71
|
-
Every request has a 10 s timeout and the user agent `home (+https://github.com/khalido/home)`: BOM's app API gives Bun's default `Bun/1.4.2` no answer at all, so nothing here relies on the default.
|
|
71
|
+
Every request has a 10 s timeout and the user agent `home (+https://github.com/khalido/home.ts)`: BOM's app API gives Bun's default `Bun/1.4.2` no answer at all, so nothing here relies on the default.
|
|
72
72
|
|
|
73
73
|
## Not built, on purpose
|
|
74
74
|
|
|
@@ -246,19 +246,35 @@ export function weather(
|
|
|
246
246
|
/** "20:20" in the home's time zone (the runtime's when unset), or "?". */
|
|
247
247
|
const hhmmIn = (iso: string | undefined, timeZone?: string) =>
|
|
248
248
|
iso ? localTime(Date.parse(iso), timeZone) : "?";
|
|
249
|
-
const day = (d?: ForecastDay) =>
|
|
249
|
+
const day = (d?: ForecastDay, rainLeft?: number) =>
|
|
250
250
|
d
|
|
251
251
|
? [
|
|
252
252
|
d.minC === undefined && d.maxC === undefined
|
|
253
253
|
? ""
|
|
254
254
|
: `${d.minC ?? "?"}–${d.maxC ?? "?"} °C`,
|
|
255
255
|
d.text?.replace(/\.$/, "").toLowerCase() ?? "",
|
|
256
|
-
d.rainChance === undefined
|
|
256
|
+
d.rainChance === undefined
|
|
257
|
+
? ""
|
|
258
|
+
: rainLeft !== undefined && rainLeft < d.rainChance
|
|
259
|
+
? `${rainLeft}% chance of rain the rest of the day`
|
|
260
|
+
: `${d.rainChance}% chance of rain`,
|
|
257
261
|
]
|
|
258
262
|
.filter(Boolean)
|
|
259
263
|
.join(" ")
|
|
260
264
|
: "?";
|
|
261
265
|
|
|
266
|
+
/** The highest hourly chance left today, all 24 hours of it, when the hourly forecast is the same source and day as the forecast: sources are never mixed. */
|
|
267
|
+
const rainLeftToday = (
|
|
268
|
+
w: Pick<Slices, "forecast" | "hourly"> & { timeZone?: string },
|
|
269
|
+
at: number,
|
|
270
|
+
) => {
|
|
271
|
+
if (!w.hourly || w.hourly.source !== w.forecast?.source) return undefined;
|
|
272
|
+
const rest = dayBlocks(w.hourly, at, w.timeZone, [
|
|
273
|
+
{ name: "rest", from: 0, to: 24 },
|
|
274
|
+
])?.today[0];
|
|
275
|
+
return rest?.date === w.forecast.today?.date ? rest?.rainChance : undefined;
|
|
276
|
+
};
|
|
277
|
+
|
|
262
278
|
/** "rain in 30 min, ends in 55 min, 70%, 0.8 mm, moderate (google)", "raining, ends in 25 min", or "no rain for 2 h"; nothing when the forecast covers none of the window. */
|
|
263
279
|
export function rainText(
|
|
264
280
|
rain: Slices["rain"],
|
|
@@ -310,7 +326,7 @@ export function weatherText(
|
|
|
310
326
|
: "";
|
|
311
327
|
const f = w.forecast;
|
|
312
328
|
const forecast = f
|
|
313
|
-
? `today ${day(f.today)}, tomorrow ${day(f.tomorrow)} (${f.source}${f.modelled ? " modelled" : ""})`
|
|
329
|
+
? `today ${day(f.today, rainLeftToday(w, Date.parse(sun.at)))}, tomorrow ${day(f.tomorrow)} (${f.source}${f.modelled ? " modelled" : ""})`
|
|
314
330
|
: "";
|
|
315
331
|
const k = w.sky;
|
|
316
332
|
const skyParts = k
|
package/src/discover.ts
CHANGED
|
@@ -374,7 +374,8 @@ export function addressBook(
|
|
|
374
374
|
/**
|
|
375
375
|
* Run one discovery `passes` times, `gapMs` apart, and return the union (first answer wins). `onPass` sees each pass's
|
|
376
376
|
* additions as they come; `done` ends it early once what was looked for has answered. A pass that throws adds nothing;
|
|
377
|
-
* when every pass threw, discovery never ran, so it throws the last error rather than reporting nothing found.
|
|
377
|
+
* when every pass threw, discovery never ran, so it throws the last error rather than reporting nothing found. An error
|
|
378
|
+
* `stopOn` accepts is thrown at once: no later pass could do better.
|
|
378
379
|
*/
|
|
379
380
|
export async function discover<T = Found>(
|
|
380
381
|
pass: () => Promise<T[]>,
|
|
@@ -384,6 +385,7 @@ export async function discover<T = Found>(
|
|
|
384
385
|
gapMs?: number;
|
|
385
386
|
key?: (t: T) => string;
|
|
386
387
|
done?: (all: T[]) => boolean;
|
|
388
|
+
stopOn?: (error: unknown) => boolean;
|
|
387
389
|
onPass?: (n: number, added: T[]) => void;
|
|
388
390
|
signal?: AbortSignal;
|
|
389
391
|
},
|
|
@@ -401,9 +403,11 @@ export async function discover<T = Found>(
|
|
|
401
403
|
);
|
|
402
404
|
if (options.signal?.aborted) break;
|
|
403
405
|
ran++;
|
|
404
|
-
const answers = await pass().catch(
|
|
405
|
-
|
|
406
|
-
|
|
406
|
+
const answers = await pass().catch((error) => {
|
|
407
|
+
if (options.stopOn?.(error)) throw error;
|
|
408
|
+
errors.push(error);
|
|
409
|
+
return [] as T[];
|
|
410
|
+
});
|
|
407
411
|
const added = answers.filter((t) => !all.has(key(t)) && all.set(key(t), t));
|
|
408
412
|
options.onPass?.(n, added);
|
|
409
413
|
if (options.done?.([...all.values()])) break;
|
package/src/energy.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AmberState, PriceInterval } from "./connectors/amber/client.ts";
|
|
2
2
|
import type { EnphaseState } from "./connectors/enphase/client.ts";
|
|
3
3
|
import type { EnphaseSnapshot } from "./connectors/enphase/index.ts";
|
|
4
|
+
import { kw } from "./manifest.ts";
|
|
4
5
|
|
|
5
6
|
/** The home's energy right now: power from Enphase, prices from Amber, and what the grid flow costs per hour. */
|
|
6
7
|
export type EnergyNow = {
|
|
@@ -112,7 +113,6 @@ export function solarLevel(
|
|
|
112
113
|
* "balanced (-10 W)", or "" with no grid reading. Rounded for reading only; the numbers above stay as read.
|
|
113
114
|
*/
|
|
114
115
|
export function gridText(now: EnergyNow): string {
|
|
115
|
-
const kw = (w: number) => `${(w / 1000).toFixed(1)} kW`;
|
|
116
116
|
const rate = (c: number | undefined, positive: string, negative: string) =>
|
|
117
117
|
c === undefined
|
|
118
118
|
? ""
|
package/src/finders.ts
CHANGED
|
@@ -31,6 +31,9 @@ import {
|
|
|
31
31
|
// does not slow a scan. Without the tool the mDNS finders throw, naming it, rather than "nothing answered".
|
|
32
32
|
// `fileCache` keeps the addresses discovery found in a file between runs, so the next run connects straight away.
|
|
33
33
|
|
|
34
|
+
/** The system's mDNS tool is not installed: discovery can't run until it is. */
|
|
35
|
+
export class MissingTool extends Error {}
|
|
36
|
+
|
|
34
37
|
/**
|
|
35
38
|
* Run a long-lived `dns-sd` query (or `avahi-browse`, by `command`) and return what it printed. It never exits by itself, so it is stopped once `done`
|
|
36
39
|
* says the answer is in, or `quietMs` after the last output, or at `maxMs`. The system's mDNS cache usually answers in
|
|
@@ -68,11 +71,11 @@ export function dnsSd(
|
|
|
68
71
|
proc.on("error", (error: NodeJS.ErrnoException) => {
|
|
69
72
|
(clearTimeout(timer), clearTimeout(quiet));
|
|
70
73
|
reject(
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
74
|
+
error.code === "ENOENT"
|
|
75
|
+
? new MissingTool(
|
|
76
|
+
`${command} not found; mDNS discovery needs it${install ? ` (${install})` : ""}`,
|
|
77
|
+
)
|
|
78
|
+
: new Error(`${command}: ${error.message}`),
|
|
76
79
|
);
|
|
77
80
|
});
|
|
78
81
|
proc.on("close", finish);
|