@khalido/home 0.1.3 → 0.2.0

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 (84) hide show
  1. package/.env.example +2 -2
  2. package/CHANGELOG.md +41 -0
  3. package/README.md +7 -2
  4. package/dist/config.d.ts +5 -3
  5. package/dist/config.js +5 -1
  6. package/dist/connectors/cast/index.d.ts +1 -1
  7. package/dist/connectors/cast/index.js +1 -1
  8. package/dist/connectors/cast/state.d.ts +17 -3
  9. package/dist/connectors/cast/state.js +26 -7
  10. package/dist/connectors/daikin/client.d.ts +45 -2
  11. package/dist/connectors/daikin/client.js +67 -2
  12. package/dist/connectors/daikin/index.d.ts +2 -2
  13. package/dist/connectors/daikin/index.js +4 -3
  14. package/dist/connectors/hue/ca.d.ts +2 -0
  15. package/dist/connectors/hue/ca.js +38 -0
  16. package/dist/connectors/hue/client.d.ts +81 -6
  17. package/dist/connectors/hue/client.js +217 -6
  18. package/dist/connectors/hue/color.d.ts +30 -0
  19. package/dist/connectors/hue/color.js +92 -0
  20. package/dist/connectors/hue/index.d.ts +15 -4
  21. package/dist/connectors/hue/index.js +25 -8
  22. package/dist/connectors/hue/schemas.d.ts +27 -0
  23. package/dist/connectors/hue/schemas.js +26 -0
  24. package/dist/connectors/hue/state.d.ts +10 -1
  25. package/dist/connectors/hue/state.js +20 -1
  26. package/dist/connectors/spotify/changes.d.ts +19 -5
  27. package/dist/connectors/spotify/changes.js +29 -7
  28. package/dist/connectors/spotify/index.d.ts +1 -1
  29. package/dist/connectors/spotify/index.js +8 -2
  30. package/dist/devices.d.ts +24 -0
  31. package/dist/devices.js +56 -2
  32. package/dist/discover.js +1 -1
  33. package/dist/finders-entry.d.ts +1 -1
  34. package/dist/finders-entry.js +1 -1
  35. package/dist/finders.d.ts +2 -1
  36. package/dist/finders.js +16 -5
  37. package/dist/home.d.ts +11 -2
  38. package/dist/home.js +98 -34
  39. package/dist/index.d.ts +1 -1
  40. package/dist/index.js +1 -1
  41. package/dist/node-fetch.d.ts +2 -2
  42. package/dist/node-fetch.js +3 -2
  43. package/docs/cli.md +13 -7
  44. package/docs/config.md +11 -3
  45. package/docs/decisions.md +12 -0
  46. package/docs/hue.md +161 -0
  47. package/docs/using.md +98 -3
  48. package/examples/home.jsonc +2 -1
  49. package/home.schema.json +10 -3
  50. package/llms.txt +19 -6
  51. package/package.json +1 -1
  52. package/skills/draw-home/SKILL.md +1 -1
  53. package/skills/use-home/SKILL.md +3 -3
  54. package/src/cli/cast.ts +51 -5
  55. package/src/cli/context.ts +2 -0
  56. package/src/cli/daikin.ts +71 -0
  57. package/src/cli/hue.ts +147 -59
  58. package/src/cli/main.ts +35 -10
  59. package/src/cli/probe.ts +21 -17
  60. package/src/cli/status.ts +61 -10
  61. package/src/config.ts +8 -2
  62. package/src/connectors/cast/README.md +6 -3
  63. package/src/connectors/cast/index.ts +7 -1
  64. package/src/connectors/cast/state.ts +32 -5
  65. package/src/connectors/daikin/README.md +16 -4
  66. package/src/connectors/daikin/client.ts +104 -3
  67. package/src/connectors/daikin/index.ts +8 -2
  68. package/src/connectors/hue/README.md +62 -17
  69. package/src/connectors/hue/ca.ts +39 -0
  70. package/src/connectors/hue/client.ts +362 -15
  71. package/src/connectors/hue/color.ts +123 -0
  72. package/src/connectors/hue/index.ts +44 -11
  73. package/src/connectors/hue/schemas.ts +26 -0
  74. package/src/connectors/hue/state.ts +27 -1
  75. package/src/connectors/spotify/README.md +8 -2
  76. package/src/connectors/spotify/changes.ts +38 -9
  77. package/src/connectors/spotify/index.ts +9 -1
  78. package/src/devices.ts +83 -3
  79. package/src/discover.ts +1 -2
  80. package/src/finders-entry.ts +1 -1
  81. package/src/finders.ts +18 -6
  82. package/src/home.ts +99 -39
  83. package/src/index.ts +7 -1
  84. package/src/node-fetch.ts +6 -2
