@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,745 @@
1
+ import { z } from "zod";
2
+ import { asOf, realClock } from "../../clock.js";
3
+ import { ConnectorError, errorText } from "../../connector.js";
4
+ import { writeLane } from "../../lane.js";
5
+ import { spotifyText, stallText } from "./changes.js";
6
+ import { commandFrame, fold, Frame, FRAME_TYPES, OPEN, oddities, positionMs, toSpotifyUri, } from "./client.js";
7
+ export { positionMs, toSpotifyUri, } from "./client.js";
8
+ export { speakerState, spotifyChanges, spotifyText, } from "./changes.js";
9
+ /** Soloist has no default port (`--ws ADDR:PORT` opens it); this library assumes this one, the port in chota-bot's unit. */
10
+ export const DEFAULT_URL = "ws://127.0.0.1:9091";
11
+ /** A Soloist speaker in a home config. No key: Soloist's API key is the daemon's, never the library's. */
12
+ export const configSchema = z.looseObject({
13
+ connector: z.literal("spotify"),
14
+ url: z
15
+ .url({ protocol: /^wss?$/ })
16
+ .optional()
17
+ .describe(`Soloist's WebSocket, as its --ws flag opens it; ${DEFAULT_URL} when left out. It has no auth: keep it on loopback`),
18
+ maxVolume: z
19
+ .number()
20
+ .int()
21
+ .min(0)
22
+ .max(100)
23
+ .optional()
24
+ .describe("the loudest a write may set (0–100), and where a first play that starts above it is lowered to (Soloist issue #23)"),
25
+ repairStalls: z
26
+ .boolean()
27
+ .optional()
28
+ .describe("when playback stalls while Soloist says playing (Soloist issue #5), try once per track to restart it: a seek, then pause and play, or a skip when it is stuck past the track's end. Off unless set"),
29
+ });
30
+ /** How long a command waits for Soloist's answer. Loopback answers in milliseconds; a stated guess. */
31
+ const COMMAND_TIMEOUT_MS = 3000;
32
+ /** How long a query holds the lane for its answer: a query Soloist doesn't answer (an empty queue may send nothing) shouldn't hold the writes behind it long. */
33
+ const QUERY_TIMEOUT_MS = 1000;
34
+ /** A socket that has said nothing this long after opening is closed: Soloist sends `auth_state` at once on connect. */
35
+ const HELLO_MS = 10_000;
36
+ /** After an idle → playing change, a volume above the cap is lowered for this long: the volume can come before or after the status (issue #23). */
37
+ const CAP_WINDOW_MS = 10_000;
38
+ /** How long a write waits for Soloist to report the change it asked for. Loopback events arrive in milliseconds; a stated guess. */
39
+ const CONFIRM_MS = 2000;
40
+ /** Between two commands. Soloist publishes no limit; it is on loopback; a stated guess. */
41
+ const WRITE_GAP_MS = 100;
42
+ /** While playing, a stall check this often: one `get_state`. Soloist issue #5 reports stalls 71–150 s into a track; a stated guess. */
43
+ const STALL_CHECK_MS = 20_000;
44
+ /** Still playing the same track this far past its length: stuck ("never moved on", issue #5). */
45
+ const OVERRUN_MS = 15_000;
46
+ /** After a repair's seek, how long before looking whether the position moves again. */
47
+ const REPAIR_WAIT_MS = 5000;
48
+ const MIN_BACKOFF_MS = 1000;
49
+ const MAX_BACKOFF_MS = 60_000;
50
+ /** Signed out is Soloist's normal state until someone picks the speaker (seen live 2026-11-08); a bad key looks the same (issue #18). */
51
+ const WAITING = "waiting for someone to pick it in the Spotify app; if nobody can, check Soloist's API key (Soloist issue #18)";
52
+ const REPEAT_ORDER = {
53
+ off: [
54
+ ["set_repeat_track", false],
55
+ ["set_repeat_context", false],
56
+ ],
57
+ context: [
58
+ ["set_repeat_track", false],
59
+ ["set_repeat_context", true],
60
+ ],
61
+ track: [
62
+ ["set_repeat_context", false],
63
+ ["set_repeat_track", true],
64
+ ],
65
+ };
66
+ /**
67
+ * A Spotify Soloist speaker as one integration: one WebSocket to Soloist, its `auth_state` and `playback_state` on connect,
68
+ * then its events folded in. A drop reconnects with backoff (1 s doubling to 60 s). Unreachable is `disconnected`;
69
+ * running without a Spotify session is `connected` with `loggedIn: false` and the detail saying so. The writes are on the
70
+ * same object, through one write lane, over the same socket.
71
+ */
72
+ export function spotify(config = {}) {
73
+ const clock = config.clock ?? realClock;
74
+ const url = config.url ?? DEFAULT_URL;
75
+ const cap = config.maxVolume;
76
+ const connect = config.connect ?? ((u) => new WebSocket(u));
77
+ const lane = writeLane({ gapMs: WRITE_GAP_MS, clock });
78
+ const checkMs = config.stallCheckMs ?? STALL_CHECK_MS;
79
+ let state = { asOf: asOf(clock) };
80
+ let socket;
81
+ let open = false;
82
+ let heard = false; // an auth_state on this socket: until then the part stays connecting
83
+ let whole = false; // signed in, the first playback_state has come too (or HELLO_MS passed): a one-shot read sees the track
84
+ let down; // why the last socket ended, while there is none open
85
+ let statusKnown = false; // Soloist has told this socket its status: only then is idle → playing an edge
86
+ let queueAsked = false; // get_queue sent on this socket
87
+ let signedIn = false; // signed out → in on this socket: the first status after it counts as a start if it is playing
88
+ let capUntil = -Infinity;
89
+ let capped = false; // the cap has been sent in this window
90
+ let capNote; // the last first-play lowering, in `anomaly` until the speaker goes idle
91
+ let frameNote; // a frame the schema rejected, or an error nothing was waiting for
92
+ let note; // a write the integration made itself that failed (the cap, interrupt's resume), in `detail`
93
+ let pending; // the lane sends one at a time
94
+ const waiters = new Set();
95
+ let report;
96
+ let wake; // cuts a backoff short on refresh
97
+ let reported = false;
98
+ const publish = () => {
99
+ let status;
100
+ let detail;
101
+ if (!open)
102
+ ((status = reported || down ? "disconnected" : "connecting"),
103
+ (detail = down));
104
+ else if (!heard || (state.loggedIn === true && !whole))
105
+ status = "connecting";
106
+ else
107
+ ((status = "connected"),
108
+ (detail = state.loggedIn === false ? WAITING : note));
109
+ // Connecting is reported never: before the first report createHome's placeholder says it, and a reconnect stays
110
+ // disconnected until Soloist has answered, so two reports in a row never hide a change behind a half-done connect.
111
+ if (status === "connecting")
112
+ return;
113
+ const anomaly = [capNote, stallText(state.stalled), oddities(state), frameNote]
114
+ .filter(Boolean)
115
+ .join("; ") || undefined;
116
+ frameNote = undefined;
117
+ reported = true;
118
+ report?.({ status, detail, anomaly, state });
119
+ };
120
+ const offline = () => new ConnectorError(`Soloist is not connected at ${url}`, {
121
+ code: "offline",
122
+ });
123
+ /**
124
+ * Send one command and wait for its answer: a control's `command_result`, or for a query the frame it `answers`
125
+ * (`get_state` → `playback_state`). An `error` carries no command, so it is the one in flight's: every command, queries
126
+ * too, goes through the lane, one at a time.
127
+ */
128
+ const command = (name, fields = {}, answers) => new Promise((resolve, reject) => {
129
+ const s = socket;
130
+ if (!s || !open || s.readyState !== OPEN)
131
+ return reject(offline());
132
+ if (state.loggedIn === false && !answers)
133
+ return reject(new ConnectorError(WAITING, { code: "waiting" }));
134
+ const stop = new AbortController();
135
+ const p = {
136
+ command: name,
137
+ answers,
138
+ finish: (error) => {
139
+ if (pending !== p)
140
+ return;
141
+ pending = undefined;
142
+ stop.abort();
143
+ if (error)
144
+ return reject(error);
145
+ if (!answers)
146
+ note = undefined; // a write that worked: an earlier failed one is no longer news
147
+ resolve();
148
+ },
149
+ };
150
+ pending = p;
151
+ s.send(commandFrame(name, fields));
152
+ const ms = answers ? QUERY_TIMEOUT_MS : COMMAND_TIMEOUT_MS;
153
+ void clock
154
+ .sleep(ms, stop.signal)
155
+ .then(() => stop.signal.aborted ||
156
+ p.finish(new ConnectorError(`Soloist did not answer ${name} in ${ms / 1000} s`, { code: "timeout" })));
157
+ });
158
+ const send = async (body) => {
159
+ const before = state;
160
+ const at = clock.now();
161
+ await command(body.command, body.fields, body.answers);
162
+ if (body.then)
163
+ await command(body.then.command, body.then.fields); // repeat's second half, still this write's turn
164
+ return { before, at };
165
+ };
166
+ let once = 0; // skips and queued tracks never merge: two nexts are two skips
167
+ const write = (slot, body) => lane.send(slot, body, send);
168
+ /**
169
+ * A write through the lane, resolved once the state shows that this caller's own change took, or `CONFIRM_MS` after
170
+ * Soloist accepted the command that carried it (a merged write's may be another's: a pause merged into a later play never
171
+ * confirms). A drop ends the wait early, unconfirmed.
172
+ */
173
+ const change = async (slot, body) => {
174
+ const sent = await write(slot, body);
175
+ const took = () => body.took?.(state, sent.before, sent.at) ?? true;
176
+ await waitFor(() => took() || !open, CONFIRM_MS);
177
+ return { state, confirmed: took() };
178
+ };
179
+ /** The track restarted: a new anchor near 0 when it had been 5 s or more in (a prev past the first seconds, a next on repeat-track). */
180
+ const restarted = (s, b, at) => s.position !== b.position &&
181
+ (s.position?.ms ?? Infinity) < 5000 &&
182
+ (positionMs(b, at) ?? 0) >= 5000;
183
+ const newTrack = (s, b) => !!s.track && s.track.uri !== b.track?.uri;
184
+ const invalid = (message) => new ConnectorError(message, { code: "invalid" });
185
+ const volumeTo = (v) => change("volume", {
186
+ command: "set_volume",
187
+ fields: { volume: v },
188
+ took: (s) => s.volume === v,
189
+ });
190
+ /** Ask Soloist for something, through the lane; the same query twice merges. A refusal is an anomaly, a drop is the status's to say. */
191
+ const ANSWERS = {
192
+ get_state: "playback_state",
193
+ get_auth_state: "auth_state",
194
+ get_queue: "queue_changed",
195
+ };
196
+ const query = (name, fields) => void write(`query-${name}`, {
197
+ command: name,
198
+ fields,
199
+ answers: ANSWERS[name],
200
+ }).catch((error) => {
201
+ // A drop is the status's to say; an unanswered get_queue may just be an empty queue (unverified).
202
+ if (error instanceof ConnectorError &&
203
+ (error.code === "offline" ||
204
+ (error.code === "timeout" && name === "get_queue")))
205
+ return;
206
+ frameNote = `${name} failed: ${errorText(error)}`;
207
+ publish();
208
+ });
209
+ /** Within the window after idle → playing, a volume above the cap is lowered to it, once (issue #23). */
210
+ const applyCap = () => {
211
+ if (cap === undefined ||
212
+ capped ||
213
+ state.volume === undefined ||
214
+ clock.now() > capUntil ||
215
+ state.volume <= cap)
216
+ return;
217
+ capped = true;
218
+ capNote = `started at volume ${state.volume}, above maxVolume ${cap}; lowered it (Soloist issue #23)`;
219
+ change("volume", {
220
+ command: "set_volume",
221
+ fields: { volume: cap },
222
+ took: (s) => s.volume === cap,
223
+ }).catch((error) => ((note = `lowering the volume to ${cap} failed: ${errorText(error)}`),
224
+ publish()));
225
+ };
226
+ const handle = (data) => {
227
+ let raw;
228
+ try {
229
+ raw = JSON.parse(String(data));
230
+ }
231
+ catch {
232
+ return;
233
+ }
234
+ const type = raw?.type;
235
+ if (typeof type !== "string" || !FRAME_TYPES.has(type))
236
+ return; // anything new: not read here
237
+ const parsed = Frame.safeParse(raw);
238
+ if (!parsed.success)
239
+ return ((frameNote = `a ${type} the schema did not recognise`),
240
+ publish());
241
+ const f = parsed.data;
242
+ if (f.type === "command_result")
243
+ return void (pending &&
244
+ pending.command === f.command &&
245
+ pending.finish());
246
+ if (f.type === "error") {
247
+ const message = f.message ?? "an error with no message";
248
+ if (pending)
249
+ return pending.finish(new ConnectorError(`Soloist refused ${pending.command}: ${message}`, {
250
+ code: "refused",
251
+ }));
252
+ return ((frameNote = `Soloist said: ${message}`), publish());
253
+ }
254
+ const before = state.status;
255
+ const wasSignedIn = state.loggedIn;
256
+ state = fold(state, f, asOf(clock));
257
+ if (f.type === "auth_state") {
258
+ // Signed out there is no playback_state to wait for, and a later sign-in must not send the part back to connecting.
259
+ if (!heard && f.logged_in !== true)
260
+ whole = true;
261
+ heard = true;
262
+ if (f.logged_in === false)
263
+ statusKnown = false;
264
+ // A first sign-in (someone picked the speaker) sends only auth_state; the docs don't promise a playback_state after it.
265
+ else if (f.logged_in === true && wasSignedIn === false)
266
+ ((signedIn = true), query("get_state"));
267
+ }
268
+ if (f.type === "playback_state") {
269
+ if (!queueAsked)
270
+ ((queueAsked = true), query("get_queue", { limit: 10 })); // queue_changed pushes changes only, so ask once per socket
271
+ whole = true;
272
+ }
273
+ if (pending?.answers === f.type)
274
+ pending.finish();
275
+ if ((f.type === "playback_state" || f.type === "playback_changed") &&
276
+ f.status !== undefined) {
277
+ // A start is idle → playing, or a sign-in whose first status is playing: a phone picked the speaker and played (issue #23).
278
+ const started = f.status === "playing" && (statusKnown ? before === "idle" : signedIn);
279
+ if (started)
280
+ ((capUntil = clock.now() + CAP_WINDOW_MS), (capped = false));
281
+ signedIn = false;
282
+ if (f.status === "idle")
283
+ capNote = undefined;
284
+ statusKnown = true;
285
+ }
286
+ stallOver();
287
+ if (state.status !== "playing")
288
+ seen = undefined; // a pause between two checks must not read as frozen
289
+ applyCap();
290
+ for (const w of [...waiters])
291
+ w();
292
+ publish();
293
+ };
294
+ /** One socket's life: resolves with why it ended, and whether it got as far as open. */
295
+ const session = (signal) => new Promise((resolve) => {
296
+ let s;
297
+ try {
298
+ s = connect(url);
299
+ }
300
+ catch (error) {
301
+ return resolve({
302
+ why: `could not open ${url}: ${errorText(error)}`,
303
+ opened: false,
304
+ });
305
+ }
306
+ socket = s;
307
+ const quiet = new AbortController();
308
+ let ended = false;
309
+ const end = (why) => {
310
+ if (ended)
311
+ return;
312
+ ended = true;
313
+ quiet.abort();
314
+ signal.removeEventListener("abort", stop);
315
+ if (socket === s)
316
+ socket = undefined;
317
+ const was = open;
318
+ open = false;
319
+ heard = false;
320
+ whole = false;
321
+ queueAsked = false;
322
+ statusKnown = false;
323
+ signedIn = false;
324
+ pending?.finish(offline());
325
+ for (const w of [...waiters])
326
+ w(); // a write waiting to confirm stops waiting
327
+ seen = undefined;
328
+ resolve({ why: was ? why : `nothing answered at ${url}`, opened: was });
329
+ };
330
+ const stop = () => (end("stopped"), s.close());
331
+ signal.addEventListener("abort", stop, { once: true });
332
+ s.onopen = () => {
333
+ open = true;
334
+ down = undefined;
335
+ note = undefined; // a new socket: what failed on the last one is history
336
+ void clock.sleep(HELLO_MS, quiet.signal).then(() => {
337
+ if (quiet.signal.aborted)
338
+ return;
339
+ if (!heard)
340
+ return (end(`Soloist said nothing in ${HELLO_MS / 1000} s`),
341
+ s.close());
342
+ if (!whole)
343
+ ((whole = true), publish()); // signed in, no playback_state: connected with what there is
344
+ });
345
+ };
346
+ s.onmessage = (ev) => handle(ev.data);
347
+ s.onerror = () => { }; // onclose follows and says it
348
+ s.onclose = () => end("Soloist closed the socket");
349
+ });
350
+ /** Until `ok()` or `ms`, whichever first; each state change looks again. */
351
+ const waitFor = async (ok, ms) => {
352
+ if (ok())
353
+ return;
354
+ const done = new AbortController();
355
+ const check = () => ok() && done.abort();
356
+ waiters.add(check);
357
+ await clock.sleep(ms, done.signal);
358
+ waiters.delete(check);
359
+ };
360
+ /** Not connected or not playing: the reason `duck` and `interrupt` just run `fn`. */
361
+ const notPlaying = () => !open
362
+ ? "Soloist is not connected"
363
+ : state.status === "playing" || state.status === "buffering"
364
+ ? undefined
365
+ : "it was not playing";
366
+ /** A write the integration made itself that failed: in `detail`, and the reason returned. */
367
+ const failed = (why) => ((note = why), publish(), why);
368
+ let asides = Promise.resolve();
369
+ /** `state.aside` set or cleared, so `spotifyChanges` can mark what happens meanwhile as the library's own. */
370
+ const marked = (kind) => {
371
+ const { aside: _, ...rest } = state;
372
+ state = kind ? { ...rest, aside: kind } : rest;
373
+ stallOver(); // a repair's skip changed the track while it ran
374
+ publish();
375
+ };
376
+ /**
377
+ * Run `fn` between `enter` and `leave`, one aside at a time. `enter` returns why it changed nothing (then `fn` just runs);
378
+ * `leave` puts things back, or says why not, and runs even if `fn` throws.
379
+ */
380
+ const aside = (kind, fn, enter, leave) => {
381
+ const run = async () => {
382
+ if (open)
383
+ marked(kind);
384
+ try {
385
+ return await within();
386
+ }
387
+ finally {
388
+ if (state.aside)
389
+ marked();
390
+ }
391
+ };
392
+ const within = async () => {
393
+ const skipped = await enter();
394
+ if (skipped)
395
+ return { value: await fn(), music: "untouched", why: skipped };
396
+ let value;
397
+ try {
398
+ value = await fn();
399
+ }
400
+ catch (error) {
401
+ await leave(); // its outcome is in `detail` when it fails; the error is fn's
402
+ throw error;
403
+ }
404
+ return { value, ...(await leave()) };
405
+ };
406
+ const p = asides.then(run, run);
407
+ asides = p.catch(() => { });
408
+ return p;
409
+ };
410
+ const putBack = (r, what) => r.confirmed
411
+ ? { music: "restored" }
412
+ : {
413
+ music: "failed",
414
+ why: failed(`sent the ${what}; Soloist has not reported it`),
415
+ };
416
+ const actions = {
417
+ async play(linkOrUri) {
418
+ if (linkOrUri === undefined)
419
+ return change("transport", {
420
+ command: "play",
421
+ took: (s) => s.status === "playing",
422
+ });
423
+ const uri = toSpotifyUri(linkOrUri);
424
+ if (!uri)
425
+ throw invalid(`not a Spotify link or URI: ${linkOrUri}`);
426
+ return change("transport", {
427
+ command: "play",
428
+ fields: { uri },
429
+ took: (s) => s.status === "playing" &&
430
+ (s.track?.uri === uri || s.context?.uri === uri),
431
+ });
432
+ },
433
+ pause: () => change("transport", {
434
+ command: "pause",
435
+ took: (s) => s.status === "paused",
436
+ }),
437
+ resume: () => change("transport", {
438
+ command: "play",
439
+ took: (s) => s.status === "playing",
440
+ }),
441
+ // The end of the queue goes idle; repeat-track restarts it.
442
+ next: () => change(`once-${++once}`, {
443
+ command: "skip_next",
444
+ took: (s, b, at) => newTrack(s, b) ||
445
+ (s.status === "idle" && b.status !== "idle") ||
446
+ restarted(s, b, at),
447
+ }),
448
+ prev: () => change(`once-${++once}`, {
449
+ command: "skip_prev",
450
+ took: (s, b, at) => newTrack(s, b) || restarted(s, b, at),
451
+ }),
452
+ async seek(ms) {
453
+ if (!Number.isFinite(ms) || ms < 0)
454
+ throw invalid(`a position in ms, 0 or more, not ${ms}`);
455
+ return change("seek", {
456
+ command: "seek",
457
+ fields: { position_ms: ms },
458
+ took: (s, b) => s.position !== b.position &&
459
+ Math.abs((s.position?.ms ?? -Infinity) - ms) < 2000,
460
+ });
461
+ },
462
+ async setVolume(volume) {
463
+ if (!Number.isFinite(volume) || volume < 0 || volume > 100)
464
+ throw invalid(`volume is 0–100, not ${volume}`);
465
+ return volumeTo(Math.round(cap === undefined ? volume : Math.min(volume, cap)));
466
+ },
467
+ async queue(track) {
468
+ const uri = toSpotifyUri(track);
469
+ if (!uri?.startsWith("spotify:track:"))
470
+ throw invalid(`only a Spotify track can be queued, not ${track}`);
471
+ return change(`once-${++once}`, {
472
+ command: "add_to_queue",
473
+ fields: { uri },
474
+ took: (s) => !!s.upNext?.some((t) => t.uri === uri),
475
+ });
476
+ },
477
+ setShuffle: (on) => change("shuffle", {
478
+ command: "set_shuffle",
479
+ fields: { enabled: on },
480
+ took: (s) => s.shuffle === on,
481
+ }),
482
+ async setRepeat(mode) {
483
+ // developer.spotify.com/documentation/soloist/reference/websocket-api: clear the other mode first, then set this one.
484
+ if (!Object.hasOwn(REPEAT_ORDER, mode))
485
+ throw invalid(`repeat is off, context or track, not ${mode}`);
486
+ const [first, second] = REPEAT_ORDER[mode];
487
+ return change("repeat", {
488
+ command: first[0],
489
+ fields: { enabled: first[1] },
490
+ then: { command: second[0], fields: { enabled: second[1] } },
491
+ took: (s) => s.repeat === mode,
492
+ });
493
+ },
494
+ interrupt(fn) {
495
+ let track;
496
+ let shown = false; // the pause showed
497
+ return aside("interrupt", fn, async () => {
498
+ const no = notPlaying();
499
+ if (no)
500
+ return no;
501
+ track = state.track?.uri;
502
+ try {
503
+ shown = (await actions.pause()).confirmed; // a pause that never shows still runs fn: what interrupts matters more
504
+ }
505
+ catch (error) {
506
+ return failed(`pause before interrupt failed: ${errorText(error)}`);
507
+ }
508
+ }, async () => {
509
+ if (state.status !== "paused")
510
+ return {
511
+ music: "kept",
512
+ why: shown
513
+ ? `someone changed it meanwhile: it is ${state.status ?? "in an unknown state"}`
514
+ : "Soloist never reported the pause",
515
+ };
516
+ if (state.track?.uri !== track)
517
+ return { music: "kept", why: "the track changed meanwhile" };
518
+ try {
519
+ return putBack(await actions.resume(), "resume");
520
+ }
521
+ catch (error) {
522
+ return {
523
+ music: "failed",
524
+ why: failed(`resume after interrupt failed: ${errorText(error)}`),
525
+ };
526
+ }
527
+ });
528
+ },
529
+ duck(fn, { to }) {
530
+ if (!Number.isFinite(to) || to < 0 || to > 100)
531
+ return Promise.reject(invalid(`duck's volume is 0–100, not ${to}`));
532
+ let earlier = 0;
533
+ let shown = false; // the lowered volume showed
534
+ return aside("duck", fn, async () => {
535
+ const no = notPlaying();
536
+ if (no)
537
+ return no;
538
+ if (state.volume === undefined)
539
+ return "its volume is not known";
540
+ if (state.volume <= to)
541
+ return `it was already at ${state.volume}`;
542
+ earlier = state.volume;
543
+ try {
544
+ shown = (await volumeTo(Math.round(to))).confirmed;
545
+ }
546
+ catch (error) {
547
+ return failed(`lowering the volume for duck failed: ${errorText(error)}`);
548
+ }
549
+ }, async () => {
550
+ if (state.volume !== Math.round(to))
551
+ return {
552
+ music: "kept",
553
+ why: shown
554
+ ? `someone set the volume to ${state.volume} meanwhile`
555
+ : "Soloist never reported the lower volume",
556
+ };
557
+ try {
558
+ return putBack(await volumeTo(earlier), `volume ${earlier}`);
559
+ }
560
+ catch (error) {
561
+ return {
562
+ music: "failed",
563
+ why: failed(`restoring the volume to ${earlier} after duck failed: ${errorText(error)}`),
564
+ };
565
+ }
566
+ });
567
+ },
568
+ };
569
+ /** The stall set or cleared, through `state` so a snapshot and `spotifyChanges` see it. */
570
+ const stallAs = (stall) => {
571
+ const { stalled: _, ...rest } = state;
572
+ state = stall ? { ...rest, stalled: stall } : rest;
573
+ publish();
574
+ };
575
+ /** A stall ends when its track changes or stops being played; not during the repair's own pause or skip. */
576
+ function stallOver() {
577
+ const st = state.stalled;
578
+ if (!st ||
579
+ state.aside === "repair" ||
580
+ (state.track?.uri === st.track &&
581
+ (state.status === "playing" || state.status === "buffering")))
582
+ return;
583
+ const { stalled: _, ...rest } = state;
584
+ state = rest;
585
+ }
586
+ let seen; // the last check's reported position
587
+ let repairedFor; // at most one repair per track
588
+ /** Ask Soloist where it is, waiting for the answer (or the query's timeout). */
589
+ const fresh = () => write("query-get_state", {
590
+ command: "get_state",
591
+ answers: "playback_state",
592
+ }).then(() => undefined, () => undefined);
593
+ const where = () => state.track &&
594
+ state.position?.ms !== undefined &&
595
+ state.position.atMs !== undefined
596
+ ? {
597
+ track: state.track.uri,
598
+ ms: state.position.ms,
599
+ atMs: state.position.atMs,
600
+ }
601
+ : undefined;
602
+ /** Two fresh reports of one track, at least `gap` apart by Soloist's own clock, less than a second apart in position. */
603
+ const frozen = (a, b, gap = checkMs / 2) => !!a &&
604
+ !!b &&
605
+ a.track === b.track &&
606
+ b.atMs - a.atMs >= gap &&
607
+ Math.abs(b.ms - a.ms) < 1000;
608
+ const playing = () => open && state.status === "playing" && !state.aside;
609
+ /** One look while playing: a fresh position, then frozen, overrun, recovered or fine (Soloist issue #5). */
610
+ const checkStall = async () => {
611
+ if (!playing())
612
+ return void (seen = undefined);
613
+ await fresh();
614
+ if (!playing())
615
+ return void (seen = undefined);
616
+ const now = where();
617
+ const track = state.track;
618
+ if (!track)
619
+ return void (seen = undefined);
620
+ // Repeat-track loops one track, so running past its length is normal there.
621
+ const over = state.repeat !== "track" &&
622
+ track.durationMs !== undefined &&
623
+ (positionMs(state, clock.now()) ?? 0) > track.durationMs + OVERRUN_MS;
624
+ const stuck = frozen(seen, now);
625
+ if (now &&
626
+ seen &&
627
+ now.track === seen.track &&
628
+ !stuck &&
629
+ !over &&
630
+ state.stalled?.track === now.track)
631
+ stallAs(); // it moves again: recovered
632
+ else if ((stuck || over) && !state.stalled) {
633
+ stallAs({
634
+ track: track.uri,
635
+ ms: now?.ms ?? 0,
636
+ how: over ? "overrun" : "frozen",
637
+ });
638
+ if (config.repairStalls && repairedFor !== track.uri)
639
+ await repair(track.uri, over);
640
+ }
641
+ seen = where();
642
+ };
643
+ /** Once per track: a skip when stuck past the end; otherwise a seek to where it stuck, then pause and play if it still doesn't move. */
644
+ const repair = (track, over) => {
645
+ repairedFor = track;
646
+ const tried = (what) => state.stalled && stallAs({ ...state.stalled, tried: what });
647
+ // Still ours to fix: playing this track, stalled, and no duck, interrupt or person in between.
648
+ const ours = () => open &&
649
+ state.status === "playing" &&
650
+ state.track?.uri === track &&
651
+ state.stalled?.track === track &&
652
+ (!state.aside || state.aside === "repair");
653
+ const run = async () => {
654
+ if (!ours())
655
+ return;
656
+ marked("repair");
657
+ try {
658
+ if (over)
659
+ return void (tried("a skip"), await actions.next());
660
+ await actions.seek(state.stalled.ms);
661
+ tried("a seek");
662
+ const at = where(); // where the seek left it
663
+ await clock.sleep(REPAIR_WAIT_MS);
664
+ if (!ours())
665
+ return;
666
+ await fresh();
667
+ if (!ours() || !frozen(at, where(), REPAIR_WAIT_MS / 2))
668
+ return;
669
+ await actions.pause();
670
+ await actions.resume();
671
+ tried("a seek, then pause and play");
672
+ }
673
+ catch (error) {
674
+ tried(`a repair that failed: ${errorText(error)}`);
675
+ }
676
+ finally {
677
+ if (state.aside === "repair")
678
+ marked();
679
+ }
680
+ };
681
+ // In line with duck and interrupt: one aside at a time, so a repair never runs inside an announcement.
682
+ const p = asides.then(run, run);
683
+ asides = p.catch(() => { });
684
+ return p;
685
+ };
686
+ return {
687
+ ...actions,
688
+ name: "spotify",
689
+ /** Ask Soloist for its state again (`get_state`, or `get_auth_state` without a session); while it is unreachable, reconnect now. */
690
+ refresh() {
691
+ const s = socket;
692
+ if (s && open && s.readyState === OPEN)
693
+ return query(state.loggedIn === false || !heard ? "get_auth_state" : "get_state");
694
+ wake?.abort();
695
+ },
696
+ async run(signal, update) {
697
+ report = update;
698
+ if (checkMs > 0)
699
+ void (async () => {
700
+ while (!signal.aborted) {
701
+ await clock.sleep(checkMs, signal);
702
+ if (!signal.aborted)
703
+ await checkStall().catch((error) => ((frameNote = `stall check failed: ${errorText(error)}`),
704
+ publish()));
705
+ }
706
+ })();
707
+ let backoff = MIN_BACKOFF_MS;
708
+ while (!signal.aborted) {
709
+ const { why, opened } = await session(signal);
710
+ if (signal.aborted)
711
+ return;
712
+ if (opened)
713
+ backoff = MIN_BACKOFF_MS; // it had been up: start over
714
+ down = why;
715
+ publish();
716
+ wake = new AbortController();
717
+ await clock.sleep(backoff, AbortSignal.any([signal, wake.signal]));
718
+ backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
719
+ }
720
+ },
721
+ };
722
+ }
723
+ export const manifest = {
724
+ name: "spotify",
725
+ title: "Spotify (Soloist)",
726
+ iotClass: "local_push",
727
+ access: "Soloist's WebSocket on loopback, no key",
728
+ env: [
729
+ {
730
+ name: "SOLOIST_WS",
731
+ example: DEFAULT_URL,
732
+ what: "Soloist's --ws address as a URL; `home status` skips the speaker without it",
733
+ },
734
+ {
735
+ name: "SOLOIST_MAX_VOLUME",
736
+ example: "60",
737
+ optional: true,
738
+ what: "the loudest `home spotify volume` sets, and where a first play above it is lowered to",
739
+ },
740
+ ],
741
+ /** Nothing until Soloist has said something: a speaker never reached has no line. */
742
+ lines: ({ state }) => state && (state.loggedIn !== undefined || state.status !== undefined)
743
+ ? [spotifyText(state)]
744
+ : [],
745
+ };