@khalido/home 0.1.1 → 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 (88) hide show
  1. package/CHANGELOG.md +60 -2
  2. package/README.md +12 -12
  3. package/dist/clock.js +2 -0
  4. package/dist/config.js +3 -1
  5. package/dist/connector.d.ts +9 -2
  6. package/dist/connectors/amber/index.js +1 -0
  7. package/dist/connectors/cast/index.d.ts +10 -5
  8. package/dist/connectors/cast/index.js +36 -4
  9. package/dist/connectors/cast/schemas.d.ts +2 -0
  10. package/dist/connectors/cast/schemas.js +1 -0
  11. package/dist/connectors/cast/state.d.ts +56 -0
  12. package/dist/connectors/cast/state.js +134 -0
  13. package/dist/connectors/cast/writes.d.ts +9 -2
  14. package/dist/connectors/cast/writes.js +67 -13
  15. package/dist/connectors/daikin/index.d.ts +6 -2
  16. package/dist/connectors/daikin/index.js +21 -1
  17. package/dist/connectors/enphase/index.d.ts +5 -1
  18. package/dist/connectors/enphase/index.js +34 -2
  19. package/dist/connectors/hue/index.d.ts +6 -2
  20. package/dist/connectors/hue/index.js +39 -6
  21. package/dist/connectors/printers/index.d.ts +6 -2
  22. package/dist/connectors/printers/index.js +23 -0
  23. package/dist/connectors/spotify/changes.d.ts +1 -1
  24. package/dist/connectors/spotify/changes.js +4 -2
  25. package/dist/connectors/spotify/client.d.ts +1 -1
  26. package/dist/connectors/spotify/index.d.ts +9 -2
  27. package/dist/connectors/spotify/index.js +130 -45
  28. package/dist/connectors/up/index.d.ts +4 -2
  29. package/dist/connectors/up/index.js +3 -1
  30. package/dist/connectors/weather/bom.js +1 -0
  31. package/dist/connectors/weather/google.js +52 -9
  32. package/dist/connectors/weather/index.d.ts +2 -1
  33. package/dist/connectors/weather/index.js +7 -0
  34. package/dist/connectors/weather/source.d.ts +2 -0
  35. package/dist/devices.d.ts +27 -0
  36. package/dist/devices.js +61 -0
  37. package/dist/discover.d.ts +2 -0
  38. package/dist/discover.js +4 -1
  39. package/dist/health.d.ts +2 -1
  40. package/dist/health.js +2 -1
  41. package/dist/home.d.ts +41 -3
  42. package/dist/home.js +24 -7
  43. package/dist/index.d.ts +3 -2
  44. package/dist/index.js +2 -1
  45. package/dist/lane.js +24 -20
  46. package/docs/cast.md +1 -1
  47. package/docs/cli.md +3 -3
  48. package/docs/config.md +8 -3
  49. package/docs/decisions.md +5 -0
  50. package/docs/using.md +17 -0
  51. package/package.json +1 -1
  52. package/skills/use-home/SKILL.md +7 -7
  53. package/src/cli/cast.ts +19 -11
  54. package/src/cli/enphase.ts +5 -1
  55. package/src/cli/spotify.ts +2 -1
  56. package/src/cli/status.ts +26 -3
  57. package/src/clock.ts +1 -0
  58. package/src/config.ts +3 -1
  59. package/src/connector.ts +18 -2
  60. package/src/connectors/amber/index.ts +1 -0
  61. package/src/connectors/cast/README.md +10 -5
  62. package/src/connectors/cast/index.ts +74 -9
  63. package/src/connectors/cast/schemas.ts +1 -0
  64. package/src/connectors/cast/state.ts +189 -0
  65. package/src/connectors/cast/writes.ts +87 -17
  66. package/src/connectors/daikin/README.md +4 -0
  67. package/src/connectors/daikin/index.ts +41 -2
  68. package/src/connectors/enphase/index.ts +49 -2
  69. package/src/connectors/hue/index.ts +57 -8
  70. package/src/connectors/printers/README.md +4 -2
  71. package/src/connectors/printers/index.ts +43 -1
  72. package/src/connectors/spotify/README.md +33 -9
  73. package/src/connectors/spotify/changes.ts +4 -2
  74. package/src/connectors/spotify/client.ts +1 -1
  75. package/src/connectors/spotify/index.ts +157 -49
  76. package/src/connectors/up/README.md +1 -1
  77. package/src/connectors/up/index.ts +6 -2
  78. package/src/connectors/weather/README.md +1 -1
  79. package/src/connectors/weather/bom.ts +1 -0
  80. package/src/connectors/weather/google.ts +66 -10
  81. package/src/connectors/weather/index.ts +16 -2
  82. package/src/connectors/weather/source.ts +2 -0
  83. package/src/devices.ts +102 -0
  84. package/src/discover.ts +6 -2
  85. package/src/health.ts +4 -2
  86. package/src/home.ts +75 -8
  87. package/src/index.ts +5 -1
  88. package/src/lane.ts +23 -20
