@khalido/home 0.1.2 → 0.1.3

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 (68) hide show
  1. package/CHANGELOG.md +33 -2
  2. package/README.md +1 -1
  3. package/dist/clock.js +2 -0
  4. package/dist/connector.d.ts +9 -2
  5. package/dist/connectors/amber/index.js +1 -0
  6. package/dist/connectors/cast/index.d.ts +8 -6
  7. package/dist/connectors/cast/index.js +30 -4
  8. package/dist/connectors/cast/state.d.ts +52 -0
  9. package/dist/connectors/cast/state.js +122 -0
  10. package/dist/connectors/cast/writes.d.ts +0 -2
  11. package/dist/connectors/cast/writes.js +25 -7
  12. package/dist/connectors/daikin/index.d.ts +6 -2
  13. package/dist/connectors/daikin/index.js +21 -1
  14. package/dist/connectors/enphase/index.d.ts +5 -1
  15. package/dist/connectors/enphase/index.js +34 -2
  16. package/dist/connectors/hue/index.d.ts +6 -2
  17. package/dist/connectors/hue/index.js +39 -6
  18. package/dist/connectors/printers/index.d.ts +6 -2
  19. package/dist/connectors/printers/index.js +23 -0
  20. package/dist/connectors/spotify/index.d.ts +6 -1
  21. package/dist/connectors/spotify/index.js +26 -1
  22. package/dist/connectors/up/index.js +1 -0
  23. package/dist/connectors/weather/bom.js +1 -0
  24. package/dist/connectors/weather/google.js +52 -9
  25. package/dist/connectors/weather/index.d.ts +2 -1
  26. package/dist/connectors/weather/index.js +7 -0
  27. package/dist/connectors/weather/source.d.ts +2 -0
  28. package/dist/devices.d.ts +27 -0
  29. package/dist/devices.js +61 -0
  30. package/dist/health.d.ts +2 -1
  31. package/dist/health.js +2 -1
  32. package/dist/home.d.ts +41 -3
  33. package/dist/home.js +24 -7
  34. package/dist/index.d.ts +3 -2
  35. package/dist/index.js +2 -1
  36. package/docs/cli.md +1 -1
  37. package/docs/config.md +8 -3
  38. package/docs/decisions.md +4 -0
  39. package/docs/using.md +17 -0
  40. package/package.json +1 -1
  41. package/skills/use-home/SKILL.md +7 -7
  42. package/src/cli/enphase.ts +5 -1
  43. package/src/cli/status.ts +26 -3
  44. package/src/clock.ts +1 -0
  45. package/src/connector.ts +18 -2
  46. package/src/connectors/amber/index.ts +1 -0
  47. package/src/connectors/cast/README.md +7 -3
  48. package/src/connectors/cast/index.ts +59 -7
  49. package/src/connectors/cast/state.ts +177 -0
  50. package/src/connectors/cast/writes.ts +29 -8
  51. package/src/connectors/daikin/README.md +4 -0
  52. package/src/connectors/daikin/index.ts +41 -2
  53. package/src/connectors/enphase/index.ts +49 -2
  54. package/src/connectors/hue/index.ts +57 -8
  55. package/src/connectors/printers/README.md +4 -2
  56. package/src/connectors/printers/index.ts +43 -1
  57. package/src/connectors/spotify/README.md +14 -2
  58. package/src/connectors/spotify/index.ts +46 -2
  59. package/src/connectors/up/index.ts +1 -0
  60. package/src/connectors/weather/README.md +1 -1
  61. package/src/connectors/weather/bom.ts +1 -0
  62. package/src/connectors/weather/google.ts +66 -10
  63. package/src/connectors/weather/index.ts +16 -2
  64. package/src/connectors/weather/source.ts +2 -0
  65. package/src/devices.ts +102 -0
  66. package/src/health.ts +4 -2
  67. package/src/home.ts +75 -8
  68. package/src/index.ts +5 -1
@@ -2,7 +2,7 @@ import { asOf, realClock } from "../../clock.js";
2
2
  import { errorText } from "../../connector.js";
3
3
  import { z } from "zod";
4
4
  import { lostDetail, relocator } from "../../discover.js";
5
- import { envName, filePath, } from "../../home.js";
5
+ import { envName, filePath, deviceRecords, } from "../../home.js";
6
6
  import { bunPinnedFetch, hueClient } from "./client.js";
7
7
  import { hueEvents, isRejectedKey } from "./events.js";
8
8
  import { groups, lights, reduce, snapshotFrom, } from "./state.js";
@@ -137,6 +137,7 @@ export function hue(config) {
137
137
  if (isRejectedKey(error))
138
138
  return update({
139
139
  status: "unavailable",
140
+ reason: "auth",
140
141
  detail: "the bridge rejected the key; pair again",
141
142
  state: snapshot,
142
143
  });
@@ -161,6 +162,41 @@ const view = (s) => ({
161
162
  brightness,
162
163
  })),
163
164
  });
