@khalido/home 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +60 -2
- package/README.md +12 -12
- package/dist/clock.js +2 -0
- package/dist/config.js +3 -1
- package/dist/connector.d.ts +9 -2
- package/dist/connectors/amber/index.js +1 -0
- package/dist/connectors/cast/index.d.ts +10 -5
- package/dist/connectors/cast/index.js +36 -4
- package/dist/connectors/cast/schemas.d.ts +2 -0
- package/dist/connectors/cast/schemas.js +1 -0
- package/dist/connectors/cast/state.d.ts +56 -0
- package/dist/connectors/cast/state.js +134 -0
- package/dist/connectors/cast/writes.d.ts +9 -2
- package/dist/connectors/cast/writes.js +67 -13
- package/dist/connectors/daikin/index.d.ts +6 -2
- package/dist/connectors/daikin/index.js +21 -1
- package/dist/connectors/enphase/index.d.ts +5 -1
- package/dist/connectors/enphase/index.js +34 -2
- package/dist/connectors/hue/index.d.ts +6 -2
- package/dist/connectors/hue/index.js +39 -6
- package/dist/connectors/printers/index.d.ts +6 -2
- package/dist/connectors/printers/index.js +23 -0
- package/dist/connectors/spotify/changes.d.ts +1 -1
- package/dist/connectors/spotify/changes.js +4 -2
- package/dist/connectors/spotify/client.d.ts +1 -1
- package/dist/connectors/spotify/index.d.ts +9 -2
- package/dist/connectors/spotify/index.js +130 -45
- package/dist/connectors/up/index.d.ts +4 -2
- package/dist/connectors/up/index.js +3 -1
- package/dist/connectors/weather/bom.js +1 -0
- package/dist/connectors/weather/google.js +52 -9
- package/dist/connectors/weather/index.d.ts +2 -1
- package/dist/connectors/weather/index.js +7 -0
- package/dist/connectors/weather/source.d.ts +2 -0
- package/dist/devices.d.ts +27 -0
- package/dist/devices.js +61 -0
- package/dist/discover.d.ts +2 -0
- package/dist/discover.js +4 -1
- package/dist/health.d.ts +2 -1
- package/dist/health.js +2 -1
- package/dist/home.d.ts +41 -3
- package/dist/home.js +24 -7
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/lane.js +24 -20
- package/docs/cast.md +1 -1
- package/docs/cli.md +3 -3
- package/docs/config.md +8 -3
- package/docs/decisions.md +5 -0
- package/docs/using.md +17 -0
- package/package.json +1 -1
- package/skills/use-home/SKILL.md +7 -7
- package/src/cli/cast.ts +19 -11
- package/src/cli/enphase.ts +5 -1
- package/src/cli/spotify.ts +2 -1
- package/src/cli/status.ts +26 -3
- package/src/clock.ts +1 -0
- package/src/config.ts +3 -1
- package/src/connector.ts +18 -2
- package/src/connectors/amber/index.ts +1 -0
- package/src/connectors/cast/README.md +10 -5
- package/src/connectors/cast/index.ts +74 -9
- package/src/connectors/cast/schemas.ts +1 -0
- package/src/connectors/cast/state.ts +189 -0
- package/src/connectors/cast/writes.ts +87 -17
- package/src/connectors/daikin/README.md +4 -0
- package/src/connectors/daikin/index.ts +41 -2
- package/src/connectors/enphase/index.ts +49 -2
- package/src/connectors/hue/index.ts +57 -8
- package/src/connectors/printers/README.md +4 -2
- package/src/connectors/printers/index.ts +43 -1
- package/src/connectors/spotify/README.md +33 -9
- package/src/connectors/spotify/changes.ts +4 -2
- package/src/connectors/spotify/client.ts +1 -1
- package/src/connectors/spotify/index.ts +157 -49
- package/src/connectors/up/README.md +1 -1
- package/src/connectors/up/index.ts +6 -2
- package/src/connectors/weather/README.md +1 -1
- package/src/connectors/weather/bom.ts +1 -0
- package/src/connectors/weather/google.ts +66 -10
- package/src/connectors/weather/index.ts +16 -2
- package/src/connectors/weather/source.ts +2 -0
- package/src/devices.ts +102 -0
- package/src/discover.ts +6 -2
- package/src/health.ts +4 -2
- package/src/home.ts +75 -8
- package/src/index.ts +5 -1
- package/src/lane.ts +23 -20
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { asOf, realClock } from "../../clock.js";
|
|
3
3
|
import { ConnectorError, errorText } from "../../connector.js";
|
|
4
|
+
import { deviceRecords, } from "../../home.js";
|
|
4
5
|
import { writeLane } from "../../lane.js";
|
|
5
|
-
import { spotifyText, stallText } from "./changes.js";
|
|
6
|
+
import { speakerState, spotifyText, stallText, } from "./changes.js";
|
|
6
7
|
import { commandFrame, fold, Frame, FRAME_TYPES, OPEN, oddities, positionMs, toSpotifyUri, } from "./client.js";
|
|
7
8
|
export { positionMs, toSpotifyUri, } from "./client.js";
|
|
8
9
|
export { speakerState, spotifyChanges, spotifyText, } from "./changes.js";
|
|
@@ -33,10 +34,12 @@ const COMMAND_TIMEOUT_MS = 3000;
|
|
|
33
34
|
const QUERY_TIMEOUT_MS = 1000;
|
|
34
35
|
/** A socket that has said nothing this long after opening is closed: Soloist sends `auth_state` at once on connect. */
|
|
35
36
|
const HELLO_MS = 10_000;
|
|
36
|
-
/** After
|
|
37
|
+
/** After a start signal, a volume above the cap is lowered for this long: the volume can come before or after the status (issue #23). */
|
|
37
38
|
const CAP_WINDOW_MS = 10_000;
|
|
38
39
|
/** How long a write waits for Soloist to report the change it asked for. Loopback events arrive in milliseconds; a stated guess. */
|
|
39
40
|
const CONFIRM_MS = 2000;
|
|
41
|
+
/** Between the two halves of `setRepeat`: back to back left repeat on `track`, 600 ms apart worked (live, 2026-11-09). */
|
|
42
|
+
const REPEAT_GAP_MS = 600;
|
|
40
43
|
/** Between two commands. Soloist publishes no limit; it is on loopback; a stated guess. */
|
|
41
44
|
const WRITE_GAP_MS = 100;
|
|
42
45
|
/** While playing, a stall check this often: one `get_state`. Soloist issue #5 reports stalls 71–150 s into a track; a stated guess. */
|
|
@@ -45,6 +48,8 @@ const STALL_CHECK_MS = 20_000;
|
|
|
45
48
|
const OVERRUN_MS = 15_000;
|
|
46
49
|
/** After a repair's seek, how long before looking whether the position moves again. */
|
|
47
50
|
const REPAIR_WAIT_MS = 5000;
|
|
51
|
+
/** A reported volume is rounded (phone steps are sixteenths: 43.75 shows as 44), so "it took" allows this much. */
|
|
52
|
+
const VOLUME_SLACK = 1;
|
|
48
53
|
const MIN_BACKOFF_MS = 1000;
|
|
49
54
|
const MAX_BACKOFF_MS = 60_000;
|
|
50
55
|
/** 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). */
|
|
@@ -78,15 +83,16 @@ export function spotify(config = {}) {
|
|
|
78
83
|
const checkMs = config.stallCheckMs ?? STALL_CHECK_MS;
|
|
79
84
|
let state = { asOf: asOf(clock) };
|
|
80
85
|
let socket;
|
|
81
|
-
|
|
82
|
-
let
|
|
83
|
-
|
|
86
|
+
// This socket's progress. "heard": signed in, the first playback_state still to come. "whole": a one-shot read sees the track (or HELLO_MS passed).
|
|
87
|
+
let link = "down";
|
|
88
|
+
const isOpen = () => link !== "down";
|
|
84
89
|
let down; // why the last socket ended, while there is none open
|
|
85
90
|
let statusKnown = false; // Soloist has told this socket its status: only then is idle → playing an edge
|
|
86
91
|
let queueAsked = false; // get_queue sent on this socket
|
|
87
92
|
let signedIn = false; // signed out → in on this socket: the first status after it counts as a start if it is playing
|
|
88
93
|
let capUntil = -Infinity;
|
|
89
|
-
let capped = false; // the cap
|
|
94
|
+
let capped = false; // the cap took in this window
|
|
95
|
+
let capping = false; // a lowering is on its way
|
|
90
96
|
let startBuffering = false; // a start from idle is still buffering
|
|
91
97
|
let capNote; // the last first-play lowering, in `anomaly` until the speaker goes idle
|
|
92
98
|
let frameNote; // a frame the schema rejected, or an error nothing was waiting for
|
|
@@ -99,10 +105,10 @@ export function spotify(config = {}) {
|
|
|
99
105
|
const publish = () => {
|
|
100
106
|
let status;
|
|
101
107
|
let detail;
|
|
102
|
-
if (
|
|
108
|
+
if (link === "down")
|
|
103
109
|
((status = reported || down ? "disconnected" : "connecting"),
|
|
104
110
|
(detail = down));
|
|
105
|
-
else if (
|
|
111
|
+
else if (link !== "whole")
|
|
106
112
|
status = "connecting";
|
|
107
113
|
else
|
|
108
114
|
((status = "connected"),
|
|
@@ -128,7 +134,7 @@ export function spotify(config = {}) {
|
|
|
128
134
|
*/
|
|
129
135
|
const command = (name, fields = {}, answers) => new Promise((resolve, reject) => {
|
|
130
136
|
const s = socket;
|
|
131
|
-
if (!s || !
|
|
137
|
+
if (!s || !isOpen() || s.readyState !== OPEN)
|
|
132
138
|
return reject(offline());
|
|
133
139
|
if (state.loggedIn === false && !answers)
|
|
134
140
|
return reject(new ConnectorError(WAITING, { code: "waiting" }));
|
|
@@ -157,11 +163,17 @@ export function spotify(config = {}) {
|
|
|
157
163
|
p.finish(new ConnectorError(`Soloist did not answer ${name} in ${ms / 1000} s`, { code: "timeout" })));
|
|
158
164
|
});
|
|
159
165
|
const send = async (body) => {
|
|
166
|
+
if (body.activeFirst && inactive()) {
|
|
167
|
+
await command("activate").catch(() => { }); // best effort: the write itself says how it went
|
|
168
|
+
await waitFor(() => state.active === true || !isOpen(), CONFIRM_MS);
|
|
169
|
+
}
|
|
160
170
|
const before = state;
|
|
161
171
|
const at = clock.now();
|
|
162
172
|
await command(body.command, body.fields, body.answers);
|
|
163
|
-
if (body.then)
|
|
173
|
+
if (body.then) {
|
|
174
|
+
await clock.sleep(REPEAT_GAP_MS);
|
|
164
175
|
await command(body.then.command, body.then.fields); // repeat's second half, still this write's turn
|
|
176
|
+
}
|
|
165
177
|
return { before, at };
|
|
166
178
|
};
|
|
167
179
|
let once = 0; // skips and queued tracks never merge: two nexts are two skips
|
|
@@ -174,7 +186,7 @@ export function spotify(config = {}) {
|
|
|
174
186
|
const change = async (slot, body) => {
|
|
175
187
|
const sent = await write(slot, body);
|
|
176
188
|
const took = () => body.took?.(state, sent.before, sent.at) ?? true;
|
|
177
|
-
await waitFor(() => took() || !
|
|
189
|
+
await waitFor(() => took() || !isOpen(), CONFIRM_MS);
|
|
178
190
|
return { state, confirmed: took() };
|
|
179
191
|
};
|
|
180
192
|
/** 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). */
|
|
@@ -183,11 +195,14 @@ export function spotify(config = {}) {
|
|
|
183
195
|
(positionMs(b, at) ?? 0) >= 5000;
|
|
184
196
|
const newTrack = (s, b) => !!s.track && s.track.uri !== b.track?.uri;
|
|
185
197
|
const invalid = (message) => new ConnectorError(message, { code: "invalid" });
|
|
198
|
+
const near = (volume, v) => volume !== undefined && Math.abs(volume - v) <= VOLUME_SLACK;
|
|
186
199
|
const volumeTo = (v) => change("volume", {
|
|
187
200
|
command: "set_volume",
|
|
188
201
|
fields: { volume: v },
|
|
189
|
-
took: (s) => s.volume
|
|
202
|
+
took: (s) => near(s.volume, v),
|
|
190
203
|
});
|
|
204
|
+
/** Soloist accepts a `set_volume` while it isn't the active device and drops it (three times live, 2026-11-09). */
|
|
205
|
+
const inactive = () => isOpen() && state.loggedIn !== false && state.active === false;
|
|
191
206
|
/** 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. */
|
|
192
207
|
const ANSWERS = {
|
|
193
208
|
get_state: "playback_state",
|
|
@@ -207,22 +222,50 @@ export function spotify(config = {}) {
|
|
|
207
222
|
frameNote = `${name} failed: ${errorText(error)}`;
|
|
208
223
|
publish();
|
|
209
224
|
});
|
|
210
|
-
/**
|
|
225
|
+
/**
|
|
226
|
+
* Within the window after a start signal, a volume above the cap is lowered to it (issue #23). Only while active, since
|
|
227
|
+
* Soloist drops it otherwise, and retried until Soloist reports it. A volume raised after that is someone's choice;
|
|
228
|
+
* one raised before it is reported can't be told from the start's own jump, so it is lowered too.
|
|
229
|
+
*/
|
|
211
230
|
const applyCap = () => {
|
|
212
231
|
if (cap === undefined ||
|
|
213
232
|
capped ||
|
|
233
|
+
capping ||
|
|
214
234
|
state.volume === undefined ||
|
|
215
|
-
|
|
216
|
-
state.
|
|
235
|
+
state.volume <= cap + VOLUME_SLACK ||
|
|
236
|
+
state.active === false ||
|
|
237
|
+
clock.now() > capUntil)
|
|
217
238
|
return;
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
239
|
+
capping = true;
|
|
240
|
+
const from = state.volume;
|
|
241
|
+
const window = capUntil;
|
|
242
|
+
volumeTo(cap).then(({ confirmed }) => {
|
|
243
|
+
capping = false;
|
|
244
|
+
if (capUntil !== window)
|
|
245
|
+
return applyCap(); // a new start while it went: this lowering doesn't speak for that one
|
|
246
|
+
if (confirmed) {
|
|
247
|
+
capped = true;
|
|
248
|
+
if (state.status === "idle")
|
|
249
|
+
return;
|
|
250
|
+
capNote = `started at volume ${from}, above maxVolume ${cap}; lowered it (Soloist issue #23)`;
|
|
251
|
+
note = undefined;
|
|
252
|
+
return publish();
|
|
253
|
+
}
|
|
254
|
+
if (clock.now() > capUntil) {
|
|
255
|
+
note = `could not lower the volume to ${cap}: Soloist has not reported it`;
|
|
256
|
+
return publish();
|
|
257
|
+
}
|
|
258
|
+
applyCap();
|
|
259
|
+
}, (error) => {
|
|
260
|
+
capping = false;
|
|
261
|
+
if (error instanceof ConnectorError && error.code === "offline")
|
|
262
|
+
return; // the status says it
|
|
263
|
+
if (capUntil !== window)
|
|
264
|
+
return applyCap(); // a new start while it went: this failure doesn't speak for that one
|
|
265
|
+
capped = true; // refused or unanswered: not again this window
|
|
266
|
+
note = `lowering the volume to ${cap} failed: ${errorText(error)}`;
|
|
267
|
+
publish();
|
|
268
|
+
});
|
|
226
269
|
};
|
|
227
270
|
const handle = (data) => {
|
|
228
271
|
let raw;
|
|
@@ -254,12 +297,17 @@ export function spotify(config = {}) {
|
|
|
254
297
|
}
|
|
255
298
|
const before = state.status;
|
|
256
299
|
const wasSignedIn = state.loggedIn;
|
|
300
|
+
const wasActive = link === "open" ? undefined : state.active; // the first frames on a socket are not a change
|
|
257
301
|
state = fold(state, f, asOf(clock));
|
|
302
|
+
if (state.track?.uri !== repairedFor)
|
|
303
|
+
repairedFor = undefined; // one repair per play of a track
|
|
304
|
+
// Going active makes a new stream, at WirePlumber's saved level (live, 2026-11-09): the likeliest moment for a loud start.
|
|
305
|
+
if (wasActive === false && state.active === true)
|
|
306
|
+
((capUntil = clock.now() + CAP_WINDOW_MS), (capped = false));
|
|
258
307
|
if (f.type === "auth_state") {
|
|
259
308
|
// Signed out there is no playback_state to wait for, and a later sign-in must not send the part back to connecting.
|
|
260
|
-
if (
|
|
261
|
-
|
|
262
|
-
heard = true;
|
|
309
|
+
if (link === "open" || (link === "heard" && f.logged_in === false))
|
|
310
|
+
link = f.logged_in === true ? "heard" : "whole";
|
|
263
311
|
if (f.logged_in === false)
|
|
264
312
|
statusKnown = false;
|
|
265
313
|
// A first sign-in (someone picked the speaker) sends only auth_state; the docs don't promise a playback_state after it.
|
|
@@ -269,14 +317,14 @@ export function spotify(config = {}) {
|
|
|
269
317
|
if (f.type === "playback_state") {
|
|
270
318
|
if (!queueAsked)
|
|
271
319
|
((queueAsked = true), query("get_queue", { limit: 10 })); // queue_changed pushes changes only, so ask once per socket
|
|
272
|
-
|
|
320
|
+
link = "whole";
|
|
273
321
|
}
|
|
274
322
|
if (pending?.answers === f.type)
|
|
275
323
|
pending.finish();
|
|
276
324
|
if ((f.type === "playback_state" || f.type === "playback_changed") &&
|
|
277
325
|
f.status !== undefined) {
|
|
278
|
-
// A start is idle → playing or buffering, or a sign-in whose first status is either: a phone picked the speaker and played
|
|
279
|
-
//
|
|
326
|
+
// A start is idle → playing or buffering, or a sign-in whose first status is either: a phone picked the speaker and played.
|
|
327
|
+
// The volume can land just after `playing` (issue #23), so a start through buffering opens the window again then.
|
|
280
328
|
const started = ((f.status === "playing" || f.status === "buffering") &&
|
|
281
329
|
(statusKnown ? before === "idle" : signedIn)) ||
|
|
282
330
|
(f.status === "playing" && startBuffering);
|
|
@@ -319,10 +367,8 @@ export function spotify(config = {}) {
|
|
|
319
367
|
signal.removeEventListener("abort", stop);
|
|
320
368
|
if (socket === s)
|
|
321
369
|
socket = undefined;
|
|
322
|
-
const was =
|
|
323
|
-
|
|
324
|
-
heard = false;
|
|
325
|
-
whole = false;
|
|
370
|
+
const was = isOpen();
|
|
371
|
+
link = "down";
|
|
326
372
|
queueAsked = false;
|
|
327
373
|
statusKnown = false;
|
|
328
374
|
signedIn = false;
|
|
@@ -339,17 +385,17 @@ export function spotify(config = {}) {
|
|
|
339
385
|
s.onopen = () => {
|
|
340
386
|
if (ended)
|
|
341
387
|
return;
|
|
342
|
-
|
|
388
|
+
link = "open";
|
|
343
389
|
down = undefined;
|
|
344
390
|
note = undefined; // a new socket: what failed on the last one is history
|
|
345
391
|
void clock.sleep(HELLO_MS, quiet.signal).then(() => {
|
|
346
392
|
if (quiet.signal.aborted)
|
|
347
393
|
return;
|
|
348
|
-
if (
|
|
394
|
+
if (link === "open")
|
|
349
395
|
return (end(`Soloist said nothing in ${HELLO_MS / 1000} s`),
|
|
350
396
|
s.close());
|
|
351
|
-
if (
|
|
352
|
-
((
|
|
397
|
+
if (link === "heard")
|
|
398
|
+
((link = "whole"), publish()); // signed in, no playback_state: connected with what there is
|
|
353
399
|
});
|
|
354
400
|
};
|
|
355
401
|
s.onmessage = (ev) => void (ended || handle(ev.data));
|
|
@@ -367,7 +413,7 @@ export function spotify(config = {}) {
|
|
|
367
413
|
waiters.delete(check);
|
|
368
414
|
};
|
|
369
415
|
/** Not connected or not playing: the reason `duck` and `interrupt` just run `fn`. */
|
|
370
|
-
const notPlaying = () => !
|
|
416
|
+
const notPlaying = () => !isOpen()
|
|
371
417
|
? "Soloist is not connected"
|
|
372
418
|
: state.status === "playing" || state.status === "buffering"
|
|
373
419
|
? undefined
|
|
@@ -388,7 +434,7 @@ export function spotify(config = {}) {
|
|
|
388
434
|
*/
|
|
389
435
|
const aside = (kind, fn, enter, leave) => {
|
|
390
436
|
const run = async () => {
|
|
391
|
-
if (
|
|
437
|
+
if (isOpen())
|
|
392
438
|
marked(kind);
|
|
393
439
|
try {
|
|
394
440
|
return await within();
|
|
@@ -423,10 +469,20 @@ export function spotify(config = {}) {
|
|
|
423
469
|
why: failed(`sent the ${what}; Soloist has not reported it`),
|
|
424
470
|
};
|
|
425
471
|
const actions = {
|
|
472
|
+
async activate() {
|
|
473
|
+
if (isOpen() && state.loggedIn !== false && state.active === true)
|
|
474
|
+
return { state, confirmed: true }; // a second activate might make a new stream
|
|
475
|
+
return change("activate", {
|
|
476
|
+
command: "activate",
|
|
477
|
+
took: (s) => s.active === true,
|
|
478
|
+
});
|
|
479
|
+
},
|
|
480
|
+
// A `play` while inactive didn't take live; going active first is untested for this. `resume` worked inactive, so it doesn't.
|
|
426
481
|
async play(linkOrUri) {
|
|
427
482
|
if (linkOrUri === undefined)
|
|
428
483
|
return change("transport", {
|
|
429
484
|
command: "play",
|
|
485
|
+
activeFirst: true,
|
|
430
486
|
took: (s) => s.status === "playing",
|
|
431
487
|
});
|
|
432
488
|
const uri = toSpotifyUri(linkOrUri);
|
|
@@ -435,6 +491,7 @@ export function spotify(config = {}) {
|
|
|
435
491
|
return change("transport", {
|
|
436
492
|
command: "play",
|
|
437
493
|
fields: { uri },
|
|
494
|
+
activeFirst: true,
|
|
438
495
|
took: (s) => s.status === "playing" &&
|
|
439
496
|
(s.track?.uri === uri || s.context?.uri === uri),
|
|
440
497
|
});
|
|
@@ -471,6 +528,8 @@ export function spotify(config = {}) {
|
|
|
471
528
|
async setVolume(volume) {
|
|
472
529
|
if (!Number.isFinite(volume) || volume < 0 || volume > 100)
|
|
473
530
|
throw invalid(`volume is 0–100, not ${volume}`);
|
|
531
|
+
if (inactive())
|
|
532
|
+
throw new ConnectorError("the speaker is not the active Spotify device, so Soloist would drop this; activate it first (`home spotify activate`; its volume may jump to the level last saved)", { code: "inactive" });
|
|
474
533
|
return volumeTo(Math.round(cap === undefined ? volume : Math.min(volume, cap)));
|
|
475
534
|
},
|
|
476
535
|
async queue(track) {
|
|
@@ -507,6 +566,9 @@ export function spotify(config = {}) {
|
|
|
507
566
|
const no = notPlaying();
|
|
508
567
|
if (no)
|
|
509
568
|
return no;
|
|
569
|
+
// "playing" while inactive is the account playing on another speaker (live, 2026-11-10): a pause would stop that one.
|
|
570
|
+
if (inactive())
|
|
571
|
+
return "it is not the active device, so the music playing is on another speaker";
|
|
510
572
|
track = state.track?.uri;
|
|
511
573
|
try {
|
|
512
574
|
shown = (await actions.pause()).confirmed; // a pause that never shows still runs fn: what interrupts matters more
|
|
@@ -544,6 +606,8 @@ export function spotify(config = {}) {
|
|
|
544
606
|
const no = notPlaying();
|
|
545
607
|
if (no)
|
|
546
608
|
return no;
|
|
609
|
+
if (inactive())
|
|
610
|
+
return "it is not the active device, so its volume can't be changed";
|
|
547
611
|
if (state.volume === undefined)
|
|
548
612
|
return "its volume is not known";
|
|
549
613
|
if (state.volume <= to)
|
|
@@ -556,7 +620,7 @@ export function spotify(config = {}) {
|
|
|
556
620
|
return failed(`lowering the volume for duck failed: ${errorText(error)}`);
|
|
557
621
|
}
|
|
558
622
|
}, async () => {
|
|
559
|
-
if (state.volume
|
|
623
|
+
if (!near(state.volume, Math.round(to)))
|
|
560
624
|
return {
|
|
561
625
|
music: "kept",
|
|
562
626
|
why: shown
|
|
@@ -593,7 +657,7 @@ export function spotify(config = {}) {
|
|
|
593
657
|
state = rest;
|
|
594
658
|
}
|
|
595
659
|
let seen; // the last check's reported position
|
|
596
|
-
let repairedFor; // at most one repair per track
|
|
660
|
+
let repairedFor; // at most one repair per play of a track
|
|
597
661
|
/** Ask Soloist where it is, waiting for the answer (or the query's timeout). */
|
|
598
662
|
const fresh = () => write("query-get_state", {
|
|
599
663
|
command: "get_state",
|
|
@@ -614,7 +678,7 @@ export function spotify(config = {}) {
|
|
|
614
678
|
a.track === b.track &&
|
|
615
679
|
b.atMs - a.atMs >= gap &&
|
|
616
680
|
Math.abs(b.ms - a.ms) < 1000;
|
|
617
|
-
const playing = () =>
|
|
681
|
+
const playing = () => isOpen() && state.status === "playing" && !state.aside;
|
|
618
682
|
/** One look while playing: a fresh position, then frozen, overrun, recovered or fine (Soloist issue #5). */
|
|
619
683
|
const checkStall = async () => {
|
|
620
684
|
if (!playing())
|
|
@@ -654,7 +718,7 @@ export function spotify(config = {}) {
|
|
|
654
718
|
repairedFor = track;
|
|
655
719
|
const tried = (what) => state.stalled && stallAs({ ...state.stalled, tried: what });
|
|
656
720
|
// Still ours to fix: playing this track, stalled, and no duck, interrupt or person in between.
|
|
657
|
-
const ours = () =>
|
|
721
|
+
const ours = () => isOpen() &&
|
|
658
722
|
state.status === "playing" &&
|
|
659
723
|
state.track?.uri === track &&
|
|
660
724
|
state.stalled?.track === track &&
|
|
@@ -698,8 +762,10 @@ export function spotify(config = {}) {
|
|
|
698
762
|
/** Ask Soloist for its state again (`get_state`, or `get_auth_state` without a session); while it is unreachable, reconnect now. */
|
|
699
763
|
refresh() {
|
|
700
764
|
const s = socket;
|
|
701
|
-
if (s &&
|
|
702
|
-
return query(state.loggedIn === false ||
|
|
765
|
+
if (s && isOpen() && s.readyState === OPEN)
|
|
766
|
+
return query(state.loggedIn === false || link === "open"
|
|
767
|
+
? "get_auth_state"
|
|
768
|
+
: "get_state");
|
|
703
769
|
wake?.abort();
|
|
704
770
|
},
|
|
705
771
|
async run(signal, update) {
|
|
@@ -752,3 +818,22 @@ export const manifest = {
|
|
|
752
818
|
? [spotifyText(state)]
|
|
753
819
|
: [],
|
|
754
820
|
};
|
|
821
|
+
/** The one speaker, once Soloist has said something. Its name is Soloist's `--device-name`, the one phones show. */
|
|
822
|
+
export function spotifyHomeDevices(part) {
|
|
823
|
+
const s = part?.state;
|
|
824
|
+
if (!s || (s.loggedIn === undefined && s.status === undefined))
|
|
825
|
+
return [];
|
|
826
|
+
return deviceRecords(part, [
|
|
827
|
+
{
|
|
828
|
+
connector: "spotify",
|
|
829
|
+
id: "spotify",
|
|
830
|
+
name: s.deviceName ?? "Soloist",
|
|
831
|
+
kind: "speaker",
|
|
832
|
+
available: true,
|
|
833
|
+
state: speakerState(part),
|
|
834
|
+
asOf: s.asOf,
|
|
835
|
+
text: spotifyText(s),
|
|
836
|
+
detail: s,
|
|
837
|
+
},
|
|
838
|
+
]);
|
|
839
|
+
}
|
|
@@ -82,8 +82,10 @@ export declare function toSnapshot(txs: Transaction[], categories: Category[], a
|
|
|
82
82
|
truncated: boolean;
|
|
83
83
|
household?: Household;
|
|
84
84
|
}): UpSnapshot;
|
|
85
|
-
/** Up as a home integration: two months of transactions now, then every 15 minutes.
|
|
86
|
-
export declare function up(config: UpConfig): Integration<UpSnapshot, "up"
|
|
85
|
+
/** Up as a home integration: two months of transactions now, then every 15 minutes. The integration only reads; `client` is there for a consumer's writes. */
|
|
86
|
+
export declare function up(config: UpConfig): Integration<UpSnapshot, "up"> & {
|
|
87
|
+
client: UpClient;
|
|
88
|
+
};
|
|
87
89
|
/** "Oct so far $2,140 · Sep $1,980 · 3 to categorise": this month's spend against last month's. */
|
|
88
90
|
export declare function upText(s: UpSnapshot): string;
|
|
89
91
|
/** One line per parent category this month, largest five: "home $1,030: groceries $620, utilities $410". */
|
|
@@ -97,13 +97,14 @@ export function toSnapshot(txs, categories, at) {
|
|
|
97
97
|
truncated: at.truncated,
|
|
98
98
|
};
|
|
99
99
|
}
|
|
100
|
-
/** Up as a home integration: two months of transactions now, then every 15 minutes.
|
|
100
|
+
/** Up as a home integration: two months of transactions now, then every 15 minutes. The integration only reads; `client` is there for a consumer's writes. */
|
|
101
101
|
export function up(config) {
|
|
102
102
|
const client = upClient(config);
|
|
103
103
|
const clock = config.clock ?? realClock;
|
|
104
104
|
const poll = poller(clock);
|
|
105
105
|
return {
|
|
106
106
|
name: "up",
|
|
107
|
+
client,
|
|
107
108
|
refresh: poll.nudge,
|
|
108
109
|
async run(signal, update) {
|
|
109
110
|
let last;
|
|
@@ -141,6 +142,7 @@ export function up(config) {
|
|
|
141
142
|
if (error instanceof UpError && error.status === 401)
|
|
142
143
|
return update({
|
|
143
144
|
status: "unavailable",
|
|
145
|
+
reason: "auth",
|
|
144
146
|
detail: "Up rejected the token",
|
|
145
147
|
state: last,
|
|
146
148
|
});
|
|
@@ -185,6 +185,7 @@ export function bom(options = {}) {
|
|
|
185
185
|
if (!options.fetch && base.startsWith("ftp:"))
|
|
186
186
|
return {
|
|
187
187
|
stop: "bom: plain fetch cannot read ftp://; pass fetch: ftpFetch() from @khalido/home/weather/ftp",
|
|
188
|
+
stopReason: "config",
|
|
188
189
|
nextAt: Infinity,
|
|
189
190
|
};
|
|
190
191
|
if (force || at >= obsDue) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import {
|
|
2
|
+
import { ConnectorError, fetchTimeout } from "../../connector.js";
|
|
3
3
|
import { RAIN_INTENSITIES } from "./rain.js";
|
|
4
4
|
import { sunDisagrees } from "./sun.js";
|
|
5
5
|
import { HOURLY_MINUTES, iso, nextPoll, num, RAIN_HORIZON_MINUTES, USER_AGENT, } from "./source.js";
|
|
@@ -273,6 +273,28 @@ export function toRain(minutes, fetchedAt) {
|
|
|
273
273
|
*/
|
|
274
274
|
export const MIN_POLL_MINUTES = 15;
|
|
275
275
|
export const CURRENT_MINUTES = 30;
|
|
276
|
+
/**
|
|
277
|
+
* Why Google refused: the `reason` of its error body's ErrorInfo ("API_KEY_INVALID"), since the status alone doesn't say.
|
|
278
|
+
* A bad key and a bad latitude are both HTTP 400 INVALID_ARGUMENT; only the key's carries a reason (live, 2026-11-10).
|
|
279
|
+
*/
|
|
280
|
+
async function refusal(res) {
|
|
281
|
+
try {
|
|
282
|
+
const body = (await res.json());
|
|
283
|
+
const reason = body.error?.details?.find((d) => d.reason)?.reason;
|
|
284
|
+
return typeof reason === "string" ? reason : undefined;
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
return undefined; // not JSON: the status is all there is
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
/** Google's refusal: the HTTP status, and `why`, the reason its error body gave. */
|
|
291
|
+
class GoogleError extends ConnectorError {
|
|
292
|
+
why;
|
|
293
|
+
constructor(message, status, why) {
|
|
294
|
+
super(message, { status });
|
|
295
|
+
this.why = why;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
276
298
|
/**
|
|
277
299
|
* Google Weather as a source: modelled `now`, `forecast` (10 days available, 2 read), `sky` without radiation, and `rain` from the
|
|
278
300
|
* minute forecast (Experimental at Google, so its failure is a `problem` on `rain` and never stops the other slices).
|
|
@@ -295,11 +317,20 @@ export function google(options) {
|
|
|
295
317
|
},
|
|
296
318
|
minPollMinutes: MIN_POLL_MINUTES,
|
|
297
319
|
async read({ place, at, pollMinutes, force, signal, }) {
|
|
298
|
-
const q = (extra) => `
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
320
|
+
const q = (extra) => `location.latitude=${place.latitude}&location.longitude=${place.longitude}${extra}`;
|
|
321
|
+
// The key goes in a header, never the URL, so no error, log or proxy that shows a URL can show it (all four lookups answered 200 this way, live 2026-11-10).
|
|
322
|
+
const json = async (path) => {
|
|
323
|
+
const res = await fetchTimeout(fetcher, `${base}/${path}`, {
|
|
324
|
+
headers: {
|
|
325
|
+
"user-agent": USER_AGENT,
|
|
326
|
+
"x-goog-api-key": options.key,
|
|
327
|
+
},
|
|
328
|
+
}, { signal });
|
|
329
|
+
if (res.ok)
|
|
330
|
+
return res.json();
|
|
331
|
+
const why = await refusal(res);
|
|
332
|
+
throw new GoogleError(`HTTP ${res.status}${why ? ` ${why}` : ""} on GET /${path.split("?")[0]}`, res.status, why);
|
|
333
|
+
};
|
|
303
334
|
// Started first and never throws: a failed minute forecast must not sink the rest.
|
|
304
335
|
const minutes = json(`forecast/minutes:lookup?${q("")}`).then((value) => ({ value }), (error) => ({ error: error }));
|
|
305
336
|
let problem;
|
|
@@ -324,10 +355,22 @@ export function google(options) {
|
|
|
324
355
|
}
|
|
325
356
|
}
|
|
326
357
|
catch (error) {
|
|
327
|
-
|
|
328
|
-
|
|
358
|
+
// Only a GoogleError is Google's answer; a timeout or an abort is a DOMException, with fields of its own.
|
|
359
|
+
const { status, why } = error instanceof GoogleError
|
|
360
|
+
? error
|
|
361
|
+
: {};
|
|
362
|
+
const said = `HTTP ${status}${why ? ` ${why}` : ""}`;
|
|
363
|
+
if (status === 401 || status === 403 || why?.startsWith("API_KEY_"))
|
|
364
|
+
return {
|
|
365
|
+
stop: `google: the key was rejected (${said})`,
|
|
366
|
+
stopReason: "auth",
|
|
367
|
+
nextAt: Infinity,
|
|
368
|
+
};
|
|
369
|
+
// A 400 with a good key is the request itself, and here that is the location: asking again won't change it.
|
|
370
|
+
if (status === 400)
|
|
329
371
|
return {
|
|
330
|
-
stop: `google:
|
|
372
|
+
stop: `google: the request was refused (${said}); check the latitude and longitude`,
|
|
373
|
+
stopReason: "config",
|
|
331
374
|
nextAt: Infinity,
|
|
332
375
|
};
|
|
333
376
|
problem = `google: ${error.message}`; // the old slices stay; the minute forecast below is still used
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Clock } from "../../clock.ts";
|
|
2
|
-
import type { Integration } from "../../home.ts";
|
|
2
|
+
import type { Integration, Reason } from "../../home.ts";
|
|
3
3
|
import { type Manifest } from "../../manifest.ts";
|
|
4
4
|
import { type BlockDef, type DayBlocks } from "./blocks.ts";
|
|
5
5
|
import { type RainSoon } from "./rain.ts";
|
|
@@ -38,6 +38,7 @@ export type WeatherState = Slices & {
|
|
|
38
38
|
read: boolean;
|
|
39
39
|
problem?: string;
|
|
40
40
|
stopped?: string;
|
|
41
|
+
reason?: Exclude<Reason, "crashed">;
|
|
41
42
|
anomaly?: string;
|
|
42
43
|
}>;
|
|
43
44
|
};
|
|
@@ -66,6 +66,7 @@ export function weather(config) {
|
|
|
66
66
|
read: r !== undefined,
|
|
67
67
|
problem: r?.problem,
|
|
68
68
|
stopped: r?.stop,
|
|
69
|
+
reason: r?.stop ? (r.stopReason ?? "config") : undefined,
|
|
69
70
|
anomaly: r?.anomaly,
|
|
70
71
|
},
|
|
71
72
|
];
|
|
@@ -87,6 +88,12 @@ export function weather(config) {
|
|
|
87
88
|
: undefined;
|
|
88
89
|
update({
|
|
89
90
|
status,
|
|
91
|
+
// Every source has stopped: a rejected key outranks a wrong setting, since it needs a person.
|
|
92
|
+
reason: status !== "unavailable"
|
|
93
|
+
? undefined
|
|
94
|
+
: Object.values(report).some((r) => r.reason === "auth")
|
|
95
|
+
? "auth"
|
|
96
|
+
: "config",
|
|
90
97
|
detail,
|
|
91
98
|
anomaly: notes("anomaly"),
|
|
92
99
|
lastGoodAt,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Reason } from "../../home.ts";
|
|
1
2
|
/** Sent on every request, so each service can see who is calling rather than a runtime's default string. */
|
|
2
3
|
export declare const USER_AGENT = "home (+https://github.com/khalido/home.ts)";
|
|
3
4
|
export type Place = {
|
|
@@ -116,6 +117,7 @@ export type SourceReading = Slices & {
|
|
|
116
117
|
problem?: string;
|
|
117
118
|
anomaly?: string;
|
|
118
119
|
stop?: string;
|
|
120
|
+
stopReason?: Exclude<Reason, "crashed">;
|
|
119
121
|
};
|
|
120
122
|
/**
|
|
121
123
|
* A weather provider. `owns` names the slices it can fill and how old each may get (by its `at`, beyond one polling
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type CastHomeDevice, castHomeDevices } from "./connectors/cast/index.ts";
|
|
2
|
+
import { type DaikinHomeDevice, daikinHomeDevices } from "./connectors/daikin/index.ts";
|
|
3
|
+
import { type EnphaseHomeDevice, enphaseHomeDevices } from "./connectors/enphase/index.ts";
|
|
4
|
+
import { type HueHomeDevice, hueHomeDevices } from "./connectors/hue/index.ts";
|
|
5
|
+
import { type PrinterHomeDevice, printersHomeDevices } from "./connectors/printers/index.ts";
|
|
6
|
+
import { type SpotifyHomeDevice, spotifyHomeDevices } from "./connectors/spotify/index.ts";
|
|
7
|
+
import type { HomeConfig } from "./config.ts";
|
|
8
|
+
import type { Part } from "./home.ts";
|
|
9
|
+
/** Any device this library reads. `connector`, then `kind` where a connector has more than one, narrows `detail` and `state`. */
|
|
10
|
+
export type HomeDevice = HueHomeDevice | DaikinHomeDevice | EnphaseHomeDevice | CastHomeDevice | PrinterHomeDevice | SpotifyHomeDevice;
|
|
11
|
+
/** Each connector's devices from its part, in the order `home status` lists the connectors. */
|
|
12
|
+
export declare const DEVICES: {
|
|
13
|
+
hue: typeof hueHomeDevices;
|
|
14
|
+
daikin: typeof daikinHomeDevices;
|
|
15
|
+
enphase: typeof enphaseHomeDevices;
|
|
16
|
+
cast: typeof castHomeDevices;
|
|
17
|
+
printers: typeof printersHomeDevices;
|
|
18
|
+
spotify: typeof spotifyHomeDevices;
|
|
19
|
+
};
|
|
20
|
+
/** Connectors that read a service, not a device: nothing to list. */
|
|
21
|
+
export declare const SERVICES: readonly string[];
|
|
22
|
+
/**
|
|
23
|
+
* Every device in the snapshot. With the home `config`, a device takes the `room` its entry gives it, a Hue room or zone
|
|
24
|
+
* (and its lights) the room whose `hueRoom` names it (a light in a room and a zone, the room's), and a device that reports
|
|
25
|
+
* no name of its own (the Daikin, the Enphase gateway) the entry's `name`. A device's own name is never replaced.
|
|
26
|
+
*/
|
|
27
|
+
export declare function homeDevices(snapshot: Record<string, Part | undefined>, config?: Pick<HomeConfig, "devices" | "rooms">): HomeDevice[];
|