@@ -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
+ }
@@ -85,6 +85,7 @@ export type CachedAddress = {
85
85
  host: string;
86
86
  port?: number;
87
87
  name?: string;
88
+ model?: string;
88
89
  seenAt: string;
89
90
  };
90
91
  /**
@@ -111,6 +112,7 @@ export declare function addressBook(cache: AddressCache | undefined, options?: {
111
112
  host: string;
112
113
  port?: number;
113
114
  name?: string;
115
+ model?: string;
114
116
  }): void;
115
117
  entries: () => {
116
118
  [x: string]: CachedAddress;
package/dist/discover.js CHANGED
@@ -241,13 +241,16 @@ export function addressBook(cache, options = {}) {
241
241
  c.host === address.host &&
242
242
  c.port === address.port &&
243
243
  isFresh(c) &&
244
- (!address.name || address.name === c.name))
244
+ (!address.name || address.name === c.name) &&
245
+ (!address.model || address.model === c.model))
245
246
  return;
246
247
  const name = address.name || c?.name;
248
+ const model = address.model || c?.model;
247
249
  entries[key] = {
248
250
  host: address.host,
249
251
  ...(address.port === undefined ? {} : { port: address.port }),
250
252
  ...(name ? { name } : {}),
253
+ ...(model ? { model } : {}),
251
254
  seenAt: new Date(now()).toISOString(),
252
255
  };
253
256
  cache.write({ ...entries });
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";
package/dist/lane.js CHANGED
@@ -24,28 +24,32 @@ export function writeLane(options) {
24
24
  }
25
25
  async function run() {
26
26
  running = true;
27
- while (queue.length) {
28
- const now = clock.now();
29
- const next = queue.find((p) => readyAt(p.slot) <= now);
30
- if (!next) {
31
- wake = new AbortController();
32
- await clock.sleep(Math.min(...queue.map((p) => readyAt(p.slot))) - now, wake.signal);
33
- continue;
34
- }
35
- queue.splice(queue.indexOf(next), 1);
36
- lastAt = now;
37
- sent.set(next.slot, [...recent(next.slot), now]);
38
- try {
39
- const result = await next.write(next.body);
40
- for (const w of next.waiters)
41
- w.resolve(result);
42
- }
43
- catch (error) {
44
- for (const w of next.waiters)
45
- w.reject(error);
27
+ try {
28
+ while (queue.length) {
29
+ const now = clock.now();
30
+ const next = queue.find((p) => readyAt(p.slot) <= now);
31
+ if (!next) {
32
+ wake = new AbortController();
33
+ await clock.sleep(Math.min(...queue.map((p) => readyAt(p.slot))) - now, wake.signal);
34
+ continue;
35
+ }
36
+ queue.splice(queue.indexOf(next), 1);
37
+ lastAt = now;
38
+ sent.set(next.slot, [...recent(next.slot), now]);
39
+ try {
40
+ const result = await next.write(next.body);
41
+ for (const w of next.waiters)
42
+ w.resolve(result);
43
+ }
44
+ catch (error) {
45
+ for (const w of next.waiters)
46
+ w.reject(error);
47
+ }
46
48
  }
47
49
  }
48
- running = false;
50
+ finally {
51
+ running = false; // a throw here must not leave the lane stuck
52
+ }
49
53
  }
50
54
  return {
51
55
  send(slot, body, write, merge = (_, newer) => newer) {
package/docs/cast.md CHANGED
@@ -109,7 +109,7 @@ No host: a group's host changes with its leader. All entries fold into one `cast
109
109
  - `announce(target, clip, { volume?, timeoutMs? })`, target a device or group, `clip` a URL or bytes with their type (KO's own dictation as recorded, or TTS output; the library never synthesises) served by `serveClip`. The result names each member it could not restore. Read status, set volume, launch Default Media Receiver (`CC1AD845`), `LOAD` the clip, wait for `IDLE`/`FINISHED` (with a timeout), then restore: a prior Default Media Receiver `contentId` is re-loaded at its `currentTime`, an idle device gets `STOP`, then the volume. Spotify, YouTube and other apps cannot be resumed generically (our launch ends their session); the result says so.
110
110
  - `castUrl(name, url)` launches DashCast (`84912283`, pychromecast's list) with the URL. HA casts its dashboard with its own registered receiver (`A078F6B0`), a hint at DashCast's limits. KO's own receiver costs a one-off Cast SDK fee and must be published (unpublished ones run only on serial-registered devices). Try DashCast first.
111
111
 
112
- **TTS.** The library plays a URL, never synthesises. The speaker fetches it, so it must be HTTP on the LAN, not loopback, and in a format Default Media Receiver plays (MP3, AAC, WAV; not AIFF): `say -o msg.m4a --file-format=m4af --data-format=aac "…"` (m4af alone holds uncompressed PCM). Smallest server: a ~25-line `serveClip(bytes, type)` over `node:http` beside `tls.ts`, bound to the Mac's LAN address, random port, unguessable path, closed when the clip has played or after a minute (not after the first fetch, so a probe before it does no harm). The consumer runs `say`; `serveClip` picks the address on the target's network; the sidecar will raise macOS's local-network prompt once. A cloud voice later is MP3 bytes served the same way.
112
+ **TTS.** The library plays a URL, never synthesises. The speaker fetches it, so it must be HTTP on the LAN, not loopback, and in a format Default Media Receiver plays (MP3 or AAC; not AIFF; a 24 kHz mono WAV failed on audio-only Nest speakers, live 2026-11-09): `say -o msg.m4a --file-format=m4af --data-format=aac "…"` (m4af alone holds uncompressed PCM). Smallest server: a ~25-line `serveClip(bytes, type)` over `node:http` beside `tls.ts`, bound to the Mac's LAN address, random port, unguessable path, closed when the clip has played or after a minute (not after the first fetch, so a probe before it does no harm). The consumer runs `say`; `serveClip` picks the address on the target's network; the sidecar will raise macOS's local-network prompt once. A cloud voice later is MP3 bytes served the same way.
113
113
 
114
114
  **CLI.** `home status`: `cast 7 · Display A idle 47% · TV B standby · Whole house (3, led by Display A) idle`. `home probe`: this probe.
115
115
 
package/docs/cli.md CHANGED
@@ -15,18 +15,18 @@ home config check [path] # validate a home config: what it would set up, se
15
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
16
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
17
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)
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
+ home cast announce --to <name> [--to <name>…] (--say "<text>" | --file <path>) [--volume 0-100] # exit 1 if any didn't play; play a clip, then put back what was playing and the volume
19
19
  home cast volume <name> <0-100>
20
20
  home cast mute <name> on|off
21
21
  home cast stop <name> # end the running app; the device returns to its idle screen
22
- home spotify pause | play [link] | next | volume <0-100> # the Soloist speaker at SOLOIST_WS; volume held to SOLOIST_MAX_VOLUME; prints what Soloist then reported
22
+ home spotify activate | pause | play [link] | next | volume <0-100> # the Soloist speaker at SOLOIST_WS; volume held to SOLOIST_MAX_VOLUME; prints what Soloist then reported
23
23
  ```
24
24
 
25
25
  ## Output
26
26
 
27
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
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`.
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.
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`, `reason` (why it is `unavailable`, when known: `auth`, `config` or `crashed`), `anomaly`, then its state (the weather adds `line`, its text). Lists (`home up transactions`, `uncategorised`, `spend --by merchant`) are arrays, `[]` when empty.
30
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>\`)`.
31
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.
32
32
 
package/docs/config.md CHANGED
@@ -4,12 +4,17 @@ One JSONC file lays out the home, so the `home` command, jaldi and chota-bot rea
4
4
 
5
5
  ```ts
6
6
  import { readFileSync } from "node:fs";
7
+ import { homedir } from "node:os";
7
8
  import { dirname, resolve } from "node:path";
8
9
  import { createHome, loadHome, parseJsonc } from "@khalido/home";
9
10
  import { ftpFetch } from "@khalido/home/weather/ftp";
10
11
 
11
12
  const path = "/path/to/home.jsonc";
12
- const readFile = (p: string) => readFileSync(resolve(dirname(path), p), "utf8"); // paths in the config are relative to it
13
+ const readFile = (p: string) =>
14
+ readFileSync(
15
+ resolve(dirname(path), p.replace(/^~(?=\/)/, homedir())),
16
+ "utf8",
17
+ ); // relative to the config, or from ~/
13
18
  const home = createHome(
14
19
  loadHome(parseJsonc(readFileSync(path, "utf8")), {
15
20
  env: process.env,
@@ -22,12 +27,12 @@ const home = createHome(
22
27
  `loadHome` runs on Bun and Node. Hue and Enphase need a fetch that handles their certificates: Bun's `tls` fetch option by default, and under Node `tls: NODE_TLS` from `@khalido/home/node`. Under Node, a config with Hue or Enphase and no `tls` is a problem `loadHome` names, not a silent failure at the first read.
23
28
 
24
29
  - **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
- - **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.
30
+ - **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 or from `~/`, which the caller expands (`home discover --write` writes Enphase's token as `~/.config/home/enphase-token.json`); the library reads nothing.
26
31
  - **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
32
  - **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
33
  - **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
34
  - **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
- - **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).
35
+ - **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`; another consumer passes `FINDERS` and its 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
36
  - **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
37
  - `createHome` stays for code-first use.
33
38
 
package/docs/decisions.md CHANGED
@@ -148,3 +148,8 @@ KO's decisions on this package, newest block last. They override every other doc
148
148
  - **Signed out reads as waiting**, everywhere: `speakerState` says `waiting`, the detail says "waiting for someone to pick it", the write error's code is `waiting`.
149
149
  - **Stalls (Soloist issue #5) are noticed by default and repaired only when asked** (`repairStalls: true`): the repair is the library's first write nobody asked for, so a consumer opts in. Two signals, since the issue doesn't say whether the reported position freezes: a frozen position across fresh reports, and still playing well past the track's end, the symptom actually reported. One repair per track, never a loop. The nudges (seek, then pause and play; a skip past the end) are guesses until a stall is seen live.
150
150
  - **The project is home.ts; the npm package stays `@khalido/home`** (KO, 2026-11-08), so imports stay short (`@khalido/home/spotify`). It is home automation for TypeScript, starting with the devices in KO's house, and is meant to grow into more of the basics.
151
+ - **Spotify: inactive is refused, and activating is the caller's choice** (KO, 2026-11-09). Soloist accepts and drops `set_volume` while it isn't the active device. `setVolume` refuses with code `inactive`, and `activate()` is separate, because activating makes a new stream and can jump the volume to the level last saved. `duck` runs `fn` anyway, with `music: "untouched"`, because the announcement matters more. `play` goes active itself, since someone asking for music wants it to start; `resume` doesn't, since it worked inactive. The `maxVolume` cap says "lowered" only once Soloist reports it.
152
+
153
+ - **`Part.reason`, only on `unavailable`** (KO, 2026-11-09). An admin page had to match `detail` text like "pair again" to know a person must re-key. `reason` is `auth` (a key or token rejected or unusable), `config` (a setting or file is wrong) or `crashed` (a `run` threw), for code to switch on; `detail` stays the text for people. `disconnected` gets none: it already says unreachable. Additive, so a patch. `config` came a day later (KO, 2026-11-10), so "re-key", "fix a setting" and "report a bug" are three answers; `crashed` stays though nothing reads it yet. Weather has one too: each source says why it stopped (`stopReason`), the part is `auth` if any stopped source is, else `config`, and `state.sources.<name>.reason` shows a dead key while another source keeps the part `connected`.
154
+ - **Google's key goes in the `X-Goog-Api-Key` header, not the URL** (KO, 2026-11-10), so nothing that prints a URL can print it. Its rejection is read from the error body: a bad key and a bad latitude are both HTTP 400, and only the key's carries `API_KEY_INVALID` (live, 2026-11-10).
155
+ - **Every device answers the same few questions** (KO, 2026-11-10: "nice and consistent so we can easily and consistently add devices"). `DeviceRecord` is identity, availability, one word of state and one line of text, the same for a light, a speaker and a printer; `homeDevices(snapshot, config?)` lists them all. Everything particular to a kind stays in the connector's own typed row, carried as `detail`, so the common record never grows a field only one kind has. Three levels, each inside the last: the house list, the connector's typed row, the raw snapshot. Each connector exports its own typed function, listed by hand in `src/devices.ts`, and not a manifest field: `MANIFESTS` is untyped, and `detail` must narrow by `connector`. The list is read-only: a write means something different for a light, a speaker and an air conditioner, so there is no generic `turnOn`. Not done, for a 0.2.0: Hue's `reachable` becoming `available`, and its groups joining its lights in one call.
package/docs/using.md CHANGED
@@ -37,6 +37,23 @@ health(home.snapshot(), Date.now()); // per integration: ok, stale or down, with
37
37
 
38
38
  `createHome` stamps each part with `lastGoodAt` (its last good read) and `downSince` (when it stopped being connected). `health` judges from those: a polling connector is stale after 2½ polls with no good read and down after 3 polls not connected; Hue and Cast are down after 2 minutes not connected. Health takes each connector's interval from its manifest; a home that changes one (the weather's `pollMinutes`) passes it: `health(snapshot, now, { pollSeconds: { weather: 60 * 60 } })`. For a one-shot read (a script, a printed brief), `settle(integrations, { ms })` runs them until each has answered and returns the snapshot.
39
39
 
40
+ ## Every device, the same way
41
+
42
+ `homeDevices(home.snapshot(), config)` lists every device the home has: each Hue light, room and zone, the Daikin, the Enphase gateway, each Cast device and group, each printer and the Soloist speaker. Each answers the same questions in the same words: `connector`, `id`, `name`, `kind` (light, group, speaker, display, tv, ac, printer, solar; `cast` for a Cast model the library doesn't know), `available` (and when it is false, `reason`: `offline`, `auth`, `config` or `crashed`), `asOf` (when the device last reported or was last read; a quiet light can be hours old and current), `state` (one word: on, off, idle, busy, cool, printing), `text` (one line for people), `room` and, for a group, `members`. A field the device hasn't reported is absent. The config is optional: with it a device takes its entry's `room`, and a device with no name of its own (the Daikin, the gateway) its entry's `name`; a device's own name is never replaced. While a connector isn't connected none of its devices is `available`, and each keeps its last values.
43
+
44
+ To drill down, `detail` is the connector's own typed row, and `connector` (with `kind` for Hue) tells TypeScript which:
45
+
46
+ ```ts
47
+ for (const d of homeDevices(home.snapshot(), config)) {
48
+ console.log(d.text); // "Kitchen: on · 60%"
49
+ if (d.connector === "cast") d.detail.volume; // CastStatus
50
+ if (d.connector === "hue" && d.kind === "light") d.detail.brightness; // LightView
51
+ if (d.connector === "printers") d.detail.markers; // Printer
52
+ }
53
+ ```
54
+
55
+ Below that is the connector's snapshot, exactly as the device sent it (`home.part("hue")?.state`). One connector alone: `castHomeDevices(home.part("cast"))`, and likewise `hueHomeDevices`, `daikinHomeDevices`, `enphaseHomeDevices`, `printersHomeDevices`, `spotifyHomeDevices`. The list is for reading; a write goes to the device's connector, with `d.id` where it takes one.
56
+
40
57
  ## Writes
41
58
 
42
59
  Writes hang off the integration: `home.integration("daikin")?.client.write(setMode("cool"))`, `home.integration("hue")?.client.recallScene(id)`, `home.integration("cast")?.setVolume(…)`, `home.integration("spotify")?.play(link)`. Each goes through the device's write lane: spaced, and a burst to one light or the AC merges so the last value lands. Spotify's writes resolve with `{ state, confirmed }`, the state once Soloist reported the change, and `duck(fn, { to })` and `interrupt(fn)` turn the music down or pause it around an announcement, returning what happened to it (`music`: `untouched`, `restored`, `kept` or `failed`). `speakerState(part)` says the speaker in one word (offline, waiting, idle, playing, paused) and `spotifyChanges(change.before, change.after)` turns an `onChange` into events (started, resumed, paused, stopped, track, stalled, recovered, offline, waiting, picked), those from the library's own `duck` or `interrupt` marked `own`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@khalido/home",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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",
@@ -29,13 +29,13 @@ The contract (in `docs/cli.md`): stdout is data, stderr everything else. `--json
29
29
 
30
30
  What a status means:
31
31
 
32
- | Status | Meaning | What to do |
33
- | -------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------- |
34
- | `connected` | read and current | use it |
35
- | `connecting` | still starting (a 15 s cap in the CLI) | wait, or run again |
36
- | `disconnected` | the device or service did not answer; the last good state is kept, with its `asOf` | say so; check the device is on and at its address |
37
- | `unavailable` | stopped for good: a key rejected, a setting wrong | the `detail` names it; fix the key or setting |
38
- | `not set up` | env names or a file missing (`needs` lists them) | set them, or say what is needed |
32
+ | Status | Meaning | What to do |
33
+ | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
34
+ | `connected` | read and current | use it |
35
+ | `connecting` | still starting (a 15 s cap in the CLI) | wait, or run again |
36
+ | `disconnected` | the device or service did not answer; the last good state is kept, with its `asOf` | say so; check the device is on and at its address |
37
+ | `unavailable` | stopped for good: a key rejected, a setting wrong; `reason`, when present, is `auth` (a person must re-key), `config` (a setting or file is wrong) or `crashed` | the `detail` names it; fix the key or setting |
38
+ | `not set up` | env names or a file missing (`needs` lists them) | set them, or say what is needed |
39
39
 
40
40
  Weather: `blocks` holds morning, afternoon and evening for today and tomorrow in the home's time zone; `rainSoon` says when rain starts in the next two hours (needs the Google key). Energy: `now` in `home --json` is the one owner of solar, use, grid flow and cost per hour; never recompute it.
41
41
 
package/src/cli/cast.ts CHANGED
@@ -34,6 +34,7 @@ export function castNow() {
34
34
  {
35
35
  castId: key.slice(5),
36
36
  name: c.name ?? key.slice(5),
37
+ model: c.model,
37
38
  host: c.host,
38
39
  port: c.port,
39
40
  },
@@ -71,7 +72,7 @@ async function withCast<T>(
71
72
  }
72
73
 
73
74
  export const CAST_USAGE =
74
- 'usage: home cast announce --to <name> (--say "<text>" | --file <path>) [--volume 0-100] | volume <name> <0-100> | mute <name> on|off | stop <name>';
75
+ 'usage: home cast announce --to <name> [--to <name>…] (--say "<text>" | --file <path>) [--volume 0-100] | volume <name> <0-100> | mute <name> on|off | stop <name>';
75
76
 
76
77
  /** A 0–100 argument as the 0–1 a Cast device takes. */
