@khalido/home 0.0.0-stage → 0.1.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 (217) hide show
  1. package/.env.example +25 -0
  2. package/CHANGELOG.md +25 -0
  3. package/LICENSE +21 -0
  4. package/README.md +77 -2
  5. package/dist/clock.d.ts +17 -0
  6. package/dist/clock.js +43 -0
  7. package/dist/config.d.ts +273 -0
  8. package/dist/config.js +392 -0
  9. package/dist/connector.d.ts +34 -0
  10. package/dist/connector.js +47 -0
  11. package/dist/connectors/amber/client.d.ts +61 -0
  12. package/dist/connectors/amber/client.js +106 -0
  13. package/dist/connectors/amber/index.d.ts +30 -0
  14. package/dist/connectors/amber/index.js +94 -0
  15. package/dist/connectors/cast/codec.d.ts +38 -0
  16. package/dist/connectors/cast/codec.js +102 -0
  17. package/dist/connectors/cast/index.d.ts +60 -0
  18. package/dist/connectors/cast/index.js +426 -0
  19. package/dist/connectors/cast/schemas.d.ts +167 -0
  20. package/dist/connectors/cast/schemas.js +87 -0
  21. package/dist/connectors/cast/serve.d.ts +11 -0
  22. package/dist/connectors/cast/serve.js +57 -0
  23. package/dist/connectors/cast/state.d.ts +62 -0
  24. package/dist/connectors/cast/state.js +133 -0
  25. package/dist/connectors/cast/tls.d.ts +3 -0
  26. package/dist/connectors/cast/tls.js +67 -0
  27. package/dist/connectors/cast/writes.d.ts +80 -0
  28. package/dist/connectors/cast/writes.js +390 -0
  29. package/dist/connectors/daikin/client.d.ts +65 -0
  30. package/dist/connectors/daikin/client.js +221 -0
  31. package/dist/connectors/daikin/index.d.ts +41 -0
  32. package/dist/connectors/daikin/index.js +108 -0
  33. package/dist/connectors/enphase/client.d.ts +65 -0
  34. package/dist/connectors/enphase/client.js +173 -0
  35. package/dist/connectors/enphase/index.d.ts +43 -0
  36. package/dist/connectors/enphase/index.js +174 -0
  37. package/dist/connectors/enphase/schemas.d.ts +51 -0
  38. package/dist/connectors/enphase/schemas.js +34 -0
  39. package/dist/connectors/hue/client.d.ts +48 -0
  40. package/dist/connectors/hue/client.js +95 -0
  41. package/dist/connectors/hue/events.d.ts +41 -0
  42. package/dist/connectors/hue/events.js +156 -0
  43. package/dist/connectors/hue/index.d.ts +42 -0
  44. package/dist/connectors/hue/index.js +205 -0
  45. package/dist/connectors/hue/schemas.d.ts +65 -0
  46. package/dist/connectors/hue/schemas.js +48 -0
  47. package/dist/connectors/hue/state.d.ts +43 -0
  48. package/dist/connectors/hue/state.js +84 -0
  49. package/dist/connectors/printers/client.d.ts +28 -0
  50. package/dist/connectors/printers/client.js +74 -0
  51. package/dist/connectors/printers/codec.d.ts +76 -0
  52. package/dist/connectors/printers/codec.js +246 -0
  53. package/dist/connectors/printers/found.d.ts +15 -0
  54. package/dist/connectors/printers/found.js +35 -0
  55. package/dist/connectors/printers/index.d.ts +52 -0
  56. package/dist/connectors/printers/index.js +282 -0
  57. package/dist/connectors/printers/state.d.ts +60 -0
  58. package/dist/connectors/printers/state.js +99 -0
  59. package/dist/connectors/spotify/changes.d.ts +32 -0
  60. package/dist/connectors/spotify/changes.js +84 -0
  61. package/dist/connectors/spotify/client.d.ts +199 -0
  62. package/dist/connectors/spotify/client.js +270 -0
  63. package/dist/connectors/spotify/index.d.ts +92 -0
  64. package/dist/connectors/spotify/index.js +745 -0
  65. package/dist/connectors/up/client.d.ts +183 -0
  66. package/dist/connectors/up/client.js +308 -0
  67. package/dist/connectors/up/index.d.ts +91 -0
  68. package/dist/connectors/up/index.js +213 -0
  69. package/dist/connectors/up/spend.d.ts +78 -0
  70. package/dist/connectors/up/spend.js +213 -0
  71. package/dist/connectors/up/webhook.d.ts +13 -0
  72. package/dist/connectors/up/webhook.js +32 -0
  73. package/dist/connectors/weather/blocks.d.ts +51 -0
  74. package/dist/connectors/weather/blocks.js +106 -0
  75. package/dist/connectors/weather/bom.d.ts +37 -0
  76. package/dist/connectors/weather/bom.js +237 -0
  77. package/dist/connectors/weather/ftp.d.ts +5 -0
  78. package/dist/connectors/weather/ftp.js +46 -0
  79. package/dist/connectors/weather/google.d.ts +31 -0
  80. package/dist/connectors/weather/google.js +364 -0
  81. package/dist/connectors/weather/index.d.ts +53 -0
  82. package/dist/connectors/weather/index.js +267 -0
  83. package/dist/connectors/weather/open-meteo.d.ts +15 -0
  84. package/dist/connectors/weather/open-meteo.js +285 -0
  85. package/dist/connectors/weather/rain.d.ts +19 -0
  86. package/dist/connectors/weather/rain.js +51 -0
  87. package/dist/connectors/weather/source.d.ts +149 -0
  88. package/dist/connectors/weather/source.js +77 -0
  89. package/dist/connectors/weather/sun.d.ts +48 -0
  90. package/dist/connectors/weather/sun.js +65 -0
  91. package/dist/discover.d.ts +195 -0
  92. package/dist/discover.js +446 -0
  93. package/dist/energy.d.ts +61 -0
  94. package/dist/energy.js +162 -0
  95. package/dist/finders-entry.d.ts +1 -0
  96. package/dist/finders-entry.js +2 -0
  97. package/dist/finders.d.ts +45 -0
  98. package/dist/finders.js +173 -0
  99. package/dist/health.d.ts +28 -0
  100. package/dist/health.js +35 -0
  101. package/dist/home.d.ts +81 -0
  102. package/dist/home.js +114 -0
  103. package/dist/index.d.ts +11 -0
  104. package/dist/index.js +11 -0
  105. package/dist/lane.d.ts +17 -0
  106. package/dist/lane.js +74 -0
  107. package/dist/locate.d.ts +27 -0
  108. package/dist/locate.js +59 -0
  109. package/dist/manifest.d.ts +31 -0
  110. package/dist/manifest.js +4 -0
  111. package/dist/manifests.d.ts +5 -0
  112. package/dist/manifests.js +23 -0
  113. package/dist/node-fetch.d.ts +9 -0
  114. package/dist/node-fetch.js +25 -0
  115. package/dist/poll.d.ts +10 -0
  116. package/dist/poll.js +33 -0
  117. package/docs/cast.md +133 -0
  118. package/docs/cli.md +39 -0
  119. package/docs/config.md +36 -0
  120. package/docs/decisions.md +150 -0
  121. package/docs/devices.md +128 -0
  122. package/docs/enphase-notes.md +24 -0
  123. package/docs/google.md +92 -0
  124. package/docs/ideas.md +69 -0
  125. package/docs/matter.md +55 -0
  126. package/docs/performance.md +12 -0
  127. package/docs/philosophy.md +23 -0
  128. package/docs/up.md +84 -0
  129. package/docs/using.md +65 -0
  130. package/docs/weather-probe.md +63 -0
  131. package/examples/home.jsonc +74 -0
  132. package/examples/home.svg +76 -0
  133. package/home.schema.json +474 -0
  134. package/llms.txt +32 -0
  135. package/package.json +143 -4
  136. package/skills/draw-home/README.md +26 -0
  137. package/skills/draw-home/SKILL.md +82 -0
  138. package/skills/use-home/SKILL.md +86 -0
  139. package/src/cli/cache.ts +29 -0
  140. package/src/cli/cast.ts +172 -0
  141. package/src/cli/config.ts +52 -0
  142. package/src/cli/context.ts +118 -0
  143. package/src/cli/discover.ts +175 -0
  144. package/src/cli/enphase.ts +62 -0
  145. package/src/cli/hue.ts +124 -0
  146. package/src/cli/locate.ts +47 -0
  147. package/src/cli/main.ts +182 -0
  148. package/src/cli/printers.ts +21 -0
  149. package/src/cli/probe.ts +274 -0
  150. package/src/cli/spotify.ts +76 -0
  151. package/src/cli/status.ts +228 -0
  152. package/src/cli/up.ts +350 -0
  153. package/src/clock.ts +62 -0
  154. package/src/config.ts +578 -0
  155. package/src/connector.ts +77 -0
  156. package/src/connectors/amber/README.md +40 -0
  157. package/src/connectors/amber/client.ts +157 -0
  158. package/src/connectors/amber/index.ts +118 -0
  159. package/src/connectors/cast/README.md +43 -0
  160. package/src/connectors/cast/codec.ts +126 -0
  161. package/src/connectors/cast/index.ts +551 -0
  162. package/src/connectors/cast/schemas.ts +101 -0
  163. package/src/connectors/cast/serve.ts +72 -0
  164. package/src/connectors/cast/state.ts +203 -0
  165. package/src/connectors/cast/tls.ts +100 -0
  166. package/src/connectors/cast/writes.ts +572 -0
  167. package/src/connectors/daikin/README.md +78 -0
  168. package/src/connectors/daikin/client.ts +295 -0
  169. package/src/connectors/daikin/index.ts +145 -0
  170. package/src/connectors/enphase/README.md +64 -0
  171. package/src/connectors/enphase/client.ts +244 -0
  172. package/src/connectors/enphase/index.ts +241 -0
  173. package/src/connectors/enphase/schemas.ts +45 -0
  174. package/src/connectors/hue/README.md +50 -0
  175. package/src/connectors/hue/client.ts +157 -0
  176. package/src/connectors/hue/events.ts +182 -0
  177. package/src/connectors/hue/index.ts +261 -0
  178. package/src/connectors/hue/schemas.ts +60 -0
  179. package/src/connectors/hue/state.ts +124 -0
  180. package/src/connectors/printers/README.md +46 -0
  181. package/src/connectors/printers/client.ts +114 -0
  182. package/src/connectors/printers/codec.ts +292 -0
  183. package/src/connectors/printers/found.ts +46 -0
  184. package/src/connectors/printers/index.ts +385 -0
  185. package/src/connectors/printers/state.ts +182 -0
  186. package/src/connectors/spotify/README.md +80 -0
  187. package/src/connectors/spotify/changes.ts +127 -0
  188. package/src/connectors/spotify/client.ts +387 -0
  189. package/src/connectors/spotify/index.ts +929 -0
  190. package/src/connectors/up/README.md +35 -0
  191. package/src/connectors/up/client.ts +518 -0
  192. package/src/connectors/up/index.ts +370 -0
  193. package/src/connectors/up/spend.ts +280 -0
  194. package/src/connectors/up/webhook.ts +62 -0
  195. package/src/connectors/weather/README.md +84 -0
  196. package/src/connectors/weather/blocks.ts +173 -0
  197. package/src/connectors/weather/bom.ts +344 -0
  198. package/src/connectors/weather/ftp.ts +48 -0
  199. package/src/connectors/weather/google.ts +425 -0
  200. package/src/connectors/weather/index.ts +380 -0
  201. package/src/connectors/weather/open-meteo.ts +309 -0
  202. package/src/connectors/weather/rain.ts +73 -0
  203. package/src/connectors/weather/source.ts +236 -0
  204. package/src/connectors/weather/sun.ts +114 -0
  205. package/src/discover.ts +634 -0
  206. package/src/energy.ts +221 -0
  207. package/src/finders-entry.ts +2 -0
  208. package/src/finders.ts +247 -0
  209. package/src/health.ts +74 -0
  210. package/src/home.ts +205 -0
  211. package/src/index.ts +62 -0
  212. package/src/lane.ts +110 -0
  213. package/src/locate.ts +85 -0
  214. package/src/manifest.ts +43 -0
  215. package/src/manifests.ts +27 -0
  216. package/src/node-fetch.ts +41 -0
  217. package/src/poll.ts +33 -0
