@khalido/home 0.1.3 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/.env.example +2 -2
  2. package/CHANGELOG.md +56 -1
  3. package/README.md +7 -2
  4. package/dist/config.d.ts +5 -3
  5. package/dist/config.js +5 -1
  6. package/dist/connector.d.ts +2 -0
  7. package/dist/connector.js +3 -0
  8. package/dist/connectors/amber/client.d.ts +1 -1
  9. package/dist/connectors/amber/client.js +1 -1
  10. package/dist/connectors/amber/index.js +2 -2
  11. package/dist/connectors/cast/index.d.ts +1 -1
  12. package/dist/connectors/cast/index.js +1 -1
  13. package/dist/connectors/cast/state.d.ts +17 -3
  14. package/dist/connectors/cast/state.js +26 -7
  15. package/dist/connectors/daikin/client.d.ts +45 -2
  16. package/dist/connectors/daikin/client.js +67 -2
  17. package/dist/connectors/daikin/index.d.ts +2 -2
  18. package/dist/connectors/daikin/index.js +4 -3
  19. package/dist/connectors/enphase/client.d.ts +1 -1
  20. package/dist/connectors/enphase/client.js +1 -1
  21. package/dist/connectors/enphase/index.js +2 -1
  22. package/dist/connectors/hue/ca.d.ts +2 -0
  23. package/dist/connectors/hue/ca.js +38 -0
  24. package/dist/connectors/hue/client.d.ts +81 -6
  25. package/dist/connectors/hue/client.js +217 -6
  26. package/dist/connectors/hue/color.d.ts +30 -0
  27. package/dist/connectors/hue/color.js +92 -0
  28. package/dist/connectors/hue/events.d.ts +0 -2
  29. package/dist/connectors/hue/events.js +1 -2
  30. package/dist/connectors/hue/index.d.ts +15 -4
  31. package/dist/connectors/hue/index.js +27 -10
  32. package/dist/connectors/hue/schemas.d.ts +27 -0
  33. package/dist/connectors/hue/schemas.js +26 -0
  34. package/dist/connectors/hue/state.d.ts +10 -1
  35. package/dist/connectors/hue/state.js +20 -1
  36. package/dist/connectors/printers/client.d.ts +41 -6
  37. package/dist/connectors/printers/client.js +81 -8
  38. package/dist/connectors/printers/codec.d.ts +17 -3
  39. package/dist/connectors/printers/codec.js +32 -8
  40. package/dist/connectors/printers/index.d.ts +2 -0
  41. package/dist/connectors/printers/index.js +4 -0
  42. package/dist/connectors/printers/raster.d.ts +30 -0
  43. package/dist/connectors/printers/raster.js +175 -0
  44. package/dist/connectors/printers/state.d.ts +2 -0
  45. package/dist/connectors/spotify/changes.d.ts +19 -5
  46. package/dist/connectors/spotify/changes.js +29 -7
  47. package/dist/connectors/spotify/index.d.ts +1 -1
  48. package/dist/connectors/spotify/index.js +20 -3
  49. package/dist/connectors/up/client.d.ts +1 -1
  50. package/dist/connectors/up/client.js +1 -1
  51. package/dist/connectors/up/index.js +2 -2
  52. package/dist/connectors/weather/google.js +2 -2
  53. package/dist/devices.d.ts +24 -0
  54. package/dist/devices.js +56 -2
  55. package/dist/discover.js +1 -1
  56. package/dist/finders-entry.d.ts +1 -1
  57. package/dist/finders-entry.js +1 -1
  58. package/dist/finders.d.ts +2 -1
  59. package/dist/finders.js +16 -5
  60. package/dist/home.d.ts +11 -2
  61. package/dist/home.js +98 -34
  62. package/dist/index.d.ts +1 -1
  63. package/dist/index.js +1 -1
  64. package/dist/node-fetch.d.ts +2 -2
  65. package/dist/node-fetch.js +3 -2
  66. package/docs/cli.md +15 -7
  67. package/docs/config.md +11 -3
  68. package/docs/decisions.md +19 -0
  69. package/docs/hue.md +161 -0
  70. package/docs/ideas.md +2 -0
  71. package/docs/using.md +98 -3
  72. package/examples/home.jsonc +2 -1
  73. package/home.schema.json +10 -3
  74. package/llms.txt +19 -6
  75. package/package.json +1 -1
  76. package/skills/draw-home/README.md +2 -0
  77. package/skills/draw-home/SKILL.md +1 -1
  78. package/skills/use-home/SKILL.md +3 -3
  79. package/src/cli/cast.ts +51 -5
  80. package/src/cli/context.ts +2 -0
  81. package/src/cli/daikin.ts +71 -0
  82. package/src/cli/enphase.ts +31 -5
  83. package/src/cli/hue.ts +147 -59
  84. package/src/cli/main.ts +43 -13
  85. package/src/cli/printers.ts +254 -3
  86. package/src/cli/probe.ts +21 -17
  87. package/src/cli/status.ts +61 -10
  88. package/src/config.ts +8 -2
  89. package/src/connector.ts +5 -0
  90. package/src/connectors/amber/README.md +1 -1
  91. package/src/connectors/amber/client.ts +1 -1
  92. package/src/connectors/amber/index.ts +2 -2
  93. package/src/connectors/cast/README.md +6 -3
  94. package/src/connectors/cast/index.ts +7 -1
  95. package/src/connectors/cast/state.ts +32 -5
  96. package/src/connectors/daikin/README.md +16 -4
  97. package/src/connectors/daikin/client.ts +104 -3
  98. package/src/connectors/daikin/index.ts +8 -2
  99. package/src/connectors/enphase/README.md +3 -1
  100. package/src/connectors/enphase/client.ts +1 -1
  101. package/src/connectors/enphase/index.ts +2 -1
  102. package/src/connectors/hue/README.md +62 -17
  103. package/src/connectors/hue/ca.ts +39 -0
  104. package/src/connectors/hue/client.ts +362 -15
  105. package/src/connectors/hue/color.ts +123 -0
  106. package/src/connectors/hue/events.ts +1 -4
  107. package/src/connectors/hue/index.ts +46 -13
  108. package/src/connectors/hue/schemas.ts +26 -0
  109. package/src/connectors/hue/state.ts +27 -1
  110. package/src/connectors/printers/README.md +9 -4
  111. package/src/connectors/printers/client.ts +186 -12
  112. package/src/connectors/printers/codec.ts +44 -10
  113. package/src/connectors/printers/index.ts +17 -0
  114. package/src/connectors/printers/raster.ts +215 -0
  115. package/src/connectors/printers/state.ts +2 -0
  116. package/src/connectors/spotify/README.md +10 -2
  117. package/src/connectors/spotify/changes.ts +38 -9
  118. package/src/connectors/spotify/index.ts +21 -2
  119. package/src/connectors/up/README.md +1 -1
  120. package/src/connectors/up/client.ts +1 -1
  121. package/src/connectors/up/index.ts +2 -2
  122. package/src/connectors/weather/google.ts +6 -2
  123. package/src/devices.ts +83 -3
  124. package/src/discover.ts +1 -2
  125. package/src/finders-entry.ts +1 -1
  126. package/src/finders.ts +18 -6
  127. package/src/home.ts +99 -39
  128. package/src/index.ts +7 -1
  129. package/src/node-fetch.ts +6 -2