77
78
  function percentLevel(value: string | undefined): number {
@@ -96,7 +97,7 @@ const AUDIO_TYPES: Record<string, string> = {
96
97
  export const CAST_OPTIONS: [string, string, { type: unknown[] }?][] = [
97
98
  [
98
99
  "--to <name>",
99
- "announce: the device or group, by name or castId",
100
+ "announce: the device or group, by name or castId; repeat for several",
100
101
  { type: [String] },
101
102
  ],
102
103
  ["--say <text>", "announce: words spoken by macOS `say`", { type: [String] }],
@@ -119,7 +120,8 @@ export const castVerbs: Verbs = {
119
120
  await withCast((c) => c.stop(name!));
120
121
  },
121
122
  async announce(_, o) {
122
- const [to, say, file] = [text(o.to), text(o.say), text(o.file)];
123
+ const [say, file] = [text(o.say), text(o.file)];
124
+ const to = [o.to].flat().filter(Boolean).map(String); // --to can repeat
123
125
  // cac gives "true" for a flag with no value: `--say` alone must not speak the word "true", so each flag needs a value after it.
124
126
  const bare = ["--to", "--say", "--file"].some((f) =>
125
127
  process.argv.some(
@@ -130,7 +132,7 @@ export const castVerbs: Verbs = {
130
132
  ),
131
133
  );
132
134
  if (bare) usage(CAST_USAGE);
133
- if (!to || !say === !file) usage(CAST_USAGE);
135
+ if (!to.length || !say === !file) usage(CAST_USAGE);
134
136
  const volume =
135
137
  o.volume === undefined ? undefined : percentLevel(text(o.volume));
136
138
  let type: string;
@@ -160,13 +162,19 @@ export const castVerbs: Verbs = {
160
162
  }
161
163
  type = "audio/mp4";
162
164
  }
163
- const r = await withCast((c) =>
164
- c.announce(to!, { bytes, type }, { volume }),
165
+ const results = await withCast((c) =>
166
+ c.announceAll(to, { bytes, type }, { volume }),
165
167
  );
166
- if (o.json) return printJson(r);
167
- console.log(r.played ? "played" : `not played: ${r.why}`);
168
- if (r.restored.length) console.log(`restored: ${r.restored.join(", ")}`);
169
- for (const n of r.notRestored)
170
- console.log(`not restored: ${n.name}: ${n.why}`);
168
+ if (results.some((r) => !r.played)) process.exitCode = 1; // docs/cli.md: 1 is a failure, so a script can tell
169
+ if (o.json) return printJson(to.length === 1 ? results[0] : results);
170
+ const named = results.length > 1;
171
+ for (const r of results) {
172
+ const who = named ? `${r.target}: ` : "";
173
+ console.log(`${who}${r.played ? "played" : `not played: ${r.why}`}`);
174
+ if (r.restored.length)
175
+ console.log(`${who}restored: ${r.restored.join(", ")}`);
176
+ for (const n of r.notRestored)
177
+ console.log(`${who}not restored: ${n.name}: ${n.why}`);
178
+ }
171
179
  },
172
180
  };
@@ -23,7 +23,11 @@ export function enphaseToken(): { token: string; expiresAt: Date } | undefined {
23
23
  const { token } = JSON.parse(text) as { token: string };
24
24
  return { token, expiresAt: tokenExpiry(token) };
25
25
  } catch (error) {
26
- return fail(`${TOKEN_FILE}: ${(error as Error).message}`);
26
+ return fail(
27
+ `${TOKEN_FILE}: ${(error as Error).message}`,
28
+ "auth",
29
+ "home enphase login",
30
+ );
27
31
  }
28
32
  }
29
33
 
@@ -31,7 +31,7 @@ export const spotifyFromEnv = () =>
31
31
  });
32
32
 
33
33
  export const SPOTIFY_USAGE =
34
- "usage: home spotify pause | play [link] | next | volume <0-100>";
34
+ "usage: home spotify activate | pause | play [link] | next | volume <0-100>";
35
35
 
36
36
  async function withSpotify<T>(
37
37
  act: (s: ReturnType<typeof spotify>) => Promise<T>,
@@ -64,6 +64,7 @@ const said = ({ state, confirmed }: Applied) =>
64
64
  );
65
65
 
66
66
  export const spotifyVerbs: Verbs = {
67
+ activate: () => withSpotify((s) => s.activate()).then(said),
67
68
  pause: () => withSpotify((s) => s.pause()).then(said),
68
69
  next: () => withSpotify((s) => s.next()).then(said),
69
70
  play: ([link]) => withSpotify((s) => s.play(link)).then(said),