@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
@@ -2,15 +2,15 @@ import { errorText } from "../../connector.js";
2
2
  import { writeLane } from "../../lane.js";
3
3
  import { NS } from "./codec.js";
4
4
  import { MediaStatus, MultizoneStatus, ReceiverStatus, } from "./schemas.js";
5
- import { castKey } from "./state.js";
5
+ import { castKey, DEFAULT_MEDIA_RECEIVER, findCast, } from "./state.js";
6
6
  // Writes over the sockets `cast()` already holds: volume, mute, stop, and an announcement that plays a clip and puts
7
- // the device back as it was. Never called by the integration itself; tried on a scripted fake socket, not yet live.
8
- /** Google's own player for a URL of audio or video. */
9
- export const DEFAULT_MEDIA_RECEIVER = "CC1AD845";
7
+ // the device back as it was. Never called by the integration itself; announce, volume and mute have run on real devices (the README has the dates).
10
8
  /** Each request (a status, a volume, LAUNCH, LOAD, STOP) gets this long to answer. */
11
9
  export const ANSWER_MS = 10_000;
12
10
  /** How long an announcement waits for its clip to finish, beyond the clip's own length when the device reports it. */
13
11
  export const PLAY_MS = 60_000;
12
+ /** How long a clip gets to start playing. A clip a speaker can't play took 35–44 s to say so (live, 2026-11-09); good ones start within a few seconds. A stated guess. */
13
+ export const START_MS = 15_000;
14
14
  const RECEIVER = "receiver-0";
15
15
  /** Level writes, and mute writes, per device or group in any minute; more wait their turn, the newest winning. A guess: Cast publishes no limit. */
16
16
  export const VOLUME_PER_MINUTE = 30;