package/src/cli/probe.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  printerList,
7
7
  } from "../connectors/printers/index.ts";
8
8
  import { gatewayHost } from "../connectors/enphase/client.ts";
9
+ import { fetchTimeout } from "../connector.ts";
9
10
  import { bunPinnedFetch } from "../connectors/hue/index.ts";
10
11
  import { type WeatherSource } from "../connectors/weather/index.ts";
11
12
  import {
@@ -39,7 +40,7 @@ function envFromConfig(path: string): Record<string, string> {
39
40
  Object.assign(out, {
40
41
  HUE_BRIDGE_HOST: d.host,
41
42
  HUE_BRIDGE_ID: d.bridgeId,
42
- HUE_CA_FILE: fromConfig(path, d.ca),
43
+ HUE_CA_FILE: d.ca && fromConfig(path, d.ca),
43
44
  HUE_APPLICATION_KEY: env[d.key],
44
45
  });
45
46
  if (d.connector === "daikin") out.DAIKIN_HOST = d.host;
@@ -111,25 +112,28 @@ const jsonOr = (body: string) => {
111
112
 
112
113
  export async function probe(o: Options): Promise<void> {
113
114
  const configPath = configPathOf(o);
114
- if (configPath) Object.assign(env, envFromConfig(configPath)); // the config's addresses, for this one command
115
+ if (configPath) {
116
+ delete env.HUE_CA_FILE; // the config's `ca` or the package's roots, never the shell's file
117
+ Object.assign(env, envFromConfig(configPath)); // the config's addresses, for this one command
118
+ }
115
119
  const out: Record<string, unknown> = {};
116
120
  try {
117
121
  const b = await bridge();
118
- const fetchPinned = env.HUE_CA_FILE
119
- ? bunPinnedFetch(b.id, ca())
120
- : undefined;
121
- const r = fetchPinned
122
- ? await fetchPinned(`https://${b.host}/api/0/config`).then((x) =>
123
- x.json(),
124
- )
125
- : "needs HUE_CA_FILE";
126
- out.hue =
127
- typeof r === "string"
128
- ? { status: r }
129
- : {
130
- status: "answers",
131
- ...pick(r, ["name", "modelid", "apiversion", "swversion"]),
132
- };
122
+ const r = await fetchTimeout(
123
+ bunPinnedFetch(b.id, ca()),
124
+ `https://${b.host}/api/0/config`,
125
+ {},
126
+ { timeoutMs: 5000 },
127
+ ).then((x) => x.json());
128
+ out.hue = {
129
+ status: "answers",
130
+ ...pick(r as Record<string, unknown>, [
131
+ "name",
132
+ "modelid",
133
+ "apiversion",
134
+ "swversion",
135
+ ]),
136
+ };
133
137
  } catch (error) {
134
138
  out.hue = { status: "unavailable", detail: (error as Error).message };
135
139
  }
package/src/cli/status.ts CHANGED
@@ -12,13 +12,15 @@ import {
12
12
  type WeatherSource,
13
13
  } from "../connectors/weather/index.ts";
14
14
  import { ftpFetch } from "../connectors/weather/ftp.ts";
15
- import { loadHome } from "../config.ts";
15
+ import { HomeConfig, loadHome } from "../config.ts";
16
+ import { DEVICES, type HomeDevice, homeDevices } from "../devices.ts";
16
17
  import { energyNow, gridText } from "../energy.ts";
17
18
  import { FINDERS } from "../finders.ts";
18
19
  import { type Integration, type Part, type Status, settle } from "../home.ts";
19
20
  import { kw, type Manifest } from "../manifest.ts";
20
21
  import { MANIFESTS, manifestOf } from "../manifests.ts";
21
22
  import { castNow } from "./cast.ts";
23
+ import { daikinHost } from "./daikin.ts";
22
24
  import { printersNow } from "./printers.ts";
23
25
  import { spotifyFromEnv } from "./spotify.ts";
24
26
  import {
@@ -62,17 +64,25 @@ export const weatherSources = (): WeatherSource[] => [
62
64
 
63
65
  /**
64
66
  * Each connector from the shell's env names, once the manifest's non-optional names are set. `needs` adds a step that
65
- * is not an env name (Enphase's token file). Addresses come from the env only; `home probe` and `home discover` search.
67
+ * is not an env name (Enphase's token file). Addresses come from the env, except the Daikin's and the Hue bridge's, which
68
+ * are searched for when unset; `home probe` and `home discover` search for the rest.
66
69
  */
67
70
  const FROM_ENV: Record<
68
71
  string,
69
72
  {
70
- needs?: () => string[];
73
+ needs?: () => string[] | Promise<string[]>;
71
74
  build: () => Integration<any> | Promise<Integration<any>>;
72
75
  }
73
76
  > = {
74
77
  hue: { build: hueFromEnv },
75
- daikin: { build: () => daikin({ host: env.DAIKIN_HOST!, fetch }) },
78
+ daikin: {
79
+ // No address set: ask the network. A home with no unit reads "not set up", not as a failure.
80
+ needs: async () =>
81
+ (await daikinHost())
82
+ ? []
83
+ : ["DAIKIN_HOST (no unit answered the UDP broadcast)"],
84
+ build: async () => daikin({ host: (await daikinHost())!, fetch }),
85
+ },
76
86
  enphase: {
77
87
  needs: () => (existsSync(TOKEN_FILE) ? [] : ["`home enphase login`"]),
78
88
  build: enphaseFromEnv,
@@ -106,7 +116,7 @@ async function statusFromEnv(
106
116
  const from = FROM_ENV[m.name]!;
107
117
  const needs = [
108
118
  ...m.env.filter((e) => !e.optional && !env[e.name]).map((e) => e.name),
109
- ...(from.needs?.() ?? []),
119
+ ...((await from.needs?.()) ?? []),
110
120
  ];
111
121
  rows[m.name] = {
112
122
  status: needs.length ? "not set up" : "connecting",
@@ -216,13 +226,54 @@ function rowLines(name: string, r: Row): string[] {
216
226
  ];
217
227
  }
218
228
 
229
+ /** Every connector's row (or the one named), from the config at `path`, else from the shell's env names. */
230
+ const rowsOf = (path: string | undefined, only?: string) =>
231
+ path
232
+ ? statusFromConfig(path, only)
233
+ : statusFromEnv(only === undefined ? MANIFESTS : [manifestOf(only)!]);
234
+
235
+ /**
236
+ * `home devices`: every device the connectors read, one line each from `homeDevices`, with its room and name from the
237
+ * config when there is one. A connector that isn't connected is named on stderr.
238
+ */
239
+ export async function devices(o: Options): Promise<void> {
240
+ const path = configPathOf(o);
241
+ const rows = await rowsOf(path);
242
+ const parts: Record<string, Part> = {};
243
+ for (const [name, r] of Object.entries(rows))
244
+ if (r.status !== "not set up") parts[name] = r as Part;
245
+ // loadHome has already accepted the config, so a parse that fails here is not expected: list without rooms.
246
+ const config = path ? HomeConfig.safeParse(readConfig(path)).data : undefined;
247
+ const list = homeDevices(parts, config);
248
+ // A connector that isn't connected may have no device to list: say so, off stdout.
249
+ for (const [name, r] of Object.entries(rows))
250
+ if (name in DEVICES && r.status !== "connected")
251
+ console.error(
252
+ [
253
+ `${name}: ${r.status}`,
254
+ r.needs.length ? `needs ${r.needs.join(", ")}` : "",
255
+ r.detail ?? "",
256
+ ]
257
+ .filter(Boolean)
258
+ .join(" · "),
259
+ );
260
+ if (o.json) return printJson(list);
261
+ for (const d of list) console.log(deviceLine(d));
262
+ }
263
+
264
+ /** A device's line: "connector text · in room · not available: reason". */
265
+ export const deviceLine = (d: HomeDevice): string =>
266
+ [
267
+ `${d.connector.padEnd(9)}${d.text}`,
268
+ d.room ? `in ${d.room}` : "",
269
+ d.available === false ? `not available: ${d.reason}` : "",
270
+ ]
271
+ .filter(Boolean)
272
+ .join(" · ");
273
+
219
274
  /** `home status`: every connector, with the energy now on top. With `only` (`home <connector>`), that connector alone. */
220
275
  export async function status(o: Options, only?: string): Promise<void> {
221
- const path = configPathOf(o);
222
- const manifests = only === undefined ? MANIFESTS : [manifestOf(only)!];
223
- const rows = path
224
- ? await statusFromConfig(path, only)
225
- : await statusFromEnv(manifests);
276
+ const rows = await rowsOf(configPathOf(o), only);
226
277
  if (only !== undefined) {
227
278
  const row = rows[only]!;
228
279
  if (row.status !== "connected") process.exitCode = 1; // `home <connector>` is a check a script can test; `home` alone lists every one and exits 0
package/src/config.ts CHANGED
@@ -70,6 +70,12 @@ export const Room = z.looseObject({
70
70
  .describe(
71
71
  "the Hue room, zone or grouped_light id whose lights this room's marker shows",
72
72
  ),
73
+ photos: z
74
+ .array(filePath)
75
+ .optional()
76
+ .describe(
77
+ "photos of the room, as paths relative to the config or from ~/; the library never reads them, a drawing agent or an app does",
78
+ ),
73
79
  });
74
80
 
75
81
  /** JSONC (comments and trailing commas) through jsonc-parser, which runs on Node too; a syntax error throws rather than parsing to part of the file. */
@@ -323,7 +329,7 @@ export type LoadOptions = {
323
329
  fetch?: typeof fetch;
324
330
  /** Hue's pinned fetch and Enphase's unverified one: Bun's by default, `NODE_TLS` from `@khalido/home/node` under Node. */
325
331
  tls?: {
326
- hue(bridgeId: string, ca: string): typeof fetch;
332
+ hue(bridgeId: string, ca?: string): typeof fetch;
327
333
  enphase(): typeof fetch;
328
334
  };
329
335
  /** BOM's transport: `ftpFetch()` from `@khalido/home/weather/ftp`. Without it a listed `bom` source stops and says why. */
@@ -445,7 +451,7 @@ export function loadHome(
445
451
  ): HomeIntegration {
446
452
  switch (d.connector) {
447
453
  case "hue": {
448
- const ca = file(d.ca);
454
+ const ca = d.ca === undefined ? undefined : file(d.ca);
449
455
  return hue({
450
456
  ...start("hue", d.bridgeId, d.host),
451
457
  bridgeId: d.bridgeId,
package/src/connector.ts CHANGED
@@ -51,6 +51,11 @@ export class ConnectorError extends Error {
51
51
  }
52
52
  }
53
53
 
54
+ /** A 401 or 403 from a device or service: the key or token is wrong or revoked. Every connector reads it as `reason: "auth"`. */
55
+ export const isRejectedKey = (error: unknown) =>
56
+ error instanceof ConnectorError &&
57
+ (error.status === 401 || error.status === 403);
58
+
54
59
  /** `fetch` that gives up after `timeoutMs`, or as soon as `signal` (a run's) aborts. Returns the response whatever its status. */
55
60
  export function fetchTimeout(
56
61
  fetcher: typeof fetch,
@@ -9,7 +9,7 @@ Amber Electric's live wholesale-linked prices, read through Amber's public API.
9
9
  - `forecast`: the current interval, then each forecast one, both channels on one row (`PriceInterval`). `next=96` asks for more than Amber has, so the reply runs as far as Amber forecasts: on 2026-10-28 at 3 pm, 12 five-minute intervals then 14 half-hours, to 11 pm. Amber's `startTime` is a second past the interval's start, so `start` is taken to the minute.
10
10
  - `estimate: true` means the current interval's price can still move; it is shown as "(estimate)", never as an anomaly.
11
11
  - The Amber line in `home status` shows the descriptor, "(estimate)", a spike other than `none`, and a negative price. `energyNow` combines the prices with Enphase; see Energy below.
12
- - A 401 marks Amber unavailable and stops polling.
12
+ - A 401 or 403 marks Amber unavailable and stops polling.
13
13
 
14
14
  ## Energy
15
15
 
@@ -4,7 +4,7 @@ import { ConnectorError, getJson } from "../../connector.ts";
4
4
  // Amber Electric's public API v1 (https://app.amber.com.au/developers; the published spec is 1.0, there is no v2). Shapes checked against live replies,
5
5
  // 2026-10-13; every field optional so a change degrades a reading rather than throwing.
6
6
 
7
- /** A failed Amber call. `status` 401 means the key is wrong or revoked; 429 means the rate limit (50 per 5 min). */
7
+ /** A failed Amber call. `status` 401 or 403 (`isRejectedKey`) means the key is wrong or revoked; 429 means the rate limit (50 per 5 min). */
8
8
  export class AmberError extends ConnectorError {}
9
9
 
10
10
  /** One 5-minute price interval on one channel. Prices are c/kWh including GST, as a cost: negative feed-in means you are paid. */
@@ -1,5 +1,5 @@
1
1
  import { asOf, type Clock, realClock } from "../../clock.ts";
2
- import { errorText } from "../../connector.ts";
2
+ import { errorText, isRejectedKey } from "../../connector.ts";
3
3
  import { z } from "zod";
4
4
  import { envName, type Integration } from "../../home.ts";
5
5
  import { energyNow } from "../../energy.ts";
@@ -62,7 +62,7 @@ export function amber(
62
62
  update({ status: "connected", anomaly: last.anomaly, state: last });
63
63
  } catch (error) {
64
64
  if (signal.aborted) return; // stopped mid-read: not a failure
65
- if (error instanceof AmberError && error.status === 401)
65
+ if (isRejectedKey(error))
66
66
  return update({
67
67
  status: "unavailable",
68
68
  reason: "auth",
@@ -10,9 +10,10 @@ Nest speakers, Nest Hubs, Chromecasts, Google TV and speaker groups, read over C
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.
11
11
  - State (`CastSnapshot`, by mDNS id): `form` (`speaker`, `display` or `tv`, from `castForm(model)`; none for a model it doesn't know; the address cache keeps the model), app, `idle` (no app, or Backdrop's `isIdleScreen`), `standby`, volume (unrounded), muted, `volumeControl`, player state, title, artist; groups carry `members` and `leader`; devices carry `groups`, and `playingVia` when they show a playing group's media because their own is empty (Home Assistant's model, not yet seen live).
12
12
  - Names: a device's `name` in the snapshot is its own, from discovery's `fn` or its own multizone status (the same text on all six live devices, 2026-10-13), so a rename in the Google Home app shows once the device answers, even when every address came from the cache and discovery never ran; the config's name is only the placeholder until then. A group's multizone status lists its members, not itself, so a group's new name shows at the next discovery.
13
- - `castStatus(snapshot)`: what each device and group is doing, for a caller choosing where to play something. `state` is `idle`, `restorable` (the Default Media Receiver, which `announce` puts back), `busy` (another app, playing or paused: an announce ends it) or `offline` (not connected, or not heard from yet); with the app, title, artist, volume, `via` (the group whose media a member is playing), `standby`, and a group's `members`. A group is as free as its least free connected member.
14
- - `castFree(snapshot, targets)`: the targets an announcement would not disturb, as names for `announceAll`, and `skipped` with why (`busy`, `offline`, `standby`, `unknown`). A TV in standby is skipped, since a clip may wake it. A group stays a group while every connected member is free; one playing another app is skipped whole; otherwise a group with a busy member becomes its free members one by one (they then play out of step). A device is never named beside a free group that holds it. Both functions are pure, over the snapshot, and tested on built snapshots only: what a group's members report while it plays Spotify hasn't been checked live.
13
+ - `castStatus(state)`: what each device and group is doing, for a caller choosing where to play something. `state` is `idle`, `restorable` (the Default Media Receiver, which `announce` puts back), `busy` (another app, playing or paused: an announce ends it) or `offline` (not connected, or not heard from yet); with the app, title, artist, volume, `via` (the group whose media a member is playing), `standby`, and a group's `members`. A group is as free as its least free connected member.
14
+ - `castFree(state, targets)`: the targets an announcement would not disturb, as names for `announceAll`, and `skipped` with why (`busy`, `offline`, `standby`, `unknown`, `form`). With `{ forms: ["speaker", "display"] }` a TV, or a model whose form isn't known, is skipped as `form`, and a group holding one becomes its other free members. A TV in standby is skipped, since a clip may wake it. A group stays a group while every connected member is free; one playing another app is skipped whole; otherwise a group with a busy member becomes its free members one by one (they then play out of step). A device is never named beside a free group that holds it. Both functions are pure, over the snapshot, and tested on built snapshots only: what a group's members report while it plays Spotify hasn't been checked live.
15
15
  - Writes, on the object `cast()` returns (`CastActions`), over the sockets it already holds, never a second connection. A target is a name (any case) or a castId; volume is 0 to 1, as the snapshot's. `setVolume`, `setMuted` (receiver `SET_VOLUME`; a group's goes to the group's socket; per device, levels and mutes each at most 30 a minute, the newest waiting one winning; one device's lane never waits on another's) and `stop` (receiver `STOP` of the running app; nothing when idle). Each request waits 10 s for its reply and a refusal (`LAUNCH_ERROR`, `LOAD_FAILED`, …) rejects, naming it.
16
+ - `castFreeSpeakers(state)`: the one call for "where can I announce now": `castFree` over every device and group, with `forms: ["speaker", "display"]`. Read live 2026-11-11 (Bun 1.4.4, reads only): 8 devices and groups gave one free group of four, with two TVs and a device whose model was not known skipped as `form`.
16
17
  - `announce(target, clip, { volume?, timeoutMs? })`: `clip` is `{ url, type? }` or `{ bytes, type }`, bytes served through `serve` (`serveClip` from `@khalido/home/cast/serve`, passed to `cast()`). It reads the receiver status (and a Default Media Receiver's media status), for a group re-runs discovery first and reconnects if the leader moved, sets the volume if given and unmutes a muted target (as two requests: a `SET_VOLUME` takes a level or a mute, not both), launches the Default Media Receiver (`CC1AD845`), `LOAD`s the clip and waits for its media session to go `IDLE` with `FINISHED` (60 s plus the clip's length when the device reports it, or `timeoutMs`). Then, always: what the Default Media Receiver was playing is loaded again at its `currentTime`, an idle device gets `STOP`, then the volume and mute (a group's its own level first, then member by member, through each member's own socket, from the group's multizone status). Another app (Spotify, YouTube) is interrupted, as Google's own announcements do, and the result names it in `notRestored`. It returns `{ played, why?, restored, notRestored }` and throws only before anything changed (an unknown target, a socket down). A clip gets 15 s to reach `PLAYING`, then the name of its type goes in the failure, since a device that can't play it took 35-44 s to say so; a receiver error sooner carries its reason and code. `timeoutMs` replaces both waits. `announceAll(targets, clip, options)` does several at once, one result per device, an unreachable target its own `played: false`; a group and one of its members would clash, untried.
17
18
  - `serveClip(bytes, type, { host?, signal? })` (`node:http`, Node-only like `tls.ts`): an address of this machine on the target's network (else the first non-internal IPv4), a random port and an unguessable path, `Content-Type` and `Content-Length`; the whole body to every GET and HEAD (a `Range` probe gets a full 200, which HTTP allows), so a player that probes before it fetches still gets the clip. It closes on `close()`, which `announce` calls once the clip has played, when `signal` aborts, or after 60 s. Not yet seen live: how the Default Media Receiver fetches, and the macOS local-network prompt the first serve may raise.
18
19
  - CLI: `home cast announce --to <name> [--to <name>…] (--say "<text>" | --file <path>) [--volume 0-100]`, `home cast volume <name> <0-100>`, `home cast mute <name> on|off`, `home cast stop <name>`. `--say` runs `say -o <tmp>.m4a --file-format=m4af --data-format=aac` (without the data format, m4af holds uncompressed PCM), serves it as `audio/mp4` and deletes the file. The library never runs `say`.
@@ -31,7 +32,9 @@ CastV2 is TLS to port 8009 (a group: its own port), carrying length-prefixed pro
31
32
  - Media status first seen live 2026-11-09 (Bun 1.4.4): a paused title and artist on a TV.
32
33
  - **First announcements, 2026-11-09, from a Pi on 0.1.1.** MP3 played on two audio-only speakers and two displays, 7-8 s each, the clip fetched from the Pi's LAN address, idle and volume restored. A 24 kHz mono WAV played on the displays and failed on both audio-only speakers, "the clip stopped: ERROR" after 35-44 s. One WAV format tried: use MP3 or AAC.
33
34
  - **On 0.1.2, 2026-11-10, from the Pi (Bun 1.4.4; chota-bot's session, KO listening).** MP3 at volume 30 on one audio-only speaker: played and restored in 2 s. To a group of four: played in 9.5 s, each member back at its own level. `setVolume` and `setMuted` on one speaker read back as set. Over Spotify playing on the speaker: played, the volume restored, Spotify listed in `notRestored`, and KO had to pick the speaker again in the Spotify app.
34
- - **A group's own level was not restored** (same run; fixed after 0.1.2, untried live): after the group announce at 30 the group read 30 (it had been 53) though every member was back; setting the group to 53 afterwards did not move the members. `restoreVolume` restored members only; since fixed: the group's own level goes back first, then each member's. Untried live.
35
+ - **A group's own level was not restored** (same run; fixed in 0.1.3, confirmed below): after the group announce at 30 the group read 30 (it had been 53) though every member was back; setting the group to 53 afterwards did not move the members. `restoreVolume` restored members only; fixed in 0.1.3: the group's own level goes back first, then each member's.
36
+ - **From a Mac, 2026-11-11 (Bun 1.4.4).** `home cast announce --to <a Nest Hub> --say … --volume 40`: played, idle and volume restored, 6 s in all; KO heard it.
37
+ - **On 0.1.3, 2026-11-11, from the Pi (Bun 1.4.4; chota-bot's session).** A group announce at volume 30: played, and the group and all four members read their earlier levels straight after and 10 s later. With Spotify cast to the group of four: every member read the group's track "via" the group at its own level, `castStatus` gave each member and the group `busy`, and `castFree` over every target left only the idle TV free. So what `castFree` assumes members report is what they report. `castFree` over every target at idle: the group and an idle TV free, a TV with Spotify paused skipped as busy, a device not in discovery skipped as unknown.
35
38
  - Not seen yet: `stop`, an announce over a Default Media Receiver stream, `announceAll`, a leader change, member changes, and Node's `tls` (no Node caller yet).
36
39
 
37
40
  ## TLS: verification off, the name checked
@@ -37,7 +37,13 @@ import {
37
37
  } from "./writes.ts";
38
38
 
39
39
  export type { CastConnect } from "./codec.ts";
40
- export { castDevices, castForm, castFree, castStatus } from "./state.ts";
40
+ export {
41
+ castDevices,
42
+ castForm,
43
+ castFree,
44
+ castFreeSpeakers,
45
+ castStatus,
46
+ } from "./state.ts";
41
47
  export type {
42
48
  CastDevice,
43
49
  CastForm,
@@ -325,10 +325,10 @@ export function castStatus(s: CastSnapshot): CastStatus[] {
325
325
  return castDevices(s).map(one);
326
326
  }
327
327
 
328
- /** A target `castFree` left out: `standby` is a TV that is off, which a clip may wake. */
328
+ /** A target `castFree` left out: `standby` is a TV that is off, which a clip may wake; `form` is one whose form wasn't asked for. */
329
329
  export type CastSkipped = {
330
330
  name: string;
331
- why: "busy" | "offline" | "standby" | "unknown";
331
+ why: "busy" | "offline" | "standby" | "unknown" | "form";
332
332
  app?: string;
333
333
  };
334
334
 
@@ -340,11 +340,21 @@ export type CastSkipped = {
340
340
  * becomes its free members, named one by one (they then play out of step with each other).
341
341
  * - A device is never named beside a free group that holds it, nor two groups that share a member: the second becomes its
342
342
  * members not yet covered.
343
+ * - With `forms` (`["speaker", "display"]` keeps an announcement off TVs), a device of another form, or of a model this
344
+ * library doesn't know, is skipped with `why: "form"`, and a group holding one (or a member the snapshot doesn't have)
345
+ * becomes its other free members. An empty list leaves every device out.
343
346
  */
344
347
  export function castFree(
345
348
  s: CastSnapshot,
346
349
  targets: string[],
350
+ options: { forms?: readonly CastForm[] } = {},
347
351
  ): { free: string[]; skipped: CastSkipped[] } {
352
+ /** A device of a form asked for. A group has no form of its own: its members decide. */
353
+ const wanted = (id: string) => {
354
+ const d = s.devices[id];
355
+ if (!options.forms || d?.kind === "group") return true;
356
+ return d?.form !== undefined && options.forms.includes(d.form);
357
+ };
348
358
  const status = new Map(castStatus(s).map((c) => [c.id, c]));
349
359
  const free = new Map<string, CastStatus>();
350
360
  const covered = new Set<string>(); // members of the groups in `free`
@@ -354,15 +364,17 @@ export function castFree(
354
364
  const skip = (c: CastStatus) =>
355
365
  skipped.set(c.id, {
356
366
  name: c.name,
357
- why:
358
- c.state === "offline"
367
+ why: !wanted(c.id)
368
+ ? "form"
369
+ : c.state === "offline"
359
370
  ? "offline"
360
371
  : c.standby && c.state !== "busy"
361
372
  ? "standby"
362
373
  : "busy",
363
374
  ...(c.app ? { app: c.app } : {}),
364
375
  });
365
- const device = (c: CastStatus) => (ok(c) ? free.set(c.id, c) : skip(c));
376
+ const device = (c: CastStatus) =>
377
+ ok(c) && wanted(c.id) ? free.set(c.id, c) : skip(c);
366
378
  for (const target of targets) {
367
379
  const d = findCast(s.devices, target);
368
380
  const c = d && status.get(d.id);
@@ -376,6 +388,7 @@ export function castFree(
376
388
  else if (
377
389
  own !== "offline" &&
378
390
  up.every(ok) &&
391
+ (d.members ?? []).every(wanted) &&
379
392
  !members.some((m) => covered.has(m.id))
380
393
  ) {
381
394
  free.set(c.id, c);
@@ -390,3 +403,17 @@ export function castFree(
390
403
  skipped: [...skipped.values()],
391
404
  };
392
405
  }
406
+
407
+ /**
408
+ * Where an announcement can play right now, in one call: every speaker and display that is free, a whole group where all
409
+ * of it is, and never a TV. `castFree` over everything Cast has found, with `forms: ["speaker", "display"]`; `free` goes
410
+ * straight to `announceAll`.
411
+ */
412
+ export const castFreeSpeakers = (
413
+ s: CastSnapshot,
414
+ ): { free: string[]; skipped: CastSkipped[] } =>
415
+ castFree(
416
+ s,
417
+ castDevices(s).map((d) => d.id),
418
+ { forms: ["speaker", "display"] },
419
+ );
@@ -2,9 +2,9 @@
2
2
 
3
3
  Reads one Daikin AC over its local JSON `dsiot` API. `iot_class`: **local_polling**. KO's unit is a Daikin Australia **Alira X** with built-in Wi-Fi (likely; it reports a blank model string).
4
4
 
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
- - `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`, or a home config's address, with discovery by `mac` if the unit moves. There is no CLI command that writes.
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`). **`power(on)` and `mode(mode, targetC?)` are the writes most callers want.** `mode` selects the mode, turns the unit on and sets the setpoint in one request: the one given, else the library's default (`DEFAULT_TARGET_C`: cool 25, heat 27), else (auto) the unit's own; dry and fan take none. Both resolve with `{ state, confirmed }`: the unit read once a second until it reports what was asked, at most 5 s (`CONFIRM_MS`); unconfirmed is a result, not an error. A setpoint off the unit's range or a half degree throws before anything is sent. `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
+ - `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` (`power`, `mode`, or `write` for a built request): one client for the integration's life, at the unit's current address.
7
+ - `home status` and `home daikin` read `DAIKIN_HOST`, or a home config's address (with discovery by `mac` if the unit moves); with neither, they take the first unit to answer the UDP broadcast, which adds 1.5 s. The broadcast goes out three times in that window: a single one missed KO's unit in 2 runs of 3 while mDNS searches ran beside it (2026-11-11); three found it 6 of 6. `home daikin on | off` and `home daikin cool | heat | auto [°C] | dry | fan` are the CLI writes, at `DAIKIN_HOST` or the first unit to answer the UDP broadcast, printing what the unit then reports.
8
8
 
9
9
  ## The protocol
10
10
 
@@ -39,7 +39,7 @@ One read-only request, saved with the MAC, SSID, `edid` and the unit's ASCII ids
39
39
  ## Unconfirmed
40
40
 
41
41
  - **The write lane's rates** (1 s apart, 20 a minute) are guesses. One client lives as long as the integration and reads the unit's address at each send, so a move keeps the lane's count, and a write still waiting goes to the new address.
42
- - **Writes other than power.** Only `setPower(true)` has been sent to KO's unit (2026-10-16, Bun 1.4.3, see below); mode, setpoint and fan writes are untried. The encodings come from pydaikin, which reports verified live writes on other BRP084 units. The two sources disagree in three places, plus one choice of our own, settled here as follows:
42
+ - **Writes other than power.** Only `setPower(true)` has been sent to KO's unit (2026-10-16 and 2026-11-11, see below); dry to cool at 25 °C worked (2026-11-11, below); heat, auto, fan mode and fan-speed writes are untried. The encodings come from pydaikin, which reports verified live writes on other BRP084 units. The two sources disagree in three places, plus one choice of our own, settled here as follows:
43
43
  - Setpoint width: pydaikin sends one byte, Alira two (`3200`). One byte, because the live field is one byte (`p_02` `mi 24`, `mx 40`).
44
44
  - Method: pydaikin POSTs, Alira PUTs. POST, the method reads already use.
45
45
  - Field: Alira always writes `p_03` and `p_0A`. Those are pydaikin's heat-mode setpoint and fan, and Alira reads by list position (its `[2]` and `[8]` are `p_03` and `p_0A`), so it appears written on a unit in heat. Per-mode fields here.
@@ -63,10 +63,22 @@ All setpoint and fan fields, read from `e_3001` with `md` (setpoint is one byte,
63
63
 
64
64
  Unmapped and unread: `p_27` (`0A00`, like auto), `p_34` (one signed byte, `00`, `mi FA`, `mx 00`), and the `p_05`–`p_08`, `p_20`–`p_25` fields (3- and 4-byte values, mostly `0F…`, likely schedules or limits). Meaning unknown.
65
65
 
66
+ ## Second write (2026-11-11)
67
+
68
+ `setPower(true)` at KO's request, through `daikinClient().write` and the rewritten write lane (Bun 1.4.4, from a Mac): accepted in 280 ms, and reads at once, 2 s and 5 s later all showed `on` (before: off, dry, room 25 °C). The unit was found by the UDP broadcast. Then `setMode("dry")` at KO's request: accepted in 291 ms and the unit read `on`, dry. It was already in dry, so this shows the write is accepted, not that a mode changes. `home daikin on` on the running unit printed its line and exited 0. `home probe` shows 404 on the three old `/common` and `/aircon` paths and an answer on `dsiot`, as expected for this unit.
69
+
70
+ ## First mode change and setpoint (2026-11-11)
71
+
72
+ `home daikin cool` at KO's request, the unit on in dry (Bun 1.4.4): one request with power, mode and the default setpoint; the command took 3.3 s in all, 1.5 s of it the UDP search, and printed `on · cool 25 °C`. A read some 15 s later showed the same, fan 3. So `mode` with a setpoint works on this unit, and the unit reports the change within the confirm wait. Still untried: heat, auto, fan mode, a fan-speed write, and `power(false)`.
73
+
66
74
  ## Read from a Pi (2026-11-10)
67
75
 
68
76
  `home daikin` on 0.1.2 (Bun 1.4.4; chota-bot's session): "connected · off · dry · room 26 °C · outdoor unit 28 °C". `home discover` found the unit by UDP. No write was sent.
69
77
 
78
+ ## Dry run (2026-11-12)
79
+
80
+ `power` and `mode` take `{ dryRun: true }`: the checks, one read, and `wouldSend` with what it would set; nothing sent. `home daikin cool --dry-run` and `heat --dry-run` on KO's unit (Bun 1.4.4) read it and printed 25 and 27; the unit stayed off.
81
+
70
82
  ## Sources
71
83
 
72
84
  Both Daikin protocol sources are GPL-3.0, so they are read for facts only (endpoints, field paths, value maps) and no code is copied; that keeps this package MIT.
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import type { Clock } from "../../clock.ts";
2
+ import { type Clock, realClock } from "../../clock.ts";
3
3
  import { ConnectorError, getJson } from "../../connector.ts";
4
4
  import { writeLane } from "../../lane.ts";
5
5
 
@@ -81,6 +81,37 @@ const TARGET_RANGE: Partial<Record<Mode, [number, number]>> = {
81
81
  auto: [18, 30],
82
82
  };
83
83
 
84
+ /**
85
+ * The setpoint a mode gets when the caller names none: this library's opinion (KO's: not too cold, properly warm).
86
+ * Without one, "cool" would mean whatever the remote last left. Auto keeps the unit's own.
87
+ */
88
+ export const DEFAULT_TARGET_C = {
89
+ cool: 25,
90
+ heat: 27,
91
+ } as const satisfies Partial<Record<Mode, number>>;
92
+
93
+ /**
94
+ * What a write left: the state once the unit reported the change (`confirmed`), or as it stood when `CONFIRM_MS` ran out
95
+ * without it. With `{ dryRun: true }` nothing is sent: `wouldSend` is the request and what it sets, `state` the unit as it
96
+ * reads now, and `confirmed` is false. Check `wouldSend` first.
97
+ */
98
+ export type DaikinApplied = {
99
+ state: DaikinState;
100
+ confirmed: boolean;
101
+ wouldSend?: DaikinRequest;
102
+ };
103
+ /** One write: `POST /dsiot/multireq` with `body`; `sets` is what it changes, in this library's words. */
104
+ export type DaikinRequest = {
105
+ method: "POST";
106
+ path: "/dsiot/multireq";
107
+ body: WriteRequest;
108
+ sets: { on?: boolean; mode?: Mode; targetC?: number };
109
+ };
110
+ /** `dryRun`: every check a write makes (the mode, the setpoint's range), one read for the state, and nothing sent. */
111
+ export type DaikinWriteOptions = { dryRun?: boolean };
112
+ /** How long `power` and `mode` wait for the unit to report what was asked, reading once a second. */
113
+ export const CONFIRM_MS = 5000;
114
+
84
115
  /** Little-endian hex to an integer, two's complement when `signed`: "2700" → 39, "EEFF" → -18. */
85
116
  export function hexLE(hex: string, signed = false): number | undefined {
86
117
  if (!/^(?:[0-9a-f]{2})+$/i.test(hex)) return undefined;
@@ -229,7 +260,7 @@ export function setFan(fan: Fan, mode: Mode): WriteRequest {
229
260
  return write([["e_3001", FAN[mode]!, key(FANS, fan)]]);
230
261
  }
231
262
 
232
- /** A client for one unit. Reads are op 2; `write` sends a body built by setPower, setMode, setTarget or setFan. */
263
+ /** A client for one unit. Reads are op 2. `power` and `mode` are the writes most callers want; `write` sends any body built by setPower, setMode, setTarget or setFan. */
233
264
  export function daikinClient(options: {
234
265
  host: string;
235
266
  fetch: typeof fetch;
@@ -251,7 +282,8 @@ export function daikinClient(options: {
251
282
  schema: Multi,
252
283
  error: DaikinError,
253
284
  });
254
- return {
285
+ const clock = options.clock ?? realClock;
286
+ const client = {
255
287
  /** The indoor and outdoor status in one request. A failed outdoor read leaves `outsideC` empty. */
256
288
  async read(signal?: AbortSignal): Promise<DaikinState> {
257
289
  const m = await multireq(
@@ -292,4 +324,73 @@ export function daikinClient(options: {
292
324
  );
293
325
  },
294
326
  };
327
+ /** Send `request`, then read once a second until the unit reports `asked` or `CONFIRM_MS` passes. Rejects if the write is refused, or if no read after it succeeds. */
328
+ async function apply(
329
+ request: WriteRequest,
330
+ sets: DaikinRequest["sets"],
331
+ asked: (s: DaikinState) => boolean,
332
+ options: DaikinWriteOptions | undefined,
333
+ ): Promise<DaikinApplied> {
334
+ if (options?.dryRun)
335
+ return {
336
+ state: await client.read(),
337
+ confirmed: false,
338
+ wouldSend: {
339
+ method: "POST",
340
+ path: "/dsiot/multireq",
341
+ body: request,
342
+ sets,
343
+ },
344
+ };
345
+ await client.write(request);
346
+ const until = clock.now() + CONFIRM_MS;
347
+ let state: DaikinState | undefined;
348
+ let failed: unknown;
349
+ // The write was accepted, so one dropped read must not turn it into a failure: keep reading until the time is up.
350
+ for (;;) {
351
+ try {
352
+ state = await client.read();
353
+ if (asked(state)) break;
354
+ } catch (error) {
355
+ failed = error;
356
+ }
357
+ if (clock.now() >= until) break;
358
+ await clock.sleep(1000);
359
+ }
360
+ if (!state) throw failed; // sent, but the unit answered no read since: nothing to report
361
+ return { state, confirmed: asked(state) };
362
+ }
363
+ return {
364
+ ...client,
365
+ /** Turn the unit on or off, as it was last left. Only when a person asks. Resolves with what the unit then reported. */
366
+ power: (
367
+ on: boolean,
368
+ options?: DaikinWriteOptions,
369
+ ): Promise<DaikinApplied> =>
370
+ apply(setPower(on), { on }, (s) => s.on === on, options),
371
+ /**
372
+ * Select a mode, turn the unit on and set its setpoint, in one request: `targetC`, else `DEFAULT_TARGET_C` (cool 25,
373
+ * heat 27), else (auto) the unit's own. Dry and fan take none, and a `targetC` for them throws before anything is
374
+ * sent, as one off the unit's range does. Only when a person asks. Resolves with what the unit then reported.
375
+ */
376
+ async mode(
377
+ mode: Mode,
378
+ targetC?: number,
379
+ options?: DaikinWriteOptions,
380
+ ): Promise<DaikinApplied> {
381
+ const c =
382
+ targetC ?? (DEFAULT_TARGET_C as Partial<Record<Mode, number>>)[mode];
383
+ return apply(
384
+ c === undefined
385
+ ? setMode(mode)
386
+ : mergeWrites(setMode(mode), setTarget(c, mode)),
387
+ { on: true, mode, ...(c === undefined ? {} : { targetC: c }) },
388
+ (s) =>
389
+ s.on === true &&
390
+ s.mode === mode &&
391
+ (c === undefined || s.targetC === c),
392
+ options,
393
+ );
394
+ },
395
+ };
295
396
  }
@@ -17,8 +17,13 @@ import {
17
17
  } from "./client.ts";
18
18
 
19
19
  export {
20
+ CONFIRM_MS,
21
+ type DaikinApplied,
22
+ type DaikinRequest,
23
+ type DaikinWriteOptions,
20
24
  DaikinError,
21
25
  type DaikinState,
26
+ DEFAULT_TARGET_C,
22
27
  daikinClient,
23
28
  type Fan,
24
29
  type Mode,
@@ -60,7 +65,7 @@ export type DaikinSnapshot = DaikinState & { asOf: string };
60
65
  export type DaikinClient = ReturnType<typeof daikinClient>;
61
66
 
62
67
  /**
63
- * One Daikin AC as a home integration: one read now, then every 120 s. Its writes are `client.write(setPower(true))`: one
68
+ * One Daikin AC as a home integration: one read now, then every 120 s. Its writes are `client.power(on)` and `client.mode(mode, targetC?)`: one
64
69
  * client for the integration's life, so the writes share its lane and follow the unit when it moves.
65
70
  */
66
71
  export function daikin(
@@ -148,7 +153,8 @@ export const manifest: Manifest<DaikinSnapshot> = {
148
153
  {
149
154
  name: "DAIKIN_HOST",
150
155
  example: "192.0.2.20",
151
- what: "the unit's address; `home probe` finds it by UDP broadcast",
156
+ optional: true,
157
+ what: "the unit's address; found by UDP broadcast when unset (1.5 s a run)",
152
158
  },
153
159
  ],
154
160
  lines: ({ state }) => (state ? [acText(state)] : []),
@@ -10,9 +10,11 @@ Reads an IQ Gateway (Envoy) on the LAN: production, consumption and grid flow. `
10
10
  - `host` may be pasted with a scheme or a trailing slash (`https://…/`); `gatewayHost` reduces it to the bare host for requests, discovery and the address cache.
11
11
  - In the home config the device may carry `marker` (the plan's marker where the array is drawn, on the roof outside any room) and `capacityW` (the array's rated watts from the installer's paperwork, never guessed from the gateway). Neither reaches the gateway; they are for drawing.
12
12
  - A meter counts only when `/ivp/meters` says `enabled` **and** its reading has `activeCount > 0`; otherwise production comes from the inverters and Wh today is left out, since pyenphase found the inverter daily total unreliable.
13
- - The token's days left ride in the snapshot; under 30 the status says to run `home enphase login`. A 401 marks Enphase unavailable and stops polling.
13
+ - The token's days left ride in the snapshot; under 30 the status says to run `home enphase login`. A 401 or 403 marks Enphase unavailable and stops polling.
14
14
  - `home enphase login` mints the token (below) and writes `~/.config/home/enphase-token.json` (0600, directory 0700), printing only the days left. `home status` shows solar now, consumption and the token's days left.
15
15
 
16
+ **Live, 2026-11-12 (Bun 1.4.4).** From late October `home enphase` said ECONNREFUSED. The gateway had not changed: DHCP had given it a new address, mDNS and the address cache had the new one, and an `IQ_GATEWAY_HOST` in the shell still named the old one, which the CLI trusted without a fallback. The CLI now takes the serial from the address cache and runs discovery after one failed read, so the status line reads and says "moved from a to b". The owner token was fine throughout (338 days left).
17
+
16
18
  **Live, KO's gateway, 2026-10-13 (D8.3.5528, owner token).** `/info` answers without a token; everything else answers 401 without one. With the token, one GET each:
17
19
 
18
20
  - `/ivp/meters`: `production` and `net-consumption` **enabled**, `meteringStatus normal`; `storage`, `backfeed`, `load`, `evse`, `pv3p` disabled. So both CTs are fitted.
@@ -8,7 +8,7 @@ import {
8
8
  type Measurement,
9
9
  } from "./schemas.ts";
10
10
 
11
- /** A failed Enphase call. `status` 401 means the token is wrong or expired. */
11
+ /** A failed Enphase call. `status` 401 or 403 (`isRejectedKey`) means the token is wrong or expired. */
12
12
  export class EnphaseError extends ConnectorError {}
13
13
 
14
14
  /** Solar right now and today. Watts and watt-hours as the gateway reports them, negatives included. */