165
+ /** "Kitchen: on · 40%", "Kitchen: off", or the name alone when the bridge hasn't said. */
166
+ const onText = (v) => `${v.name ?? v.id}${v.on === undefined ? "" : `: ${v.on ? `on${v.brightness === undefined ? "" : ` · ${Math.round(v.brightness)}%`}` : "off"}`}`;
167
+ /** Every light, then every room and zone. A light's `available` is its Zigbee link; a group's `members` are its lights. */
168
+ export function hueHomeDevices(part) {
169
+ const s = part?.state;
170
+ if (!s)
171
+ return [];
172
+ const on = (v) => v.on === undefined
173
+ ? {}
174
+ : { state: v.on ? "on" : "off" };
175
+ return deviceRecords(part, [
176
+ ...lights(s).map((l) => ({
177
+ connector: "hue",
178
+ id: l.id,
179
+ name: l.name ?? l.id,
180
+ kind: "light",
181
+ ...(l.reachable === undefined ? {} : { available: l.reachable }),
182
+ ...on(l),
183
+ asOf: s.asOf,
184
+ text: onText(l),
185
+ detail: l,
186
+ })),
187
+ ...groups(s).map((g) => ({
188
+ connector: "hue",
189
+ id: g.id,
190
+ name: g.name ?? g.id,
191
+ kind: "group",
192
+ ...on(g),
193
+ asOf: s.asOf,
194
+ text: onText(g),
195
+ members: g.lightIds,
196
+ detail: g,
197
+ })),
198
+ ]);
199
+ }
164
200
  export const manifest = {
165
201
  name: "hue",
166
202
  title: "Philips Hue",
@@ -195,11 +231,8 @@ export const manifest = {
195
231
  lines({ state }) {
196
232
  if (!state)
197
233
  return [];
198
- const { lightsOn, rooms } = view(state);
199
- return [
200
- `${lightsOn} lights on`,
201
- ...rooms.map((r) => `${r.name}: ${r.on ? `on${r.brightness === undefined ? "" : ` · ${Math.round(r.brightness)}%`}` : "off"}`),
202
- ];
234
+ const { lightsOn } = view(state);
235
+ return [`${lightsOn} lights on`, ...groups(state).map(onText)];
203
236
  },
204
237
  record: ({ state }) => (state ? view(state) : {}),
205
238
  };
@@ -1,9 +1,9 @@
1
1
  import { z } from "zod";
2
2
  import { type Clock } from "../../clock.ts";
3
3
  import { type Finder } from "../../discover.ts";
4
- import type { Integration } from "../../home.ts";
4
+ import { type DeviceRecord, type Integration, type Part } from "../../home.ts";
5
5
  import type { Manifest } from "../../manifest.ts";
6
- import { type Printer, type PrintersState } from "./state.ts";
6
+ import { type Printer, type PrinterStatus, type PrintersState } from "./state.ts";
7
7
  export { type Marker, type Printer, type PrintersState, type PrinterStatus, printerText, } from "./state.ts";
8
8
  /** One printer in a home config. Everything the printer reports about itself (model, markers, state) comes live; `host` and `port` are only where it last answered. */
9
9
  export declare const configSchema: z.ZodObject<{
@@ -50,3 +50,7 @@ export declare function printers(config?: PrintersConfig): Integration<PrintersS
50
50
  /** Printers sorted by name. */
51
51
  export declare const printerList: (s: PrintersState) => Printer[];
52
52
  export declare const manifest: Manifest<PrintersState>;
53
+ /** A printer as a home device: `state` is the printer's own (`idle`, `printing`, `stopped`, `offline`); `detail` its `Printer`. */
54
+ export type PrinterHomeDevice = DeviceRecord<"printers", "printer", PrinterStatus, Printer>;
55
+ /** Every printer, by name. One that has stopped answering is not `available`, and keeps its last values. */
56
+ export declare function printersHomeDevices(part: Part<PrintersState> | undefined): PrinterHomeDevice[];
@@ -2,6 +2,7 @@ import { z } from "zod";
2
2
  import { asOf, realClock } from "../../clock.js";
3
3
  import { errorText } from "../../connector.js";
4
4
  import { DAY_MS, discover, HOUR, lostDetail, rescanDelay, } from "../../discover.js";
5
+ import { deviceRecords, } from "../../home.js";
5
6
  import { poller } from "../../poll.js";
6
7
  import { getPrinterAttributes, printerUrl } from "./client.js";
7
8
  import { attributesOf } from "./codec.js";
@@ -280,3 +281,25 @@ export const manifest = {
280
281
  ];
281
282
  },
282
283
  };
284
+ /** Every printer, by name. One that has stopped answering is not `available`, and keeps its last values. */
285
+ export function printersHomeDevices(part) {
286
+ if (!part?.state)
287
+ return [];
288
+ return deviceRecords(part, printerList(part.state).map((p) => ({
289
+ connector: "printers",
290
+ id: p.uuid,
291
+ name: p.name,
292
+ kind: "printer",
293
+ ...(p.state === undefined ? {} : { available: p.state !== "offline" }),
294
+ ...(p.state === undefined ? {} : { state: p.state }),
295
+ // An offline printer's values are from when it last answered, not from this check.
296
+ ...(p.state === "offline"
297
+ ? p.lastSeen === undefined
298
+ ? {}
299
+ : { asOf: p.lastSeen }
300
+ : { asOf: p.asOf }),
301
+ text: printerText(p),
302
+ ...(p.room ? { room: p.room } : {}),
303
+ detail: p,
304
+ })));
305
+ }
@@ -1,7 +1,8 @@
1
1
  import { z } from "zod";
