@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 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.0...HEAD
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
- continue;
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?: NodeJS.Dict<import("node:os").NetworkInterfaceInfo[]>): string;
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 = 10_000, }) => new Promise((resolve, reject) => {
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 idle → playing, a volume above the cap is lowered to it, once (issue #23). */
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 playing: a phone picked the speaker and played (issue #23).
278
- const started = f.status === "playing" && (statusKnown ? before === "idle" : signedIn);
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 ? "" : `${d.rainChance}% chance of rain`,
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
@@ -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) => (errors.push(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(new Error(error.code === "ENOENT"
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 the caller's: the CLI uses Bun's built-in `Bun.JSONC.parse` (comments and trailing commas, no dependency); `jsonc-parser` (already a dependency, for `home discover --write`) parses it anywhere else. `bun run schema` rewrites `home.schema.json` from the zod schema, and a test fails if they drift.
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 has this bug today (`hhmm` in `weather/index.ts`). Calendar must not copy it.
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.0",
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": "./src/cli/main.ts"
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. Local clones live in `~/code/refs/`.
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; local `~/code/refs/iso-glow/iso-glow/`) | 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); local `~/code/refs/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) (local `~/code/refs/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` (local `~/code/refs/ai-iso-skill/`) | Flat-faced isometric figures on a box, ~10 KB pages | The lightest isometric option | MIT |
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, `~/code/refs/anatomy/`, [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.
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** (from sfl-mes's live contract): 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.
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 and sfl-mes's "spin"): 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` (`~/code/refs/anatomy/kit/iso-kit.mjs`). 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.
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
 
@@ -14,7 +14,13 @@ import {
14
14
  KNOWN_SERVICES,
15
15
  matchFound,
16
16
  } from "../discover.ts";
17
- import { browse, daikinFound, FINDERS, findDaikins } from "../finders.ts";
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
- - Not seen yet: media status (nothing was playing), a group playing, a leader change, member changes, and Node's `tls` (no Node caller yet).
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
- continue;
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 = networkInterfaces(),
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 ?? [])
@@ -18,7 +18,7 @@ export const tlsConnect: CastConnect = ({
18
18
  host,
19
19
  port,
20
20
  signal,
21
- timeoutMs = 10_000,
21
+ timeoutMs = 3000,
22
22
  }) =>
23
23
  new Promise<CastChannel>((resolve, reject) => {
24
24
  const socket = connect({ host, port, rejectUnauthorized: false });
@@ -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`, else the first unit that answers the UDP broadcast. There is no CLI command that writes.
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**. Tested against a fake socket only: no real Soloist has answered this connector yet.
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 → 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.
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
- Everything through this connector; only `soloist ctl` has talked to a real Soloist. In particular:
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 idle → playing, a volume above the cap is lowered to it, once (issue #23). */
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 playing: a phone picked the speaker and played (issue #23).
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" && (statusKnown ? before === "idle" : signedIn);
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 ? "" : `${d.rainChance}% chance of rain`,
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
- (error) => (errors.push(error), [] as T[]),
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
- new Error(
72
- error.code === "ENOENT"
73
- ? `${command} not found; mDNS discovery needs it${install ? ` (${install})` : ""}`
74
- : `${command}: ${error.message}`,
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);