@@ -0,0 +1,127 @@
1
+ import type { Part } from "../../home.ts";
2
+ import type { SpotifyState, Stall, Track } from "./client.ts";
3
+
4
+ // What a consumer reads off the speaker's parts: one word, the events between two reports, a line of text. Pure: no socket, no clock.
5
+
6
+ /** A stall in a few words: `playback stalled at 1:39 (Soloist issue #5); tried a seek`. */
7
+ export function stallText(s: Stall | undefined): string | undefined {
8
+ if (!s) return undefined;
9
+ const at = `${Math.floor(s.ms / 60_000)}:${String(Math.floor((s.ms % 60_000) / 1000)).padStart(2, "0")}`;
10
+ const what =
11
+ s.how === "overrun"
12
+ ? `playback stuck past the end of the track (Soloist issue #5)`
13
+ : `playback stalled at ${at} (Soloist issue #5)`;
14
+ return s.tried ? `${what}; tried ${s.tried}` : what;
15
+ }
16
+
17
+ /** 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. */
18
+ export type SpeakerState =
19
+ | "offline"
20
+ | "connecting"
21
+ | "waiting"
22
+ | "idle"
23
+ | "playing"
24
+ | "paused"
25
+ | "buffering"
26
+ | (string & {});
27
+
28
+ export function speakerState(
29
+ part: Part<SpotifyState> | undefined,
30
+ ): SpeakerState {
31
+ if (!part || part.status === "disconnected" || part.status === "unavailable")
32
+ return "offline";
33
+ if (part.status === "connecting") return "connecting";
34
+ if (part.state?.loggedIn === false) return "waiting";
35
+ return part.state?.status ?? "idle"; // signed in, nothing reported yet: nothing is playing
36
+ }
37
+
38
+ /**
39
+ * A change worth telling someone about, from two reports of the speaker. `own`, on playback events only: it happened while
40
+ * this library's `duck`, `interrupt` or stall repair ran (its own writes, or a person's change in that time), so a consumer
41
+ * needn't react to it. `offline`, `online`, `waiting`, `picked`, `stalled` and `recovered` are never `own`.
42
+ */
43
+ export type SpotifyEvent = { own?: true } & (
44
+ | {
45
+ type:
46
+ | "offline"
47
+ | "online"
48
+ | "waiting"
49
+ | "picked"
50
+ | "started"
51
+ | "resumed"
52
+ | "paused"
53
+ | "stopped"
54
+ | "recovered";
55
+ }
56
+ | { type: "stalled"; stall: Stall }
57
+ | { type: "track"; track: Track }
58
+ );
59
+
60
+ const PLAYING = new Set(["playing", "buffering"]);
61
+
62
+ /**
63
+ * The events between two reports, for `onChange`'s `before` and `after`: `offline` and `online`; `waiting` (signed out) and
64
+ * `picked` (someone picked the speaker); `started` (from idle), `resumed` (from paused), `paused`, `stopped` (to idle), then
65
+ * `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
66
+ * 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.
67
+ */
68
+ export function spotifyChanges(
69
+ before: Part<SpotifyState> | undefined,
70
+ after: Part<SpotifyState> | undefined,
71
+ ): SpotifyEvent[] {
72
+ if (!before?.state) return []; // createHome's first `before` is a bare connecting part: a boot is no change
73
+ const own =
74
+ before.state?.aside || after?.state?.aside ? { own: true as const } : {}; // playback events only: a drop or a sign-out is never the library's
75
+ const down = (p: Part<SpotifyState> | undefined) =>
76
+ ["offline", "connecting"].includes(speakerState(p)); // a reconnect passes through connecting
77
+ const was = down(before);
78
+ if (speakerState(after) === "offline")
79
+ return was ? [] : [{ type: "offline" }];
80
+ if (after?.status === "connecting") return [];
81
+ const events: SpotifyEvent[] = [];
82
+ const add = (e: SpotifyEvent) => events.push({ ...e, ...own });
83
+ if (was) events.push({ type: "online" });
84
+ const b = before.state;
85
+ const a = after?.state;
86
+ if (b?.loggedIn !== false && a?.loggedIn === false)
87
+ events.push({ type: "waiting" });
88
+ if (b?.loggedIn === false && a?.loggedIn === true)
89
+ events.push({ type: "picked" });
90
+ const from = b?.status;
91
+ const to = a?.status;
92
+ const playingNow = to !== undefined && PLAYING.has(to);
93
+ const playingThen = from !== undefined && PLAYING.has(from);
94
+ if (playingNow && !playingThen)
95
+ add({ type: from === "paused" ? "resumed" : "started" });
96
+ if (to === "paused" && from !== "paused") add({ type: "paused" });
97
+ if (to === "idle" && from !== undefined && from !== "idle")
98
+ add({ type: "stopped" });
99
+ if (a?.track && a.track.uri !== b?.track?.uri)
100
+ add({ type: "track", track: a.track });
101
+ if (a?.stalled && !b?.stalled)
102
+ events.push({ type: "stalled", stall: a.stalled });
103
+ if (
104
+ b?.stalled &&
105
+ !a?.stalled &&
106
+ a?.status === "playing" &&
107
+ a.track?.uri === b.stalled.track
108
+ )
109
+ events.push({ type: "recovered" });
110
+ return events;
111
+ }
112
+
113
+ /** The speaker in a few words: `Kitchen speaker: playing "Song" by Band · 35%`, or `…: waiting for someone to pick it`. */
114
+ export function spotifyText(s: SpotifyState): string {
115
+ const who = s.deviceName ?? "Soloist";
116
+ if (s.loggedIn === false) return `${who}: waiting for someone to pick it`;
117
+ const t = s.track;
118
+ const what = t
119
+ ? `${t.name ? ` "${t.name}"` : ` ${t.uri}`}${t.artists.length ? ` by ${t.artists.join(", ")}` : ""}`
120
+ : "";
121
+ const parts = [
122
+ `${s.status ?? "status unknown"}${what}`,
123
+ s.volume === undefined ? "" : `${s.volume}%`,
124
+ s.shuffle ? "shuffle" : "",
125
+ ];
126
+ return `${who}: ${parts.filter(Boolean).join(" · ")}`;
127
+ }
@@ -0,0 +1,387 @@
1
+ import { z } from "zod";
2
+
3
+ // Spotify Soloist's local WebSocket API: the frames it sends, folded into one state, and the commands it takes.
4
+ // developer.spotify.com/documentation/soloist/reference/websocket-api (read 2026-11-08). Pure: no socket, no clock.
5
+
6
+ /** The WHATWG WebSocket surface used here: the runtime's global `WebSocket` (Bun, Node 22+, browsers), or a test's fake. */
7
+ export interface SocketLike {
8
+ readonly readyState: number;
9
+ onopen: ((ev: unknown) => void) | null;
10
+ onmessage: ((ev: { data: unknown }) => void) | null;
11
+ onclose: ((ev: unknown) => void) | null;
12
+ onerror: ((ev: unknown) => void) | null;
13
+ send(data: string): void;
14
+ close(): void;
15
+ }
16
+
17
+ /** Opens a socket to Soloist. The default is the runtime's `WebSocket`. */
18
+ export type SocketFactory = (url: string) => SocketLike;
19
+
20
+ export const OPEN = 1;
21
+
22
+ /** The statuses Soloist documents. Anything else is passed through and named in `anomaly`. */
23
+ export const STATUSES: ReadonlySet<string> = new Set([
24
+ "idle",
25
+ "playing",
26
+ "paused",
27
+ "buffering",
28
+ ]);
29
+ export const REPEATS: ReadonlySet<string> = new Set([
30
+ "off",
31
+ "context",
32
+ "track",
33
+ ]);
34
+
35
+ // Every field optional: a frame missing one still folds, and a field of the wrong type fails only its own frame.
36
+ type Entity = { uri?: string; entity_type?: string; decorations?: Decorations };
37
+ type Decorations = {
38
+ identity?: { name?: string };
39
+ visual_identity?: { cover?: { url?: string; size?: string }[] };
40
+ parent?: { entity?: Entity };
41
+ creators?: { entity?: Entity }[];
42
+ playback?: { duration_ms?: number };
43
+ };
44
+ const Entity: z.ZodType<Entity> = z.lazy(() =>
45
+ z.looseObject({
46
+ uri: z.string().optional(),
47
+ entity_type: z.string().optional(),
48
+ decorations: z
49
+ .looseObject({
50
+ identity: z.looseObject({ name: z.string().optional() }).optional(),
51
+ visual_identity: z
52
+ .looseObject({
53
+ cover: z
54
+ .array(
55
+ z.looseObject({
56
+ url: z.string().optional(),
57
+ size: z.string().optional(),
58
+ }),
59
+ )
60
+ .optional(),
61
+ })
62
+ .optional(),
63
+ parent: z.looseObject({ entity: Entity.optional() }).optional(),
64
+ creators: z
65
+ .array(z.looseObject({ entity: Entity.optional() }))
66
+ .optional(),
67
+ playback: z
68
+ .looseObject({ duration_ms: z.number().optional() })
69
+ .optional(),
70
+ })
71
+ .optional(),
72
+ }),
73
+ );
74
+ const Position = z.looseObject({
75
+ position_ms: z.number().optional(),
76
+ timestamp_ms: z.number().optional(),
77
+ speed: z.number().optional(),
78
+ });
79
+ const Options = z.looseObject({
80
+ shuffle: z.boolean().optional(),
81
+ repeat: z.string().optional(),
82
+ });
83
+
84
+ const QueueEntry = z.looseObject({
85
+ uid: z.string().optional(),
86
+ source: z.string().optional(),
87
+ item: Entity.optional(),
88
+ });
89
+
90
+ /** Soloist's frames by type, those this library reads. Anything else is skipped. */
91
+ export const Frame = z.discriminatedUnion("type", [
92
+ z.looseObject({
93
+ type: z.literal("auth_state"),
94
+ logged_in: z.boolean().optional(),
95
+ is_active: z.boolean().optional(),
96
+ device_name: z.string().optional(),
97
+ }),
98
+ z.looseObject({
99
+ type: z.literal("playback_state"),
100
+ status: z.string().optional(),
101
+ item: Entity.optional(),
102
+ context: Entity.optional(),
103
+ position: Position.optional(),
104
+ volume: z.number().optional(),
105
+ is_active: z.boolean().optional(),
106
+ options: Options.optional(),
107
+ }),
108
+ z.looseObject({ type: z.literal("track_changed"), item: Entity.optional() }),
109
+ z.looseObject({
110
+ type: z.literal("playback_changed"),
111
+ status: z.string().optional(),
112
+ }),
113
+ z.looseObject({
114
+ type: z.literal("volume_changed"),
115
+ volume: z.number().optional(),
116
+ }),
117
+ z.looseObject({
118
+ type: z.literal("device_changed"),
119
+ is_active: z.boolean().optional(),
120
+ device_name: z.string().optional(),
121
+ }),
122
+ z.looseObject({
123
+ type: z.literal("context_changed"),
124
+ context: Entity.optional(),
125
+ }),
126
+ z.looseObject({
127
+ type: z.literal("options_changed"),
128
+ options: Options.optional(),
129
+ }),
130
+ z.looseObject({
131
+ type: z.literal("position_sync"),
132
+ position: Position.optional(),
133
+ }),
134
+ z.looseObject({
135
+ type: z.literal("queue_changed"),
136
+ previous: z.array(QueueEntry).optional(),
137
+ upcoming: z.array(QueueEntry).optional(),
138
+ }),
139
+ z.looseObject({
140
+ type: z.literal("command_result"),
141
+ command: z.string().optional(),
142
+ }),
143
+ z.looseObject({ type: z.literal("error"), message: z.string().optional() }),
144
+ ]);
145
+ export type Frame = z.infer<typeof Frame>;
146
+ export const FRAME_TYPES: ReadonlySet<string> = new Set(
147
+ Frame.options.map((o) => o.shape.type.value),
148
+ );
149
+
150
+ /** A stall the library noticed: see `SpotifyState.stalled`. */
151
+ export type Stall = {
152
+ track: string;
153
+ ms: number;
154
+ how: "frozen" | "overrun";
155
+ tried?: string;
156
+ };
157
+
158
+ /** What is loaded: a track or an episode. */
159
+ export type Track = {
160
+ uri: string;
161
+ type?: string;
162
+ name?: string;
163
+ artists: string[];
164
+ album?: string;
165
+ cover?: string;
166
+ durationMs?: number;
167
+ };
168
+
169
+ /** Where the position was at a moment: `atMs` is Soloist's wall clock (epoch ms, the same box's), `speed` 1 while playing. */
170
+ export type PositionAnchor = { ms?: number; atMs?: number; speed?: number };
171
+
172
+ /**
173
+ * The speaker as Soloist reports it. Every field is optional: none is known until Soloist says it. `loggedIn: false` is a
174
+ * Soloist with no Spotify session (a bad key never signs in, issue #18). No `track`: nothing loaded.
175
+ */
176
+ export type SpotifyState = {
177
+ loggedIn?: boolean;
178
+ /** Soloist's `--device-name`, the name phones show. */
179
+ deviceName?: string;
180
+ /** Whether this is the account's active Spotify Connect device. */
181
+ active?: boolean;
182
+ /** idle, playing, paused or buffering; anything else Soloist sends, passed through. */
183
+ status?: string;
184
+ track?: Track;
185
+ /** The playlist, album or show playing from. */
186
+ context?: { uri: string; type?: string; name?: string };
187
+ position?: PositionAnchor;
188
+ /** 0–100, as Soloist reports it. */
189
+ volume?: number;
190
+ shuffle?: boolean;
191
+ /** off, context or track. */
192
+ repeat?: string;
193
+ /** What plays next, next first: Soloist's `queue_changed` (at most 10) or its answer to `get_queue`. Queued tracks, then the context's. */
194
+ upNext?: Track[];
195
+ /** Set by this library while its own `duck`, `interrupt` or stall repair runs, so changes then can be told from a person's. Not Soloist's. */
196
+ aside?: "duck" | "interrupt" | "repair";
197
+ /**
198
+ * Set by this library when playback stalls while Soloist still says playing (Soloist issue #5): `frozen` (its reported
199
+ * position stopped moving) or `overrun` (still on a track well past its end). `ms` the track position where it stuck, `tried` the repair.
200
+ * Cleared when the track moves again, changes or stops. Not Soloist's.
201
+ */
202
+ stalled?: Stall;
203
+ asOf: string;
204
+ };
205
+
206
+ const name = (e: Entity | undefined) => e?.decorations?.identity?.name;
207
+
208
+ /** An item as a Track, or undefined for an empty `uri` (Soloist's "nothing loaded"). */
209
+ export function toTrack(item: Entity | undefined): Track | undefined {
210
+ if (!item?.uri) return undefined;
211
+ const d = item.decorations ?? {};
212
+ const covers = d.visual_identity?.cover ?? [];
213
+ const cover = covers.find((c) => c.size === "default") ?? covers[0];
214
+ const artists = (d.creators ?? [])
215
+ .map((c) => name(c.entity))
216
+ .filter((n): n is string => !!n);
217
+ return dropUndefined({
218
+ uri: item.uri,
219
+ type: item.entity_type || undefined,
220
+ name: name(item),
221
+ artists,
222
+ album: name(d.parent?.entity),
223
+ cover: cover?.url,
224
+ durationMs: d.playback?.duration_ms,
225
+ });
226
+ }
227
+
228
+ const toContext = (e: Entity | undefined) =>
229
+ e?.uri
230
+ ? dropUndefined({
231
+ uri: e.uri,
232
+ type: e.entity_type || undefined,
233
+ name: name(e),
234
+ })
235
+ : undefined;
236
+ const toAnchor = (p: z.infer<typeof Position>): PositionAnchor =>
237
+ dropUndefined({ ms: p.position_ms, atMs: p.timestamp_ms, speed: p.speed });
238
+ const toOptions = (o: z.infer<typeof Options>) =>
239
+ dropUndefined({ shuffle: o.shuffle, repeat: o.repeat });
240
+
241
+ /** An object without its undefined fields, so a snapshot never carries `key: undefined`. */
242
+ function dropUndefined<T extends object>(o: T): T {
243
+ return Object.fromEntries(
244
+ Object.entries(o).filter(([, v]) => v !== undefined),
245
+ ) as T;
246
+ }
247
+
248
+ /**
249
+ * One frame folded into the state. `playback_state` is a full snapshot and replaces what it covers; the others patch one
250
+ * field. `auth_state` with `logged_in: false` clears the playback (nothing plays without a session).
251
+ */
252
+ export function fold(s: SpotifyState, f: Frame, asOf: string): SpotifyState {
253
+ switch (f.type) {
254
+ case "auth_state": {
255
+ const next = {
256
+ ...s,
257
+ ...dropUndefined({
258
+ loggedIn: f.logged_in,
259
+ active: f.is_active,
260
+ deviceName: f.device_name || undefined,
261
+ }),
262
+ asOf,
263
+ };
264
+ // signed out: nothing plays, nothing is queued
265
+ return f.logged_in === false
266
+ ? {
267
+ loggedIn: false,
268
+ ...dropUndefined({
269
+ deviceName: next.deviceName,
270
+ active: next.active,
271
+ }),
272
+ asOf,
273
+ }
274
+ : next;
275
+ }
276
+ case "playback_state": {
277
+ const { track: _t, context: _c, position: _p, ...rest } = s;
278
+ return dropUndefined({
279
+ ...rest,
280
+ loggedIn: true, // Soloist sends it only to a signed-in session
281
+ status: f.status ?? s.status,
282
+ track: toTrack(f.item),
283
+ context: toContext(f.context),
284
+ position: f.position ? toAnchor(f.position) : undefined,
285
+ volume: f.volume ?? s.volume,
286
+ active: f.is_active ?? s.active,
287
+ ...(f.options ? toOptions(f.options) : {}),
288
+ asOf,
289
+ });
290
+ }
291
+ case "track_changed": {
292
+ const { track: _t, ...rest } = s;
293
+ const track = toTrack(f.item);
294
+ return track ? { ...rest, track, asOf } : { ...rest, asOf };
295
+ }
296
+ case "playback_changed":
297
+ return f.status === undefined ? s : { ...s, status: f.status, asOf };
298
+ case "volume_changed":
299
+ return f.volume === undefined ? s : { ...s, volume: f.volume, asOf };
300
+ case "device_changed":
301
+ return {
302
+ ...s,
303
+ ...dropUndefined({
304
+ active: f.is_active,
305
+ deviceName: f.device_name || undefined,
306
+ }),
307
+ asOf,
308
+ };
309
+ case "context_changed": {
310
+ const { context: _c, ...rest } = s;
311
+ const context = toContext(f.context);
312
+ return context ? { ...rest, context, asOf } : { ...rest, asOf };
313
+ }
314
+ case "options_changed":
315
+ return f.options ? { ...s, ...toOptions(f.options), asOf } : s;
316
+ case "position_sync":
317
+ return f.position ? { ...s, position: toAnchor(f.position), asOf } : s;
318
+ case "queue_changed":
319
+ return f.upcoming
320
+ ? {
321
+ ...s,
322
+ upNext: f.upcoming
323
+ .map((e) => toTrack(e.item))
324
+ .filter((t): t is Track => !!t),
325
+ asOf,
326
+ }
327
+ : s;
328
+ default:
329
+ return s; // command_result and error answer a command; they change no state
330
+ }
331
+ }
332
+
333
+ /** What in the state is outside what Soloist documents, in a few words, or undefined. Passed through, never corrected. */
334
+ export function oddities(s: SpotifyState): string | undefined {
335
+ const odd: string[] = [];
336
+ if (s.status !== undefined && !STATUSES.has(s.status))
337
+ odd.push(`status "${s.status}"`);
338
+ if (s.volume !== undefined && !(s.volume >= 0 && s.volume <= 100))
339
+ odd.push(`volume ${s.volume}, outside 0–100`);
340
+ if (s.repeat !== undefined && !REPEATS.has(s.repeat))
341
+ odd.push(`repeat "${s.repeat}"`);
342
+ if (s.position?.speed !== undefined && !(s.position.speed >= 0))
343
+ odd.push(`position speed ${s.position.speed}`);
344
+ if (
345
+ s.position?.ms !== undefined &&
346
+ s.track?.durationMs !== undefined &&
347
+ s.position.ms > s.track.durationMs
348
+ )
349
+ odd.push(
350
+ `position ${s.position.ms} ms past the track's ${s.track.durationMs} ms`,
351
+ );
352
+ return odd.length ? odd.join("; ") : undefined;
353
+ }
354
+
355
+ /**
356
+ * The position now, in ms: the last anchor moved on by the time since, at its speed, while playing. Not held to the track's
357
+ * length (a reading past the end is the device's to explain), not rounded. Undefined without an anchor or a track.
358
+ */
359
+ export function positionMs(s: SpotifyState, now: number): number | undefined {
360
+ const p = s.position;
361
+ if (p?.ms === undefined || !s.track) return undefined;
362
+ if (s.status !== "playing" || p.atMs === undefined) return p.ms;
363
+ return p.ms + (now - p.atMs) * (p.speed ?? 1); // `now` before the anchor (clock skew) gives less, not clamped
364
+ }
365
+
366
+ /**
367
+ * A Spotify open link or URI as `spotify:<type>:<id>`, or undefined if it is neither. Short `spotify.link/…` links need a
368
+ * network redirect, so they are not taken.
369
+ */
370
+ export function toSpotifyUri(input: string): string | undefined {
371
+ const s = input.trim();
372
+ if (
373
+ /^spotify:(track|album|playlist|artist|episode|show):[A-Za-z0-9]+$/.test(s)
374
+ )
375
+ return s;
376
+ const link =
377
+ /^https?:\/\/open\.spotify\.com\/(?:intl-[a-z-]+\/)?(?:embed\/)?(track|album|playlist|artist|episode|show)\/([A-Za-z0-9]+)/.exec(
378
+ s,
379
+ );
380
+ return link ? `spotify:${link[1]}:${link[2]}` : undefined;
381
+ }
382
+
383
+ /** A command frame: `{ type: "command", command, ...fields }`. */
384
+ export const commandFrame = (
385
+ command: string,
386
+ fields: Record<string, unknown> = {},
387
+ ) => JSON.stringify({ type: "command", command, ...fields });