@@ -19,6 +19,33 @@ export declare const Resource: z.ZodObject<{
19
19
  dimming: z.ZodOptional<z.ZodObject<{
20
20
  brightness: z.ZodNumber;
21
21
  }, z.core.$loose>>;
22
+ color: z.ZodCatch<z.ZodOptional<z.ZodObject<{
23
+ xy: z.ZodCatch<z.ZodOptional<z.ZodObject<{
24
+ x: z.ZodNumber;
25
+ y: z.ZodNumber;
26
+ }, z.core.$loose>>>;
27
+ gamut: z.ZodCatch<z.ZodOptional<z.ZodObject<{
28
+ red: z.ZodObject<{
29
+ x: z.ZodNumber;
30
+ y: z.ZodNumber;
31
+ }, z.core.$loose>;
32
+ green: z.ZodObject<{
33
+ x: z.ZodNumber;
34
+ y: z.ZodNumber;
35
+ }, z.core.$loose>;
36
+ blue: z.ZodObject<{
37
+ x: z.ZodNumber;
38
+ y: z.ZodNumber;
39
+ }, z.core.$loose>;
40
+ }, z.core.$loose>>>;
41
+ }, z.core.$loose>>>;
42
+ color_temperature: z.ZodCatch<z.ZodOptional<z.ZodObject<{
43
+ mirek: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
44
+ mirek_schema: z.ZodOptional<z.ZodObject<{
45
+ mirek_minimum: z.ZodOptional<z.ZodNumber>;
46
+ mirek_maximum: z.ZodOptional<z.ZodNumber>;
47
+ }, z.core.$loose>>;
48
+ }, z.core.$loose>>>;
22
49
  children: z.ZodOptional<z.ZodArray<z.ZodObject<{
23
50
  rid: z.ZodString;
24
51
  rtype: z.ZodString;
@@ -4,6 +4,7 @@ import { z } from "zod";
4
4
  // field is unconfirmed from the primary source. Only the fields the snapshot reads are parsed;
5
5
  // all are optional and unknown fields pass through, so a schema change degrades, never throws.
6
6
  const ref = z.object({ rid: z.string(), rtype: z.string() });
7
+ const xy = z.looseObject({ x: z.number(), y: z.number() });
7
8
  /** The resource kinds this library reads. */
8
9
  export const KINDS = [
9
10
  "light",
@@ -22,6 +23,31 @@ export const Resource = z.looseObject({
22
23
  metadata: z.looseObject({ name: z.string().optional() }).optional(),
23
24
  on: z.looseObject({ on: z.boolean() }).optional(),
24
25
  dimming: z.looseObject({ brightness: z.number() }).optional(),
26
+ // A light's colour and the triangle of colours it can show; absent on a white light. A shape this doesn't expect
27
+ // reads as absent, so one odd light never fails the whole read.
28
+ color: z
29
+ .looseObject({
30
+ xy: xy.optional().catch(undefined),
31
+ gamut: z
32
+ .looseObject({ red: xy, green: xy, blue: xy })
33
+ .optional()
34
+ .catch(undefined),
35
+ })
36
+ .optional()
37
+ .catch(undefined),
38
+ // mirek is null while the light shows a colour; the schema is this light's own range.
39
+ color_temperature: z
40
+ .looseObject({
41
+ mirek: z.number().nullable().optional(),
42
+ mirek_schema: z
43
+ .looseObject({
44
+ mirek_minimum: z.number().optional(),
45
+ mirek_maximum: z.number().optional(),
46
+ })
47
+ .optional(),
48
+ })
49
+ .optional()
50
+ .catch(undefined),
25
51
  children: z.array(ref).optional(),
26
52
  services: z.array(ref).optional(),
27
53
  group: ref.optional(), // scene: the room or zone it belongs to
@@ -1,3 +1,4 @@
1
+ import { type Gamut, type XY } from "./color.ts";
1
2
  import type { HueEvent, Resource } from "./schemas.ts";
2
3
  /** Every resource on the bridge by id, from the last full read plus every event since. `asOf` is local time it was last known fresh. */
3
4
  export type HueSnapshot = {
@@ -17,8 +18,16 @@ export type LightView = {
17
18
  on?: boolean;
18
19
  brightness?: number;
19
20
  reachable?: boolean;
21
+ /** White temperature in kelvin, while the light shows a white; its range is `kelvinRange` (warmest, coolest). */
22
+ kelvin?: number;
23
+ kelvinRange?: [number, number];
24
+ /** The colour it shows: the bridge's `xy`, and `hex` for a screen (the hue at full brightness). Colour lights only. */
25
+ xy?: XY;
26
+ hex?: string;
27
+ /** The colours it can show. Present on a colour light, so its presence says "takes a colour". */
28
+ gamut?: Gamut;
20
29
  };
21
- /** Every light, with on, brightness and reachability. */
30
+ /** Every light, with on, brightness, reachability, and its white temperature and colour where it has them. */
22
31
  export declare function lights(s: HueSnapshot): LightView[];
23
32
  /** A room or zone as a row shows it, with its grouped light's state. */
24
33
  export type GroupView = {
@@ -1,3 +1,4 @@
1
+ import { mirekToKelvin, xyToHex } from "./color.js";
1
2
  /** A snapshot from one full `get()`. */
2
3
  export function snapshotFrom(resources, asOf) {
3
4
  return {
@@ -32,7 +33,7 @@ const ofType = (s, type) => Object.values(s.resources).filter((r) => r.type ===
32
33
  const groupedLightOf = (s, r) => r.services
33
34
  ?.map((x) => s.resources[x.rid])
34
35
  .find((x) => x?.type === "grouped_light");
35
- /** Every light, with on, brightness and reachability. */
36
+ /** Every light, with on, brightness, reachability, and its white temperature and colour where it has them. */
36
37
  export function lights(s) {
37
38
  const zigbee = ofType(s, "zigbee_connectivity");
38
39
  return ofType(s, "light").map((l) => {
@@ -43,9 +44,27 @@ export function lights(s) {
43
44
  on: l.on?.on,
44
45
  brightness: l.dimming?.brightness,
45
46
  reachable: link ? link.status === "connected" : undefined,
47
+ ...colorOf(l),
46
48
  };
47
49
  });
48
50
  }
51
+ /** A light's temperature and colour fields, only those the bridge reported. */
52
+ function colorOf(l) {
53
+ const out = {};
54
+ const ct = l.color_temperature;
55
+ if (typeof ct?.mirek === "number")
56
+ out.kelvin = mirekToKelvin(ct.mirek);
57
+ const { mirek_minimum: min, mirek_maximum: max } = ct?.mirek_schema ?? {};
58
+ if (min !== undefined && max !== undefined)
59
+ out.kelvinRange = [mirekToKelvin(max), mirekToKelvin(min)];
60
+ if (l.color?.xy) {
61
+ out.xy = l.color.xy;
62
+ out.hex = xyToHex(l.color.xy);
63
+ }
64
+ if (l.color?.gamut)
65
+ out.gamut = l.color.gamut;
66
+ return out;
67
+ }
49
68
  /** Rooms and zones. A room's children are devices; a zone's are lights. */
50
69
  export function groups(s) {
51
70
  const all = ofType(s, "light");
@@ -2,8 +2,16 @@ import type { Part } from "../../home.ts";
2
2
  import type { SpotifyState, Stall, Track } from "./client.ts";
3
3
  /** A stall in a few words: `playback stalled at 1:39 (Soloist issue #5); tried a seek`. */
4
4
  export declare function stallText(s: Stall | undefined): string | undefined;
5
- /** The speaker in one word, to switch on. `offline`: not reached (or no part); `waiting`: running, nobody has picked it. A status Soloist sends beyond its four passes through. */
6
- export type SpeakerState = "offline" | "connecting" | "waiting" | "idle" | "playing" | "paused" | "buffering" | (string & {});
5
+ /**
6
+ * Soloist says playing while it is not the account's active device: the music is on another Spotify device (a phone,
7
+ * another speaker) and this one is silent (live, 2026-11-10). Soloist doesn't say which. Unknown `active` is not elsewhere.
8
+ */
9
+ export declare const playingElsewhere: (s: SpotifyState | undefined) => boolean;
10
+ /**
11
+ * The speaker in one word, to switch on. `offline`: not reached (or no part); `waiting`: running, nobody has picked it;
12
+ * `elsewhere`: the account's music is playing on another Spotify device, not this one. A status Soloist sends beyond its four passes through.
13
+ */
14
+ export type SpeakerState = "offline" | "connecting" | "waiting" | "elsewhere" | "idle" | "playing" | "paused" | "buffering" | (string & {});
7
15
  export declare function speakerState(part: Part<SpotifyState> | undefined): SpeakerState;
8
16
  /**
9
17
  * A change worth telling someone about, from two reports of the speaker. `own`, on playback events only: it happened while
@@ -24,9 +32,15 @@ export type SpotifyEvent = {
24
32
  /**
25
33
  * The events between two reports, for `onChange`'s `before` and `after`: `offline` and `online`; `waiting` (signed out) and
26
34
  * `picked` (someone picked the speaker); `started` (from idle), `resumed` (from paused), `paused`, `stopped` (to idle), then
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
35
+ * `track` for a new track, once it has a name, then `stalled` and `recovered` (Soloist issue #5). Buffering counts as playing.
36
+ * Music on another Spotify device (`playingElsewhere`) is not playing here: it moving away is `stopped`, it arriving is `started`
37
+ * then `track`, and nothing fires for its tracks meanwhile. No `before`, or one
28
38
  * 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
39
  */
30
40
  export declare function spotifyChanges(before: Part<SpotifyState> | undefined, after: Part<SpotifyState> | undefined): SpotifyEvent[];
31
- /** The speaker in a few words: `Kitchen speaker: playing "Song" by Band · 35%`, or `…: waiting for someone to pick it`. */
32
- export declare function spotifyText(s: SpotifyState): string;
41
+ /**
42
+ * The speaker in a few words: `Kitchen speaker: playing "Song" by Band · 35%`, `…: waiting for someone to pick it`, or,
43
+ * for music on another Spotify device, `…: silent · "Song" by Band is playing on another Spotify device` (no volume: it isn't this speaker's playing level).
44
+ * `on` names where, when the caller knows (`spotifyElsewhere`): `… is playing on Front room speaker`.
45
+ */
46
+ export declare function spotifyText(s: SpotifyState, on?: string[]): string;
@@ -9,6 +9,12 @@ export function stallText(s) {
9
9
  : `playback stalled at ${at} (Soloist issue #5)`;
10
10
  return s.tried ? `${what}; tried ${s.tried}` : what;
11
11
  }
12
+ const PLAYING = new Set(["playing", "buffering"]);
13
+ /**
14
+ * Soloist says playing while it is not the account's active device: the music is on another Spotify device (a phone,
15
+ * another speaker) and this one is silent (live, 2026-11-10). Soloist doesn't say which. Unknown `active` is not elsewhere.
16
+ */
17
+ export const playingElsewhere = (s) => s?.active === false && PLAYING.has(s.status ?? "");
12
18
  export function speakerState(part) {
13
19
  if (!part || part.status === "disconnected" || part.status === "unavailable")
14
20
  return "offline";
@@ -16,13 +22,16 @@ export function speakerState(part) {
16
22
  return "connecting";
17
23
  if (part.state?.loggedIn === false)
18
24
  return "waiting";
25
+ if (playingElsewhere(part.state))
26
+ return "elsewhere";
19
27
  return part.state?.status ?? "idle"; // signed in, nothing reported yet: nothing is playing
20
28
  }
21
- const PLAYING = new Set(["playing", "buffering"]);
22
29
  /**
23
30
  * The events between two reports, for `onChange`'s `before` and `after`: `offline` and `online`; `waiting` (signed out) and
24
31
  * `picked` (someone picked the speaker); `started` (from idle), `resumed` (from paused), `paused`, `stopped` (to idle), then
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
32
+ * `track` for a new track, once it has a name, then `stalled` and `recovered` (Soloist issue #5). Buffering counts as playing.
33
+ * Music on another Spotify device (`playingElsewhere`) is not playing here: it moving away is `stopped`, it arriving is `started`
34
+ * then `track`, and nothing fires for its tracks meanwhile. No `before`, or one
26
35
  * 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
36
  */
28
37
  export function spotifyChanges(before, after) {
@@ -45,8 +54,11 @@ export function spotifyChanges(before, after) {
45
54
  events.push({ type: "waiting" });
46
55
  if (b?.loggedIn === false && a?.loggedIn === true)
47
56
  events.push({ type: "picked" });
48
- const from = b?.status;
49
- const to = a?.status;
57
+ // What this speaker itself is doing: music on another device is silence here.
58
+ const here = (s) => (playingElsewhere(s) ? "idle" : s?.status);
59
+ const from = here(b);
60
+ // Still inactive after playing elsewhere: a pause or stop there is the other device's, not this one's.
61
+ const to = playingElsewhere(b) && a?.active === false ? "idle" : here(a);
50
62
  const playingNow = to !== undefined && PLAYING.has(to);
51
63
  const playingThen = from !== undefined && PLAYING.has(from);
52
64
  if (playingNow && !playingThen)
@@ -57,7 +69,11 @@ export function spotifyChanges(before, after) {
57
69
  add({ type: "stopped" });
58
70
  // Soloist's first report of a new track can have no name yet (seen live): the event waits for it.
59
71
  const named = (p) => !!p?.track?.name;
60
- if (a?.track && named(a) && (a.track.uri !== b?.track?.uri || !named(b)))
72
+ const heard = playingElsewhere(b) && playingNow ? undefined : b; // a track that was on another device is new here once it plays here
73
+ if (a?.track &&
74
+ named(a) &&
75
+ !playingElsewhere(a) &&
76
+ (a.track.uri !== heard?.track?.uri || !named(heard)))
61
77
  add({ type: "track", track: a.track });
62
78
  if (a?.stalled && !b?.stalled)
63
79
  events.push({ type: "stalled", stall: a.stalled });
@@ -68,8 +84,12 @@ export function spotifyChanges(before, after) {
68
84
  events.push({ type: "recovered" });
69
85
  return events;
70
86
  }
71
- /** The speaker in a few words: `Kitchen speaker: playing "Song" by Band · 35%`, or `…: waiting for someone to pick it`. */
72
- export function spotifyText(s) {
87
+ /**
88
+ * The speaker in a few words: `Kitchen speaker: playing "Song" by Band · 35%`, `…: waiting for someone to pick it`, or,
89
+ * for music on another Spotify device, `…: silent · "Song" by Band is playing on another Spotify device` (no volume: it isn't this speaker's playing level).
90
+ * `on` names where, when the caller knows (`spotifyElsewhere`): `… is playing on Front room speaker`.
91
+ */
92
+ export function spotifyText(s, on = []) {
73
93
  const who = s.deviceName ?? "Soloist";
74
94
  if (s.loggedIn === false)
75
95
  return `${who}: waiting for someone to pick it`;
@@ -77,6 +97,8 @@ export function spotifyText(s) {
77
97
  const what = t
78
98
  ? `${t.name ? ` "${t.name}"` : ` ${t.uri}`}${t.artists.length ? ` by ${t.artists.join(", ")}` : ""}`
79
99
  : "";
100
+ if (playingElsewhere(s))
101
+ return `${who}: silent ·${what || " music"} is playing on ${on.length ? on.join(", ") : "another Spotify device"}`;
80
102
  const parts = [
81
103
  `${s.status ?? "status unknown"}${what}`,
82
104
  s.volume === undefined ? "" : `${s.volume}%`,
@@ -5,7 +5,7 @@ import type { Manifest } from "../../manifest.ts";
5
5
  import { type SpeakerState } from "./changes.ts";
6
6
  import { type SocketFactory, type SpotifyState } from "./client.ts";
7
7
  export { positionMs, type PositionAnchor, type SocketFactory, type SocketLike, type SpotifyState, type Stall, toSpotifyUri, type Track, } from "./client.ts";
8
- export { type SpeakerState, speakerState, type SpotifyEvent, spotifyChanges, spotifyText, } from "./changes.ts";
8
+ export { type SpeakerState, playingElsewhere, speakerState, type SpotifyEvent, spotifyChanges, spotifyText, } from "./changes.ts";
9
9
  /** Soloist has no default port (`--ws ADDR:PORT` opens it); this library assumes this one, the port in chota-bot's unit. */
10
10
  export declare const DEFAULT_URL = "ws://127.0.0.1:9091";
11
11
  /** A Soloist speaker in a home config. No key: Soloist's API key is the daemon's, never the library's. */
@@ -6,7 +6,7 @@ import { writeLane } from "../../lane.js";
6
6
  import { speakerState, spotifyText, stallText, } from "./changes.js";
7
7
  import { commandFrame, fold, Frame, FRAME_TYPES, OPEN, oddities, positionMs, toSpotifyUri, } from "./client.js";
8
8
  export { positionMs, toSpotifyUri, } from "./client.js";
9
- export { speakerState, spotifyChanges, spotifyText, } from "./changes.js";
9
+ export { playingElsewhere, speakerState, spotifyChanges, spotifyText, } from "./changes.js";
10
10
  /** Soloist has no default port (`--ws ADDR:PORT` opens it); this library assumes this one, the port in chota-bot's unit. */
11
11
  export const DEFAULT_URL = "ws://127.0.0.1:9091";
12
12
  /** A Soloist speaker in a home config. No key: Soloist's API key is the daemon's, never the library's. */
@@ -651,6 +651,7 @@ export function spotify(config = {}) {
651
651
  if (!st ||
652
652
  state.aside === "repair" ||
653
653
  (state.track?.uri === st.track &&
654
+ state.active !== false && // moved to another device: no longer this speaker's stall
654
655
  (state.status === "playing" || state.status === "buffering")))
655
656
  return;
656
657
  const { stalled: _, ...rest } = state;
@@ -678,7 +679,11 @@ export function spotify(config = {}) {
678
679
  a.track === b.track &&
679
680
  b.atMs - a.atMs >= gap &&
680
681
  Math.abs(b.ms - a.ms) < 1000;
681
- const playing = () => isOpen() && state.status === "playing" && !state.aside;
682
+ // Not while inactive: "playing" then is another device's music, and a repair's seek or pause would land on it.
683
+ const playing = () => isOpen() &&
684
+ state.status === "playing" &&
685
+ state.active !== false &&
686
+ !state.aside;
682
687
  /** One look while playing: a fresh position, then frozen, overrun, recovered or fine (Soloist issue #5). */
683
688
  const checkStall = async () => {
684
689
  if (!playing())
@@ -720,6 +725,7 @@ export function spotify(config = {}) {
720
725
  // Still ours to fix: playing this track, stalled, and no duck, interrupt or person in between.
721
726
  const ours = () => isOpen() &&
722
727
  state.status === "playing" &&
728
+ state.active !== false &&
723
729
  state.track?.uri === track &&
724
730
  state.stalled?.track === track &&
725
731
  (!state.aside || state.aside === "repair");
package/dist/devices.d.ts CHANGED
@@ -25,3 +25,27 @@ export declare const SERVICES: readonly string[];
25
25
  * no name of its own (the Daikin, the Enphase gateway) the entry's `name`. A device's own name is never replaced.
26
26
  */
27
27
  export declare function homeDevices(snapshot: Record<string, Part | undefined>, config?: Pick<HomeConfig, "devices" | "rooms">): HomeDevice[];
28
+ /**
29
+ * Where the account's Spotify is playing when the Soloist speaker says it is on another device: the names of the Cast
30
+ * devices and groups playing a track of that name in Spotify. A group is named, not its members. Empty when the music is
31
+ * on the speaker itself, nothing is playing, or it is on something Cast can't see (a phone, a laptop). Matched by the
32
+ * track's name, since neither side names the other.
33
+ */
34
+ export declare function spotifyElsewhere(snapshot: Record<string, Part | undefined>): string[];
35
+ /** A room of the home config with the devices placed in it. `noData`: device connectors the config places here that listed nothing in it (never reached, not set up, or a `hueRoom` the bridge doesn't have), so the room isn't empty, it is unread. Per connector, not per device. */
36
+ export type RoomDevices = {
37
+ id: string;
38
+ name: string;
39
+ floor?: string;
40
+ devices: HomeDevice[];
41
+ noData: string[];
42
+ };
43
+ /**
44
+ * `homeDevices` room by room, in the config's order, for a map of the home. A connector that is down but has read before
45
+ * still lists its devices, `available: false`; `noData` names one the config places in the room (a device's `room`, or
46
+ * `hueRoom`) that has nothing to list. `unplaced`: devices with no room, or one the config doesn't have.
47
+ */
48
+ export declare function byRoom(snapshot: Record<string, Part | undefined>, config: Pick<HomeConfig, "devices" | "rooms">): {
49
+ rooms: RoomDevices[];
50
+ unplaced: HomeDevice[];
51
+ };
package/dist/devices.js CHANGED
@@ -3,7 +3,7 @@ import { daikinHomeDevices, } from "./connectors/daikin/index.js";
3
3
  import { enphaseHomeDevices, } from "./connectors/enphase/index.js";
4
4
  import { hueHomeDevices } from "./connectors/hue/index.js";
5
5
  import { printersHomeDevices, } from "./connectors/printers/index.js";
6
- import { spotifyHomeDevices, } from "./connectors/spotify/index.js";
6
+ import { playingElsewhere, spotifyHomeDevices, spotifyText, } from "./connectors/spotify/index.js";
7
7
  import { IDENTITY, sameId } from "./discover.js";
8
8
  import { manifestOf } from "./manifests.js";
9
9
  /** Each connector's devices from its part, in the order `home status` lists the connectors. */
@@ -23,7 +23,13 @@ export const SERVICES = ["amber", "up", "weather"];
23
23
  * no name of its own (the Daikin, the Enphase gateway) the entry's `name`. A device's own name is never replaced.
24
24
  */
25
25
  export function homeDevices(snapshot, config) {
26
- const all = Object.entries(DEVICES).flatMap(([name, devices]) => devices(snapshot[name]));
26
+ // Soloist doesn't say where its music went; a Cast device playing that track does, so the speaker's line names it.
27
+ const on = spotifyElsewhere(snapshot);
28
+ const all = Object.entries(DEVICES)
29
+ .flatMap(([name, devices]) => devices(snapshot[name]))
30
+ .map((d) => d.connector === "spotify" && on.length
31
+ ? { ...d, text: spotifyText(d.detail, on) }
32
+ : d);
27
33
  if (!config)
28
34
  return all;
29
35
  const entries = config.devices ?? [];
@@ -59,3 +65,51 @@ export function homeDevices(snapshot, config) {
59
65
  return { ...d, name, text, ...(room ? { room } : {}) };
60
66
  });
61
67
  }
68
+ /**
69
+ * Where the account's Spotify is playing when the Soloist speaker says it is on another device: the names of the Cast
70
+ * devices and groups playing a track of that name in Spotify. A group is named, not its members. Empty when the music is
71
+ * on the speaker itself, nothing is playing, or it is on something Cast can't see (a phone, a laptop). Matched by the
72
+ * track's name, since neither side names the other.
73
+ */
74
+ export function spotifyElsewhere(snapshot) {
75
+ const s = snapshot.spotify?.state;
76
+ const cast = snapshot.cast?.state;
77
+ const name = s?.track?.name;
78
+ if (!playingElsewhere(s) || !name || !cast)
79
+ return [];
80
+ return Object.values(cast.devices)
81
+ .filter((d) => d.status === "connected" &&
82
+ !d.playingVia && // its group's music: the group is named
83
+ d.app === "Spotify" &&
84
+ d.title === name &&
85
+ (d.playerState === "PLAYING" || d.playerState === "BUFFERING"))
86
+ .map((d) => d.name);
87
+ }
88
+ /**
89
+ * `homeDevices` room by room, in the config's order, for a map of the home. A connector that is down but has read before
90
+ * still lists its devices, `available: false`; `noData` names one the config places in the room (a device's `room`, or
91
+ * `hueRoom`) that has nothing to list. `unplaced`: devices with no room, or one the config doesn't have.
92
+ */
93
+ export function byRoom(snapshot, config) {
94
+ const all = homeDevices(snapshot, config);
95
+ const ids = new Set((config.rooms ?? []).map((r) => r.id));
96
+ return {
97
+ rooms: (config.rooms ?? []).map((r) => {
98
+ const devices = all.filter((d) => d.room === r.id);
99
+ const expected = new Set([
100
+ ...(config.devices ?? [])
101
+ .filter((d) => d.room === r.id)
102
+ .map((d) => d.connector),
103
+ ...(r.hueRoom === undefined ? [] : ["hue"]),
104
+ ]);
105
+ return {
106
+ id: r.id,
107
+ name: r.name,
108
+ ...(r.floor === undefined ? {} : { floor: r.floor }),
109
+ devices,
110
+ noData: [...expected].filter((c) => c in DEVICES && !devices.some((d) => d.connector === c)),
111
+ };
112
+ }),
113
+ unplaced: all.filter((d) => !d.room || !ids.has(d.room)),
114
+ };
115
+ }
package/dist/discover.js CHANGED
@@ -82,7 +82,7 @@ function newEntry(f) {
82
82
  [IDENTITY[f.connector]]: f.id,
83
83
  };
84
84
  if (f.connector === "hue")
85
- return { ...base, key: "HUE_APPLICATION_KEY", ca: "hue-root-ca.pem" };
85
+ return { ...base, key: "HUE_APPLICATION_KEY" };
86
86
  if (f.connector === "enphase")
87
87
  return { ...base, token: "~/.config/home/enphase-token.json" };
88
88
  return base;
@@ -1 +1 @@
1
- export { FINDERS, fileCache } from "./finders.ts";
1
+ export { FINDERS, fileCache, MissingTool } from "./finders.ts";
@@ -1,2 +1,2 @@
1
1
  // `@khalido/home/finders`: what a Node consumer passes to `loadHome`. The rest of finders.ts is the CLI's.
2
- export { FINDERS, fileCache } from "./finders.js";
2
+ export { FINDERS, fileCache, MissingTool } from "./finders.js";
package/dist/finders.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type AddressCache, type Connector, type Finder, type Found } from "./discover.ts";
2
2
  /** The system's mDNS tool is not installed: discovery can't run until it is. */
3
+ /** The discovery tool isn't installed (`dns-sd` on macOS, `avahi-browse` from avahi-utils on Linux): the message names it. */
3
4
  export declare class MissingTool extends Error {
4
5
  }
5
6
  /**
@@ -27,7 +28,7 @@ export declare function mdnsResolve(type: string): Promise<{
27
28
  }[]>;
28
29
  /** Daikin's `key=value,key=value` answers, percent-decoded. */
29
30
  export declare const daikinFields: (text: string) => any;
30
- /** Broadcast pydaikin's discovery packet (UDP 30000 → 30050) and collect who answers within `ms`. */
31
+ /** Broadcast pydaikin's discovery packet (UDP 30000 → 30050), three times across `ms`, and collect who answers, each unit once. */
31
32
  export declare function findDaikins(ms?: number): Promise<{
32
33
  ip: string;
33
34
  fields: Record<string, string>;
package/dist/finders.js CHANGED
@@ -13,6 +13,7 @@ import { parseAvahi, parseAvahiBrowse, parseBrowse, parseResolve, } from "./disc
13
13
  // does not slow a scan. Without the tool the mDNS finders throw, naming it, rather than "nothing answered".
14
14
  // `fileCache` keeps the addresses discovery found in a file between runs, so the next run connects straight away.
15
15
  /** The system's mDNS tool is not installed: discovery can't run until it is. */
16
+ /** The discovery tool isn't installed (`dns-sd` on macOS, `avahi-browse` from avahi-utils on Linux): the message names it. */
16
17
  export class MissingTool extends Error {
17
18
  }
18
19
  /**
@@ -94,17 +95,27 @@ function decoded(v) {
94
95
  return v;
95
96
  }
96
97
  }
97
- /** Broadcast pydaikin's discovery packet (UDP 30000 → 30050) and collect who answers within `ms`. */
98
+ /** Broadcast pydaikin's discovery packet (UDP 30000 → 30050), three times across `ms`, and collect who answers, each unit once. */
98
99
  export function findDaikins(ms = 1500) {
99
100
  return new Promise((resolve) => {
100
101
  const found = [];
101
102
  const sock = createSocket({ type: "udp4", reuseAddr: true });
102
- sock.on("message", (msg, from) => found.push({ ip: from.address, fields: daikinFields(msg.toString()) }));
103
- sock.on("error", () => (sock.close(), resolve(found)));
103
+ sock.on("message", (msg, from) => {
104
+ if (!found.some((f) => f.ip === from.address))
105
+ found.push({ ip: from.address, fields: daikinFields(msg.toString()) });
106
+ });
107
+ const asks = [];
108
+ const done = () => (asks.forEach(clearTimeout),
109
+ sock.close(),
110
+ resolve(found));
111
+ sock.on("error", done);
104
112
  sock.bind(30000, () => {
105
113
  sock.setBroadcast(true);
106
- sock.send("DAIKIN_UDP/common/basic_info", 30050, "255.255.255.255");
107
- setTimeout(() => (sock.close(), resolve(found)), ms);
114
+ // UDP drops packets, more so while mDNS is busy beside it: one ask missed KO's unit in 2 runs of 3 (2026-11-11). Ask three times.
115
+ const ask = () => sock.send("DAIKIN_UDP/common/basic_info", 30050, "255.255.255.255");
116
+ ask();
117
+ asks.push(setTimeout(ask, ms / 3), setTimeout(ask, (2 * ms) / 3));
118
+ setTimeout(done, ms);
108
119
  });
109
120
  });
110
121
  }
package/dist/home.d.ts CHANGED
@@ -87,7 +87,7 @@ export type Change<I extends Integration<any, string> = Integration<unknown, str
87
87
  }[I["name"]];
88
88
  /**
89
89
  * Load a home: run each integration, keep one snapshot, call `onChange` with it and what changed after every report.
90
- * `clock` stamps each part's `lastGoodAt` and `downSince`.
90
+ * `clock` stamps each part's `lastGoodAt` and `downSince`. `add` and `remove` change the set while it runs.
91
91
  */
92
92
  export declare function createHome<I extends Integration<any, string>>(integrations: readonly I[], onChange?: (home: HomeSnapshot<I>, change: Change<I>) => void, options?: {
93
93
  clock?: Clock;
@@ -105,7 +105,16 @@ export declare function createHome<I extends Integration<any, string>>(integrati
105
105
  * promise resolves once each target has reported (its next update, good or bad), or at once for one that isn't running.
106
106
  */
107
107
  refresh(name?: I["name"]): Promise<void>;
108
- /** Run every integration until `signal` aborts. One failing marks itself unavailable; the rest carry on. */
108
+ /**
109
+ * Add an integration while the home runs, or before it starts: a bridge paired just now, a key that arrived late. One
110
+ * already there under that name is stopped first and its part dropped, so the new one starts from `connecting` and
111
+ * the two never run together. Resolves once the new one is running; its reports arrive as any other's. Calls on
112
+ * one home run one at a time. Once `start`'s signal has aborted, an added one waits for the next `start`.
113
+ */
114
+ add: (integration: I) => Promise<void>;
115
+ /** Stop one integration and drop its part from the snapshot. Resolves once its run has ended; false if there was none by that name. */
116
+ remove: (name: I["name"]) => Promise<boolean>;
117
+ /** Run every integration, and any added meanwhile, until `signal` aborts. One failing marks itself unavailable; the rest carry on. */
109
118
  start(signal: AbortSignal): Promise<void>;
110
119
  };
111
120
  /**