2
2
  import { type Clock } from "../../clock.ts";
3
- import type { Integration } from "../../home.ts";
3
+ import { type DeviceRecord, type Integration, type Part } from "../../home.ts";
4
4
  import type { Manifest } from "../../manifest.ts";
5
+ import { type SpeakerState } from "./changes.ts";
5
6
  import { type SocketFactory, type SpotifyState } from "./client.ts";
6
7
  export { positionMs, type PositionAnchor, type SocketFactory, type SocketLike, type SpotifyState, type Stall, toSpotifyUri, type Track, } from "./client.ts";
7
8
  export { type SpeakerState, speakerState, type SpotifyEvent, spotifyChanges, spotifyText, } from "./changes.ts";
@@ -92,3 +93,7 @@ export type SpotifyActions = {
92
93
  */
93
94
  export declare function spotify(config?: SpotifyConfig): Integration<SpotifyState, "spotify"> & SpotifyActions;
94
95
  export declare const manifest: Manifest<SpotifyState>;
96
+ /** The Soloist speaker as a home device: `state` is `speakerState`'s word; `detail` its `SpotifyState`. */
97
+ export type SpotifyHomeDevice = DeviceRecord<"spotify", "speaker", SpeakerState, SpotifyState>;
98
+ /** The one speaker, once Soloist has said something. Its name is Soloist's `--device-name`, the one phones show. */
99
+ export declare function spotifyHomeDevices(part: Part<SpotifyState> | undefined): SpotifyHomeDevice[];
@@ -1,8 +1,9 @@
1
1
  import { z } from "zod";
2
2
  import { asOf, realClock } from "../../clock.js";
3
3
  import { ConnectorError, errorText } from "../../connector.js";
4
+ import { deviceRecords, } from "../../home.js";
4
5
  import { writeLane } from "../../lane.js";
5
- import { spotifyText, stallText } from "./changes.js";
6
+ import { speakerState, spotifyText, stallText, } from "./changes.js";
6
7
  import { commandFrame, fold, Frame, FRAME_TYPES, OPEN, oddities, positionMs, toSpotifyUri, } from "./client.js";
7
8
  export { positionMs, toSpotifyUri, } from "./client.js";
8
9
  export { speakerState, spotifyChanges, spotifyText, } from "./changes.js";
@@ -259,6 +260,8 @@ export function spotify(config = {}) {
259
260
  capping = false;
260
261
  if (error instanceof ConnectorError && error.code === "offline")
261
262
  return; // the status says it
263
+ if (capUntil !== window)
264
+ return applyCap(); // a new start while it went: this failure doesn't speak for that one
262
265
  capped = true; // refused or unanswered: not again this window
263
266
  note = `lowering the volume to ${cap} failed: ${errorText(error)}`;
264
267
  publish();
@@ -563,6 +566,9 @@ export function spotify(config = {}) {
563
566
  const no = notPlaying();
564
567
  if (no)
565
568
  return no;
569
+ // "playing" while inactive is the account playing on another speaker (live, 2026-11-10): a pause would stop that one.
570
+ if (inactive())
571
+ return "it is not the active device, so the music playing is on another speaker";
566
572
  track = state.track?.uri;
567
573
  try {
568
574
  shown = (await actions.pause()).confirmed; // a pause that never shows still runs fn: what interrupts matters more
@@ -812,3 +818,22 @@ export const manifest = {
812
818
  ? [spotifyText(state)]
813
819
  : [],
814
820
  };
821
+ /** The one speaker, once Soloist has said something. Its name is Soloist's `--device-name`, the one phones show. */
822
+ export function spotifyHomeDevices(part) {
823
+ const s = part?.state;
824
+ if (!s || (s.loggedIn === undefined && s.status === undefined))
825
+ return [];
826
+ return deviceRecords(part, [
827
+ {
828
+ connector: "spotify",
829
+ id: "spotify",
830
+ name: s.deviceName ?? "Soloist",
831
+ kind: "speaker",
832
+ available: true,
833
+ state: speakerState(part),
834
+ asOf: s.asOf,
835
+ text: spotifyText(s),
836
+ detail: s,
837
+ },
838
+ ]);
839
+ }
@@ -142,6 +142,7 @@ export function up(config) {
142
142
  if (error instanceof UpError && error.status === 401)
143
143
  return update({
144
144
  status: "unavailable",
145
+ reason: "auth",
145
146
  detail: "Up rejected the token",
146
147
  state: last,
147
148
  });
@@ -185,6 +185,7 @@ export function bom(options = {}) {
185
185
  if (!options.fetch && base.startsWith("ftp:"))
186
186
  return {
187
187
  stop: "bom: plain fetch cannot read ftp://; pass fetch: ftpFetch() from @khalido/home/weather/ftp",
188
+ stopReason: "config",
188
189
  nextAt: Infinity,
189
190
  };
190
191
  if (force || at >= obsDue) {
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { getJson } from "../../connector.js";
2
+ import { ConnectorError, fetchTimeout } from "../../connector.js";
3
3
  import { RAIN_INTENSITIES } from "./rain.js";
4
4
  import { sunDisagrees } from "./sun.js";
5
5
  import { HOURLY_MINUTES, iso, nextPoll, num, RAIN_HORIZON_MINUTES, USER_AGENT, } from "./source.js";
@@ -273,6 +273,28 @@ export function toRain(minutes, fetchedAt) {
273
273
  */
274
274
  export const MIN_POLL_MINUTES = 15;
275
275
  export const CURRENT_MINUTES = 30;
276
+ /**
277
+ * Why Google refused: the `reason` of its error body's ErrorInfo ("API_KEY_INVALID"), since the status alone doesn't say.
278
+ * A bad key and a bad latitude are both HTTP 400 INVALID_ARGUMENT; only the key's carries a reason (live, 2026-11-10).
279
+ */
280
+ async function refusal(res) {
281
+ try {
282
+ const body = (await res.json());
283
+ const reason = body.error?.details?.find((d) => d.reason)?.reason;
284
+ return typeof reason === "string" ? reason : undefined;
285
+ }
286
+ catch {
287
+ return undefined; // not JSON: the status is all there is
288
+ }
289
+ }
290
+ /** Google's refusal: the HTTP status, and `why`, the reason its error body gave. */
291
+ class GoogleError extends ConnectorError {
292
+ why;
293
+ constructor(message, status, why) {
294
+ super(message, { status });
295
+ this.why = why;
296
+ }
297
+ }
276
298
  /**
277
299
  * Google Weather as a source: modelled `now`, `forecast` (10 days available, 2 read), `sky` without radiation, and `rain` from the
278
300
  * minute forecast (Experimental at Google, so its failure is a `problem` on `rain` and never stops the other slices).
@@ -295,11 +317,20 @@ export function google(options) {
295
317
  },
296
318
  minPollMinutes: MIN_POLL_MINUTES,
297
319
  async read({ place, at, pollMinutes, force, signal, }) {
298
- const q = (extra) => `key=${encodeURIComponent(options.key)}&location.latitude=${place.latitude}&location.longitude=${place.longitude}${extra}`;
299
- const json = (path) => getJson(fetcher, `${base}/${path}`, {
300
- init: { headers: { "user-agent": USER_AGENT } },
301
- signal,
302
- });
320
+ const q = (extra) => `location.latitude=${place.latitude}&location.longitude=${place.longitude}${extra}`;
321
+ // The key goes in a header, never the URL, so no error, log or proxy that shows a URL can show it (all four lookups answered 200 this way, live 2026-11-10).
322
+ const json = async (path) => {
323
+ const res = await fetchTimeout(fetcher, `${base}/${path}`, {
324
+ headers: {
325
+ "user-agent": USER_AGENT,
326
+ "x-goog-api-key": options.key,
327
+ },
328
+ }, { signal });
329
+ if (res.ok)
330
+ return res.json();
331
+ const why = await refusal(res);
332
+ throw new GoogleError(`HTTP ${res.status}${why ? ` ${why}` : ""} on GET /${path.split("?")[0]}`, res.status, why);
333
+ };
303
334
  // Started first and never throws: a failed minute forecast must not sink the rest.
304
335
  const minutes = json(`forecast/minutes:lookup?${q("")}`).then((value) => ({ value }), (error) => ({ error: error }));
305
336
  let problem;
@@ -324,10 +355,22 @@ export function google(options) {
324
355
  }
325
356
  }
326
357
  catch (error) {
327
- const status = error.status;
328
- if (status === 400 || status === 401 || status === 403)
358
+ // Only a GoogleError is Google's answer; a timeout or an abort is a DOMException, with fields of its own.
359
+ const { status, why } = error instanceof GoogleError
360
+ ? error
361
+ : {};
362
+ const said = `HTTP ${status}${why ? ` ${why}` : ""}`;
363
+ if (status === 401 || status === 403 || why?.startsWith("API_KEY_"))
364
+ return {
365
+ stop: `google: the key was rejected (${said})`,
366
+ stopReason: "auth",
367
+ nextAt: Infinity,
368
+ };
369
+ // A 400 with a good key is the request itself, and here that is the location: asking again won't change it.
370
+ if (status === 400)
329
371
  return {
330
- stop: `google: key rejected or request refused (HTTP ${status})`,
372
+ stop: `google: the request was refused (${said}); check the latitude and longitude`,
373
+ stopReason: "config",
331
374
  nextAt: Infinity,
332
375
  };
333
376
  problem = `google: ${error.message}`; // the old slices stay; the minute forecast below is still used
@@ -1,5 +1,5 @@
1
1
  import { type Clock } from "../../clock.ts";
2
- import type { Integration } from "../../home.ts";
2
+ import type { Integration, Reason } from "../../home.ts";
3
3
  import { type Manifest } from "../../manifest.ts";
4
4
  import { type BlockDef, type DayBlocks } from "./blocks.ts";
5
5
  import { type RainSoon } from "./rain.ts";
@@ -38,6 +38,7 @@ export type WeatherState = Slices & {
38
38
  read: boolean;
39
39
  problem?: string;
40
40
  stopped?: string;
41
+ reason?: Exclude<Reason, "crashed">;
41
42
  anomaly?: string;
42
43
  }>;
43
44
  };
@@ -66,6 +66,7 @@ export function weather(config) {
66
66
  read: r !== undefined,
67
67
  problem: r?.problem,
68
68
  stopped: r?.stop,
69
+ reason: r?.stop ? (r.stopReason ?? "config") : undefined,
69
70
  anomaly: r?.anomaly,
70
71
  },
71
72
  ];
@@ -87,6 +88,12 @@ export function weather(config) {
87
88
  : undefined;
88
89
  update({
89
90
  status,
91
+ // Every source has stopped: a rejected key outranks a wrong setting, since it needs a person.
92
+ reason: status !== "unavailable"
93
+ ? undefined
94
+ : Object.values(report).some((r) => r.reason === "auth")
95
+ ? "auth"
96
+ : "config",
90
97
  detail,
91
98
  anomaly: notes("anomaly"),
92
99
  lastGoodAt,
@@ -1,3 +1,4 @@
1
+ import type { Reason } from "../../home.ts";
1
2
  /** Sent on every request, so each service can see who is calling rather than a runtime's default string. */
2
3
  export declare const USER_AGENT = "home (+https://github.com/khalido/home.ts)";
3
4
  export type Place = {
@@ -116,6 +117,7 @@ export type SourceReading = Slices & {
116
117
  problem?: string;
117
118
  anomaly?: string;
118
119
  stop?: string;
120
+ stopReason?: Exclude<Reason, "crashed">;
119
121
  };
120
122
  /**
121
123
  * A weather provider. `owns` names the slices it can fill and how old each may get (by its `at`, beyond one polling
@@ -0,0 +1,27 @@
1
+ import { type CastHomeDevice, castHomeDevices } from "./connectors/cast/index.ts";
2
+ import { type DaikinHomeDevice, daikinHomeDevices } from "./connectors/daikin/index.ts";
3
+ import { type EnphaseHomeDevice, enphaseHomeDevices } from "./connectors/enphase/index.ts";
4
+ import { type HueHomeDevice, hueHomeDevices } from "./connectors/hue/index.ts";
5
+ import { type PrinterHomeDevice, printersHomeDevices } from "./connectors/printers/index.ts";
6
+ import { type SpotifyHomeDevice, spotifyHomeDevices } from "./connectors/spotify/index.ts";
7
+ import type { HomeConfig } from "./config.ts";
8
+ import type { Part } from "./home.ts";
9
+ /** Any device this library reads. `connector`, then `kind` where a connector has more than one, narrows `detail` and `state`. */
10
+ export type HomeDevice = HueHomeDevice | DaikinHomeDevice | EnphaseHomeDevice | CastHomeDevice | PrinterHomeDevice | SpotifyHomeDevice;
11
+ /** Each connector's devices from its part, in the order `home status` lists the connectors. */
12
+ export declare const DEVICES: {
13
+ hue: typeof hueHomeDevices;
14
+ daikin: typeof daikinHomeDevices;
15
+ enphase: typeof enphaseHomeDevices;
16
+ cast: typeof castHomeDevices;
17
+ printers: typeof printersHomeDevices;
18
+ spotify: typeof spotifyHomeDevices;
19
+ };
20
+ /** Connectors that read a service, not a device: nothing to list. */
21
+ export declare const SERVICES: readonly string[];
22
+ /**
23
+ * Every device in the snapshot. With the home `config`, a device takes the `room` its entry gives it, a Hue room or zone
24
+ * (and its lights) the room whose `hueRoom` names it (a light in a room and a zone, the room's), and a device that reports
25
+ * no name of its own (the Daikin, the Enphase gateway) the entry's `name`. A device's own name is never replaced.
26
+ */
27
+ export declare function homeDevices(snapshot: Record<string, Part | undefined>, config?: Pick<HomeConfig, "devices" | "rooms">): HomeDevice[];
@@ -0,0 +1,61 @@
1
+ import { castHomeDevices, } from "./connectors/cast/index.js";
2
+ import { daikinHomeDevices, } from "./connectors/daikin/index.js";
3
+ import { enphaseHomeDevices, } from "./connectors/enphase/index.js";
4
+ import { hueHomeDevices } from "./connectors/hue/index.js";
5
+ import { printersHomeDevices, } from "./connectors/printers/index.js";
6
+ import { spotifyHomeDevices, } from "./connectors/spotify/index.js";
7
+ import { IDENTITY, sameId } from "./discover.js";
8
+ import { manifestOf } from "./manifests.js";
9
+ /** Each connector's devices from its part, in the order `home status` lists the connectors. */
10
+ export const DEVICES = {
11
+ hue: hueHomeDevices,
12
+ daikin: daikinHomeDevices,
13
+ enphase: enphaseHomeDevices,
14
+ cast: castHomeDevices,
15
+ printers: printersHomeDevices,
16
+ spotify: spotifyHomeDevices,
17
+ };
18
+ /** Connectors that read a service, not a device: nothing to list. */
19
+ export const SERVICES = ["amber", "up", "weather"];
20
+ /**
21
+ * Every device in the snapshot. With the home `config`, a device takes the `room` its entry gives it, a Hue room or zone
22
+ * (and its lights) the room whose `hueRoom` names it (a light in a room and a zone, the room's), and a device that reports
23
+ * no name of its own (the Daikin, the Enphase gateway) the entry's `name`. A device's own name is never replaced.
24
+ */
25
+ export function homeDevices(snapshot, config) {
26
+ const all = Object.entries(DEVICES).flatMap(([name, devices]) => devices(snapshot[name]));
27
+ if (!config)
28
+ return all;
29
+ const entries = config.devices ?? [];
30
+ const hueRooms = new Map(); // a Hue group's or light's id → the config room showing it
31
+ for (const d of all)
32
+ if (d.connector === "hue" && d.kind === "group") {
33
+ const room = config.rooms?.find((r) => r.hueRoom !== undefined &&
34
+ (r.hueRoom === d.id || r.hueRoom === d.detail.groupedLightId))?.id;
35
+ // A light in a room and a zone takes the room's: rooms come first, and the first one stays.
36
+ if (room)
37
+ for (const id of [d.id, ...d.detail.lightIds])
38
+ if (!hueRooms.has(id))
39
+ hueRooms.set(id, room);
40
+ }
41
+ return all.map((d) => {
42
+ const mine = entries.filter((e) => e.connector === d.connector);
43
+ const field = IDENTITY[d.connector];
44
+ // One device for the connector: its entry is this device. Many: the entry whose identity is this device's id.
45
+ const entry = d.id === d.connector
46
+ ? mine[0]
47
+ : mine.find((e) => {
48
+ const id = field && e[field];
49
+ return typeof id === "string" && sameId(id, d.id);
50
+ });
51
+ const room = d.room ?? entry?.room ?? hueRooms.get(d.id);
52
+ // A device with no name of its own carries its connector's title: only then does the entry's name replace it.
53
+ const unnamed = d.name === manifestOf(d.connector)?.title;
54
+ const name = unnamed ? (entry?.name ?? d.name) : d.name;
55
+ // Its line opens with its name, so a new name opens the line too.
56
+ const text = name !== d.name && d.text.startsWith(`${d.name}: `)
57
+ ? name + d.text.slice(d.name.length)
58
+ : d.text;
59
+ return { ...d, name, text, ...(room ? { room } : {}) };
60
+ });
61
+ }
package/dist/health.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Part, Status } from "./home.ts";
1
+ import type { Part, Reason, Status } from "./home.ts";
2
2
  /**
3
3
  * How one integration is doing, judged by time: `ok`, `stale` (no good read for a while, last values still shown) or `down`.
4
4
  * The thresholds come with it, so a page can say "stale after 4 min". Push connectors (Hue, Cast) have no poll to be late
@@ -12,6 +12,7 @@ export type Health = {
12
12
  staleAfterMs?: number;
13
13
  downAfterMs: number;
14
14
  detail?: string;
15
+ reason?: Reason;
15
16
  };
16
17
  /** A polling connector is stale after this many polls with no good read: two missed, plus half of one so a slow read that ends in a timeout does not flap it, */
17
18
  export declare const STALE_POLLS = 2.5;
package/dist/health.js CHANGED
@@ -22,7 +22,7 @@ function healthOf(part, now, pollSeconds) {
22
22
  (part.status !== "connected" && (age(part.downSince) ?? 0) >= downAfterMs);
23
23
  const goodAge = age(part.lastGoodAt) ?? age(part.downSince); // never good: as old as the wait for it
24
24
  const stale = staleAfterMs !== undefined && (goodAge ?? 0) >= staleAfterMs;
25
- const { status, lastGoodAt, downSince, detail } = part;
25
+ const { status, lastGoodAt, downSince, detail, reason } = part;
26
26
  return {
27
27
  level: down ? "down" : stale ? "stale" : "ok",
28
28
  status,
@@ -31,5 +31,6 @@ function healthOf(part, now, pollSeconds) {
31
31
  staleAfterMs,
32
32
  downAfterMs,
33
33
  detail,
34
+ reason,
34
35
  };
35
36
  }
package/dist/home.d.ts CHANGED
@@ -2,12 +2,14 @@ import { z } from "zod";
2
2
  import { type Clock } from "./clock.ts";
3
3
  /** A secret in a home config is the name of an environment variable, never the secret: a pasted key fails here. */
4
4
  export declare const envName: z.ZodString;
5
- /** A file a connector needs (a CA, a token), relative to the config file. The caller reads it; the library reads no files. */
5
+ /** A file a connector needs (a CA, a token), relative to the config file or from `~/`. The caller reads it, and expands `~/`; the library reads no files. */
6
6
  export declare const filePath: z.ZodString;
7
7
  /** How an integration is doing. "unavailable" means it stopped and will not retry by itself. */
8
8
  export type Status = "connecting" | "connected" | "disconnected" | "unavailable";
9
+ /** Why a part is `unavailable`, for code to switch on; `detail` is the text for people. `auth`: a key or token was rejected or is unusable, so a person must re-key. `config`: a setting or a file is wrong. `crashed`: its `run` threw. */
10
+ export type Reason = "auth" | "config" | "crashed";
9
11
  /**
10
- * One integration's slice of the home: its status, a human detail, anything odd the device reported, and its state.
12
+ * One integration's slice of the home: its status, a human detail, `reason` (why it is `unavailable`, for code to switch on), anything odd the device reported, and its state.
11
13
  * `createHome` stamps the times (UTC ISO). `lastGoodAt`: its last report while connected, or the read time a connected report
12
14
  * gives when it repeats an older read (weather's 5-minute sun tick). `downSince`: when it stopped being connected (or started,
13
15
  * if it never has); none while connected. Any other time an integration sets is ignored.
@@ -15,11 +17,47 @@ export type Status = "connecting" | "connected" | "disconnected" | "unavailable"
15
17
  export type Part<S = unknown> = {
16
18
  status: Status;
17
19
  detail?: string;
20
+ reason?: Reason;
18
21
  anomaly?: string;
19
22
  state?: S;
20
23
  lastGoodAt?: string;
21
24
  downSince?: string;
22
25
  };
26
+ /**
27
+ * The few things every device answers, whatever it is, in the same words; a connector's `<name>HomeDevices(part)` returns
28
+ * these and `homeDevices(snapshot)` collects them. Everything particular to its kind is in `detail`, the connector's own
29
+ * typed row, and `connector` (with `kind`) says which type that is. Unknown stays absent, never guessed.
30
+ * - `id`: the device's own stable id (a Hue light id, a castId, a printer's UUID), or the connector's name when it has one device.
31
+ * - `kind`: light, group, speaker, display, tv, ac, printer, solar (and `cast` for a Cast model this library doesn't know).
32
+ * - `available`: it answered and can be used now. When it is false, `reason` says why in one word: `offline`, or the
33
+ * connector's own reason for being `unavailable` (`auth`, `config`, `crashed`).
34
+ * - `asOf`: when the device last reported (a push connector) or was last read (a polled one). A quiet light can be hours
35
+ * old and current: `available` says whether it is.
36
+ * - `state`: one short word in that kind's vocabulary: on, off, idle, busy, cool, printing.
37
+ * - `text`: one line for people, "Kitchen: on · 60%".
38
+ * - `room`: a room id from the home config, when one places it.
39
+ * - `members`: a group's device ids.
40
+ */
41
+ export type DeviceRecord<C extends string = string, K extends string = string, S extends string = string, D = unknown> = {
42
+ connector: C;
43
+ id: string;
44
+ name: string;
45
+ kind: K;
46
+ available?: boolean;
47
+ reason?: Reason | "offline";
48
+ asOf?: string;
49
+ state?: S;
50
+ text: string;
51
+ room?: string;
52
+ members?: string[];
53
+ detail: D;
54
+ };
55
+ /**
56
+ * A connector's device records from its part. While the connector isn't connected none is `available`, and each keeps its
57
+ * last values, as the part's state does. A device that isn't available gets its `reason`: the part's when it stopped for
58
+ * good, else `offline`.
59
+ */
60
+ export declare const deviceRecords: <R extends DeviceRecord>(part: Part | undefined, records: R[]) => R[];
23
61
  /**
24
62
  * An integration is a folder in `src/` that exports a factory returning this.
25
63
  * `run` keeps its state current until `signal` aborts, reporting each change through `update`.
@@ -64,7 +102,7 @@ export declare function createHome<I extends Integration<any, string>>(integrati
64
102
  }> | undefined;
65
103
  /**
66
104
  * Read now: one integration by name, or every one. Results arrive through the snapshot and `onChange` as usual; the
67
- * promise resolves once each target has reported (its next update, good or bad), or at once for one whose run has ended.
105
+ * promise resolves once each target has reported (its next update, good or bad), or at once for one that isn't running.
68
106
  */
69
107
  refresh(name?: I["name"]): Promise<void>;
70
108
  /** Run every integration until `signal` aborts. One failing marks itself unavailable; the rest carry on. */
package/dist/home.js CHANGED
@@ -5,8 +5,21 @@ import { errorText } from "./connector.js";
5
5
  export const envName = z
6
6
  .string()
7
7
  .regex(/^[A-Z_][A-Z0-9_]*$/, "an environment variable name (e.g. AMBER_KEY), not the secret itself");
8
- /** A file a connector needs (a CA, a token), relative to the config file. The caller reads it; the library reads no files. */
8
+ /** A file a connector needs (a CA, a token), relative to the config file or from `~/`. The caller reads it, and expands `~/`; the library reads no files. */
9
9
  export const filePath = z.string().min(1);
10
+ /**
11
+ * A connector's device records from its part. While the connector isn't connected none is `available`, and each keeps its
12
+ * last values, as the part's state does. A device that isn't available gets its `reason`: the part's when it stopped for
13
+ * good, else `offline`.
14
+ */
15
+ export const deviceRecords = (part, records) => {
16
+ const up = part?.status === "connected";
17
+ // A part that stopped for good without saying why is a custom integration's: `config` is the nearest true word, since it won't come back by itself.
18
+ const why = part?.status === "unavailable" ? (part.reason ?? "config") : "offline";
19
+ return records.map((r) => up && r.available !== false
20
+ ? r
21
+ : { ...r, available: false, reason: up ? "offline" : why });
22
+ };
10
23
  /** The times `createHome` keeps on a part, from the one before it: when it was last good, and when it stopped being. */
11
24
  function stamped(part, before, now) {
12
25
  if (part.status === "connected")
@@ -29,12 +42,12 @@ export function createHome(integrations, onChange, options = {}) {
29
42
  const clock = options.clock ?? realClock;
30
43
  let snapshot = Object.fromEntries(integrations.map((i) => [i.name, { status: "connecting" }]));
31
44
  const waiting = new Map(); // refresh() callers, resolved by the integration's next report
32
- const finished = new Set(); // integrations whose run has ended: a refresh of one resolves at once, nothing will report
45
+ const finished = new Set(integrations.map((i) => i.name)); // not running, before start or after its run ended: a refresh of one resolves at once, nothing will report
33
46
  const set = (name, reported) => {
34
47
  const before = snapshot[name];
35
48
  const part = stamped(reported, before, asOf(clock));
36
49
  snapshot = { ...snapshot, [name]: part };
37
- onChange?.(snapshot, { name, before, after: part });
50
+ Promise.try(() => onChange?.(snapshot, { name, before, after: part })).catch((error) => console.error(`onChange threw on ${name}: ${errorText(error)}`)); // the caller's bug, thrown or async, not the device's: the integration keeps running
38
51
  const w = waiting.get(name);
39
52
  if (w) {
40
53
  waiting.delete(name);
@@ -51,7 +64,7 @@ export function createHome(integrations, onChange, options = {}) {
51
64
  integration: (name) => integrations.find((i) => i.name === name),
52
65
  /**
53
66
  * Read now: one integration by name, or every one. Results arrive through the snapshot and `onChange` as usual; the
54
- * promise resolves once each target has reported (its next update, good or bad), or at once for one whose run has ended.
67
+ * promise resolves once each target has reported (its next update, good or bad), or at once for one that isn't running.
55
68
  */
56
69
  refresh(name) {
57
70
  const targets = name === undefined
@@ -78,9 +91,13 @@ export function createHome(integrations, onChange, options = {}) {
78
91
  ? { ...p, downSince: p.downSince ?? now }
79
92
  : p,
80
93
  ]));
81
- await Promise.all(integrations.map((i) => i
82
- .run(signal, (part) => set(i.name, part))
83
- .catch((error) => set(i.name, { status: "unavailable", detail: errorText(error) }))
94
+ await Promise.all(integrations.map((i) => Promise.try(() => i.run(signal, (part) => set(i.name, part)))
95
+ .catch((error) => set(i.name, {
96
+ status: "unavailable",
97
+ reason: "crashed",
98
+ detail: errorText(error),
99
+ state: snapshot[i.name].state, // the last good state stays, as it does through a failed read
100
+ }))
84
101
  .finally(() => {
85
102
  finished.add(i.name);
86
103
  for (const resolve of waiting.get(i.name) ?? [])
package/dist/index.d.ts CHANGED
@@ -1,7 +1,8 @@
1
- export { type Change, createHome, envName, filePath, settle, type HomeSnapshot, type Integration, type Part, type StateOf, type Status, } from "./home.ts";
1
+ export { type Change, createHome, type DeviceRecord, deviceRecords, envName, filePath, settle, type HomeSnapshot, type Integration, type Part, type Reason, type StateOf, type Status, } from "./home.ts";
2
2
  export { DOWN_POLLS, type Health, health, PUSH_DOWN_MS, STALE_POLLS, } from "./health.ts";
3
+ export { type HomeDevice, homeDevices } from "./devices.ts";
3
4
  export { asOf, type Clock, realClock } from "./clock.ts";
4
- export { ConnectorError } from "./connector.ts";
5
+ export { type ConnectorCode, ConnectorError } from "./connector.ts";
5
6
  export { bestWindow, type EnergyNow, energyNow, gridText, type PriceWindow, solarLevel, } from "./energy.ts";
6
7
  export { checkHome, checkPlan, Device, HomeConfig, HomeConfigError, type HomeIntegration, type LoadOptions, loadHome, parseJsonc, Room, type Setup, } from "./config.ts";
7
8
  export type { AddressCache, CachedAddress, Connector, Finder, Found, } from "./discover.ts";
package/dist/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  // The root entry: the home, its config, discovery types, the clock, energy, and the sun, rain and day-block helpers. The weather types are in `./weather`.
2
2
  // Each connector has its own entry point (`@khalido/home/hue` and so on). Every export is named; test/exports.test.ts lists them.
3
- export { createHome, envName, filePath, settle, } from "./home.js";
3
+ export { createHome, deviceRecords, envName, filePath, settle, } from "./home.js";
4
4
  export { DOWN_POLLS, health, PUSH_DOWN_MS, STALE_POLLS, } from "./health.js";
5
+ export { homeDevices } from "./devices.js";
5
6
  export { asOf, realClock } from "./clock.js";
6
7
  export { ConnectorError } from "./connector.js";
7
8
  export { bestWindow, energyNow, gridText, solarLevel, } from "./energy.js";