@@ -33,6 +33,7 @@ function checkLevel(level) {
33
33
  * aborts, or after `ms` (a function is read again each time it would expire, so a deadline can grow).
34
34
  */
35
35
  function waitFor(clock, s, ms, what, pick, signal) {
36
+ const name = () => (typeof what === "string" ? what : what());
36
37
  return new Promise((resolve, reject) => {
37
38
  const done = new AbortController();
38
39
  const finish = (settle) => done.signal.aborted ||
@@ -40,10 +41,10 @@ function waitFor(clock, s, ms, what, pick, signal) {
40
41
  signal?.removeEventListener("abort", cancel),
41
42
  done.abort(),
42
43
  settle());
43
- const cancel = () => finish(() => reject(new Error(`stopped waiting for ${what}`)));
44
+ const cancel = () => finish(() => reject(new Error(`stopped waiting for ${name()}`)));
44
45
  const off = s.listen((raw) => {
45
46
  if (raw === undefined)
46
- return finish(() => reject(new Error(`the socket closed waiting for ${what}`)));
47
+ return finish(() => reject(new Error(`the socket closed waiting for ${name()}`)));
47
48
  try {
48
49
  const value = pick(raw);
49
50
  if (value !== undefined)
@@ -59,7 +60,7 @@ function waitFor(clock, s, ms, what, pick, signal) {
59
60
  void (async () => {
60
61
  for (let left = limit(); left > 0 && !done.signal.aborted; left = start + limit() - clock.now())
61
62
  await clock.sleep(left, done.signal);
62
- finish(() => reject(new Error(`no ${what} in ${Math.round(limit() / 1000)} s`)));
63
+ finish(() => reject(new Error(`no ${name()} in ${Math.round(limit() / 1000)} s`)));
63
64
  })();
64
65
  });
65
66
  }
@@ -80,8 +81,7 @@ export function castActions(ctx) {
80
81
  const { clock } = ctx;
81
82
  const find = (target) => {
82
83
  const all = Object.values(ctx.devices());
83
- const d = all.find((x) => x.id === castKey(target)) ??
84
- all.find((x) => x.name.toLowerCase() === target.toLowerCase());
84
+ const d = findCast(ctx.devices(), target);
85
85
  if (!d)
86
86
  throw new Error(`no Cast device or group "${target}"; have: ${all
87
87
  .map((x) => x.name)
@@ -171,8 +171,11 @@ export function castActions(ctx) {
171
171
  const loadId = s.nextId();
172
172
  let session;
173
173
  let durationS = 0;
174
+ let started = false; // it has reached PLAYING: BUFFERING is where a clip it can't play sits
174
175
  const cancel = new AbortController();
175
- const finished = waitFor(clock, s, () => timeoutMs ?? PLAY_MS + durationS * 1000, "end of the clip", (raw) => {
176
+ const finished = waitFor(clock, s, () => timeoutMs ?? (started ? PLAY_MS + durationS * 1000 : START_MS), () => started
177
+ ? "end of the clip"
178
+ : `start of the clip (${media.contentType}, which this device may not play)`, (raw) => {
176
179
  const r = MediaStatus.safeParse(raw);
177
180
  if (!r.success)
178
181
  return undefined;
@@ -187,11 +190,13 @@ export function castActions(ctx) {
187
190
  continue;
188
191
  if (typeof m.media?.duration === "number")
189
192
  durationS = m.media.duration;
193
+ if (m.playerState === "PLAYING")
194
+ started = true;
190
195
  if (m.playerState !== "IDLE" || !m.idleReason)
191
196
  continue;
192
197
  if (m.idleReason === "FINISHED")
193
198
  return true;
194
- throw new Error(`the clip stopped: ${m.idleReason}`);
199
+ throw new Error(`the clip stopped: ${m.idleReason}${m.detailedErrorCode === undefined ? "" : ` (detailed error ${m.detailedErrorCode})`}${m.idleReason === "ERROR" ? `; this device may not play ${media.contentType}` : ""}`);
195
200
  }
196
201
  return undefined;
197
202
  }, cancel.signal);
@@ -216,7 +221,7 @@ export function castActions(ctx) {
216
221
  .get(id)
217
222
  .send("level" in volume ? "level" : "muted", volume, (v) => receiver(live(find(target)), { type: "SET_VOLUME", volume: v }));
218
223
  };
219
- return {
224
+ const actions = {
220
225
  async setVolume(target, level) {
221
226
  checkLevel(level);
222
227
  await setVolume(target, { level });
@@ -236,7 +241,7 @@ export function castActions(ctx) {
236
241
  /**
237
242
  * Read the target's state, set the volume if asked and unmute a muted target, launch the Default Media Receiver, play the clip to its end, then
238
243
  * put back what it can, always: what the Default Media Receiver was playing is loaded again where it was, an idle
239
- * device gets STOP, then the volume (a group's, member by member through each member's own socket). Another app
244
+ * device gets STOP, then the volume (a group's own first, then member by member through each member's own socket). Another app
240
245
  * (Spotify, YouTube) is interrupted and cannot be resumed from here; the result says so. Throws only before anything changed.
241
246
  */
242
247
  async announce(target, clip, options = {}) {
@@ -352,6 +357,27 @@ export function castActions(ctx) {
352
357
  muted: before.volume?.muted,
353
358
  },
354
359
  ];
360
+ // A group keeps a level of its own, which its members' levels don't bring back (live, 2026-11-10: it stayed at the
361
+ // announcement's 30 with every member restored). First, so whatever it does to the members, their own levels land last.
362
+ if (d.kind === "group" && options.volume !== undefined) {
363
+ const level = before.volume?.level;
364
+ if (level === undefined)
365
+ result.notRestored.push({
366
+ name: d.name,
367
+ why: "its volume before was not reported",
368
+ });
369
+ else
370
+ try {
371
+ await receiver(s, { type: "SET_VOLUME", volume: { level } });
372
+ result.restored.push(`${d.name}: volume`);
373
+ }
374
+ catch (error) {
375
+ result.notRestored.push({
376
+ name: d.name,
377
+ why: `volume not restored: ${errorText(error)}`,
378
+ });
379
+ }
380
+ }
355
381
  for (const m of each) {
356
382
  const ms = m.id === d.id ? s : ctx.session(m.id);
357
383
  if (m.level === undefined)
@@ -386,5 +412,33 @@ export function castActions(ctx) {
386
412
  }
387
413
  }
388
414
  },
415
+ async announceAll(targets, clip, options = {}) {
416
+ // A caller's mistake is one error, not a failure per speaker.
417
+ if (options.volume !== undefined)
418
+ checkLevel(options.volume);
419
+ if (!("url" in clip) && !ctx.serve)
420
+ throw new Error("a clip of bytes needs serve: serveClip from @khalido/home/cast/serve");
421
+ const id = (t) => {
422
+ try {
423
+ return find(t).id;
424
+ }
425
+ catch {
426
+ return t; // unknown: announce says so, as this target's own failure
427
+ }
428
+ };
429
+ const once = targets.filter((t, i) => targets.findIndex((o) => id(o) === id(t)) === i); // one device twice would save the first's volume as the one to restore
430
+ return Promise.all(once.map(async (target) => ({
431
+ target,
432
+ ...(await actions
433
+ .announce(target, clip, options)
434
+ .catch((error) => ({
435
+ played: false,
436
+ why: errorText(error),
437
+ restored: [],
438
+ notRestored: [],
439
+ }))),
440
+ })));
441
+ },
389
442
  };
443
+ return actions;
390
444
  }
@@ -1,9 +1,9 @@
1
1
  import { type Clock } from "../../clock.ts";
2
2
  import { z } from "zod";
3
- import type { Integration } from "../../home.ts";
3
+ import { type DeviceRecord, type Integration, type Part } from "../../home.ts";
4
4
  import { type Finder } from "../../discover.ts";
5
5
  import type { Manifest } from "../../manifest.ts";
6
- import { daikinClient, type DaikinState } from "./client.ts";
6
+ import { daikinClient, type DaikinState, type Mode } from "./client.ts";
7
7
  export { DaikinError, type DaikinState, daikinClient, type Fan, type Mode, setFan, setMode, setPower, setTarget, type WriteRequest, } from "./client.ts";
8
8
  /** Poll every 120 s, as Home Assistant's daikin integration does (its coordinator's update_interval). */
9
9
  export declare const POLL_MS = 120000;
@@ -39,3 +39,7 @@ export declare function daikin(config: DaikinConfig): Integration<DaikinSnapshot
39
39
  /** The AC in a few words: "off · cool 25 °C · room 24 °C · outdoor unit 19.5 °C". */
40
40
  export declare function acText(s: DaikinState): string;
41
41
  export declare const manifest: Manifest<DaikinSnapshot>;
42
+ /** The AC as a home device: `state` is `off`, its mode while on, or `on` when it hasn't said which; `detail` its snapshot. */
43
+ export type DaikinHomeDevice = DeviceRecord<"daikin", "ac", "off" | "on" | Mode, DaikinSnapshot>;
44
+ /** The one unit, once it has been read. It reports no name or id of its own: a home config's entry names and places it. */
45
+ export declare function daikinHomeDevices(part: Part<DaikinSnapshot> | undefined): DaikinHomeDevice[];
@@ -1,8 +1,9 @@
1
1
  import { asOf, realClock } from "../../clock.js";
2
2
  import { z } from "zod";
3
+ import { deviceRecords, } from "../../home.js";
3
4
  import { lostDetail, relocator } from "../../discover.js";
4
5
  import { poller } from "../../poll.js";
5
- import { daikinClient, DaikinError } from "./client.js";
6
+ import { daikinClient, DaikinError, } from "./client.js";
6
7
  export { DaikinError, daikinClient, setFan, setMode, setPower, setTarget, } from "./client.js";
7
8
  /** Poll every 120 s, as Home Assistant's daikin integration does (its coordinator's update_interval). */
8
9
  export const POLL_MS = 120_000;
@@ -106,3 +107,22 @@ export const manifest = {
106
107
  ],
107
108
  lines: ({ state }) => (state ? [acText(state)] : []),
108
109
  };
110
+ /** The one unit, once it has been read. It reports no name or id of its own: a home config's entry names and places it. */
111
+ export function daikinHomeDevices(part) {
112
+ const s = part?.state;
113
+ if (!s)
114
+ return [];
115
+ return deviceRecords(part, [
116
+ {
117
+ connector: "daikin",
118
+ id: "daikin",
119
+ name: manifest.title,
120
+ kind: "ac",
121
+ available: true,
122
+ ...(s.on === undefined ? {} : { state: s.on ? (s.mode ?? "on") : "off" }),
123
+ asOf: s.asOf,
124
+ text: `${manifest.title}: ${acText(s)}`,
125
+ detail: s,
126
+ },
127
+ ]);
128
+ }
@@ -1,6 +1,6 @@
1
1
  import { type Clock } from "../../clock.ts";
2
2
  import { z } from "zod";
3
- import { type Integration } from "../../home.ts";
3
+ import { type DeviceRecord, type Integration, type Part } from "../../home.ts";
4
4
  import { type Finder } from "../../discover.ts";
5
5
  import { type Manifest } from "../../manifest.ts";
6
6
  import { type EnphaseState } from "./client.ts";
@@ -41,3 +41,7 @@ export declare function enphase(config: EnphaseConfig): Integration<EnphaseSnaps
41
41
  /** "solar 2.1 kW, using 0.6 kW · token 300 days left". The login ask near the end is the integration's, in `detail`. */
42
42
  export declare function enphaseText(s: EnphaseSnapshot): string;
43
43
  export declare const manifest: Manifest<EnphaseSnapshot>;
44
+ /** The gateway as a home device: `state` is `producing` while the solar makes power, else `idle`; `detail` its snapshot. */
45
+ export type EnphaseHomeDevice = DeviceRecord<"enphase", "solar", "producing" | "idle", EnphaseSnapshot>;
46
+ /** The one gateway, once it has been read. */
47
+ export declare function enphaseHomeDevices(part: Part<EnphaseSnapshot> | undefined): EnphaseHomeDevice[];
@@ -1,6 +1,6 @@
1
1
  import { localDay, realClock } from "../../clock.js";
2
2
  import { z } from "zod";
3
- import { filePath } from "../../home.js";
3
+ import { deviceRecords, filePath, } from "../../home.js";
4
4
  import { lostDetail, relocator } from "../../discover.js";
5
5
  import { kw } from "../../manifest.js";
6
6
  import { poller } from "../../poll.js";
@@ -51,7 +51,17 @@ export function enphase(config) {
51
51
  name: "enphase",
52
52
  refresh: () => (where.reset(), poll.nudge()),
53
53
  async run(signal, update) {
54
- const expiresAt = tokenExpiry(config.token); // a bad token fails this integration only
54
+ let expiresAt;
55
+ try {
56
+ expiresAt = tokenExpiry(config.token); // a bad token fails this integration only
57
+ }
58
+ catch {
59
+ return update({
60
+ status: "unavailable",
61
+ reason: "auth",
62
+ detail: "the token is unreadable; run `home enphase login`",
63
+ });
64
+ }
55
65
  let last;
56
66
  let day;
57
67
  let moved; // kept in `detail` so a consumer can persist the new address
@@ -106,6 +116,7 @@ export function enphase(config) {
106
116
  if (error instanceof EnphaseError && error.status === 401)
107
117
  return update({
108
118
  status: "unavailable",
119
+ reason: "auth",
109
120
  detail: "the gateway rejected the token; run `home enphase login`",
110
121
  state: last,
111
122
  });
@@ -172,3 +183,24 @@ export const manifest = {
172
183
  files: ["token"],
173
184
  lines: ({ state }) => (state ? [enphaseText(state)] : []),
174
185
  };
186
+ /** The one gateway, once it has been read. */
187
+ export function enphaseHomeDevices(part) {
188
+ const s = part?.state;
189
+ if (!s)
190
+ return [];
191
+ return deviceRecords(part, [
192
+ {
193
+ connector: "enphase",
194
+ id: "enphase",
195
+ name: manifest.title,
196
+ kind: "solar",
197
+ available: true,
198
+ ...(s.productionW === undefined
199
+ ? {}
200
+ : { state: s.productionW > 0 ? "producing" : "idle" }),
201
+ asOf: s.asOf,
202
+ text: `${manifest.title}: ${enphaseText(s)}`,
203
+ detail: s,
204
+ },
205
+ ]);
206
+ }
@@ -1,10 +1,10 @@
1
1
  import { type Clock } from "../../clock.ts";
2
2
  import { z } from "zod";
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
6
  import { type HueClient } from "./client.ts";
7
- import { type HueSnapshot } from "./state.ts";
7
+ import { type GroupView, type HueSnapshot, type LightView } from "./state.ts";
8
8
  export { bunPinnedFetch, type HueClient, HueError, type HueOptions, hueClient, pair, } from "./client.ts";
9
9
  export type { HueEvent, Resource } from "./schemas.ts";
10
10
  export { type GroupView, groups, type HueSnapshot, homeGroupedLightId, type LightView, lights, scenes, } from "./state.ts";
@@ -39,4 +39,8 @@ export type HueConfig = {
39
39
  export declare function hue(config: HueConfig): Integration<HueSnapshot, "hue"> & {
40
40
  readonly client: HueClient;
41
41
  };
42
+ /** A Hue light, or a room or zone, as a home device: `detail` is its `LightView` or `GroupView`. */
43
+ export type HueHomeDevice = DeviceRecord<"hue", "light", "on" | "off", LightView> | DeviceRecord<"hue", "group", "on" | "off", GroupView>;
44
+ /** Every light, then every room and zone. A light's `available` is its Zigbee link; a group's `members` are its lights. */
45
+ export declare function hueHomeDevices(part: Part<HueSnapshot> | undefined): HueHomeDevice[];
42
46
  export declare const manifest: Manifest<HueSnapshot>;
@@ -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
+ }
@@ -24,7 +24,7 @@ export type SpotifyEvent = {
24
24
  /**
25
25
  * The events between two reports, for `onChange`'s `before` and `after`: `offline` and `online`; `waiting` (signed out) and
26
26
  * `picked` (someone picked the speaker); `started` (from idle), `resumed` (from paused), `paused`, `stopped` (to idle), then
27
- * `track` for a new track, so "started" and the track come as a pair, then `stalled` and `recovered` (Soloist issue #5). Buffering counts as playing. No `before`, or one
27
+ * `track` for a new track, once it has a name, then `stalled` and `recovered` (Soloist issue #5). Buffering counts as playing. No `before`, or one
28
28
  * with no state (createHome's placeholder before the first report, as at a restart): nothing, since nothing was seen to change. No volume or queue events: read the state.
29
29
  */
30
30
  export declare function spotifyChanges(before: Part<SpotifyState> | undefined, after: Part<SpotifyState> | undefined): SpotifyEvent[];
@@ -22,7 +22,7 @@ const PLAYING = new Set(["playing", "buffering"]);
22
22
  /**
23
23
  * The events between two reports, for `onChange`'s `before` and `after`: `offline` and `online`; `waiting` (signed out) and
24
24
  * `picked` (someone picked the speaker); `started` (from idle), `resumed` (from paused), `paused`, `stopped` (to idle), then
25
- * `track` for a new track, so "started" and the track come as a pair, then `stalled` and `recovered` (Soloist issue #5). Buffering counts as playing. No `before`, or one
25
+ * `track` for a new track, once it has a name, then `stalled` and `recovered` (Soloist issue #5). Buffering counts as playing. No `before`, or one
26
26
  * with no state (createHome's placeholder before the first report, as at a restart): nothing, since nothing was seen to change. No volume or queue events: read the state.
27
27
  */
28
28
  export function spotifyChanges(before, after) {
@@ -55,7 +55,9 @@ export function spotifyChanges(before, after) {
55
55
  add({ type: "paused" });
56
56
  if (to === "idle" && from !== undefined && from !== "idle")
57
57
  add({ type: "stopped" });
58
- if (a?.track && a.track.uri !== b?.track?.uri)
58
+ // Soloist's first report of a new track can have no name yet (seen live): the event waits for it.
59
+ const named = (p) => !!p?.track?.name;
60
+ if (a?.track && named(a) && (a.track.uri !== b?.track?.uri || !named(b)))
59
61
  add({ type: "track", track: a.track });
60
62
  if (a?.stalled && !b?.stalled)
61
63
  events.push({ type: "stalled", stall: a.stalled });
@@ -158,7 +158,7 @@ export type SpotifyState = {
158
158
  name?: string;
159
159
  };
160
160
  position?: PositionAnchor;
161
- /** 0–100, as Soloist reports it. */
161
+ /** 0–100, as Soloist reports it, rounded. Until `active` it isn't the level that will play (it reads `--initial-volume`, seen live). */
162
162
  volume?: number;
163
163
  shuffle?: boolean;
164
164
  /** off, context or track. */
@@ -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";
@@ -49,9 +50,11 @@ export type Aside<T> = {
49
50
  /**
50
51
  * The writes, on the object `spotify()` returns. Each resolves once Soloist reports the change it asked for, with the state
51
52
  * then (`confirmed: true`), or after `CONFIRM_MS` with the state as it stands (`confirmed: false`). Each rejects with a
52
- * `ConnectorError` whose `code` is `offline`, `waiting` (nobody has picked the speaker), `refused`, `timeout` or `invalid`.
53
+ * `ConnectorError` whose `code` is `offline`, `waiting` (nobody has picked the speaker), `inactive` (`setVolume` on a speaker that isn't the active device: `activate()` first), `refused`, `timeout` or `invalid`.
53
54
  */
54
55
  export type SpotifyActions = {
56
+ /** Make this the account's active Spotify device. Soloist drops volume changes until it is. It makes a new audio stream, so the volume can jump to the level last saved. */
57
+ activate(): Promise<Applied>;
55
58
  /** Play a Spotify link or URI (track, album, playlist, artist, episode, show), or resume what is loaded when none is given. */
56
59
  play(linkOrUri?: string): Promise<Applied>;
57
60
  pause(): Promise<Applied>;
@@ -90,3 +93,7 @@ export type SpotifyActions = {
90
93
  */
91
94
  export declare function spotify(config?: SpotifyConfig): Integration<SpotifyState, "spotify"> & SpotifyActions;
92
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[];