@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
|
@@ -185,7 +185,7 @@ export type SpotifyState = {
|
|
|
185
185
|
/** The playlist, album or show playing from. */
|
|
186
186
|
context?: { uri: string; type?: string; name?: string };
|
|
187
187
|
position?: PositionAnchor;
|
|
188
|
-
/** 0–100, as Soloist reports it. */
|
|
188
|
+
/** 0–100, as Soloist reports it, rounded. Until `active` it isn't the level that will play (it reads `--initial-volume`, seen live). */
|
|
189
189
|
volume?: number;
|
|
190
190
|
shuffle?: boolean;
|
|
191
191
|
/** off, context or track. */
|
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { asOf, type Clock, realClock } from "../../clock.ts";
|
|
3
3
|
import { ConnectorError, errorText } from "../../connector.ts";
|
|
4
|
-
import
|
|
4
|
+
import {
|
|
5
|
+
type DeviceRecord,
|
|
6
|
+
deviceRecords,
|
|
7
|
+
type Integration,
|
|
8
|
+
type Part,
|
|
9
|
+
type Status,
|
|
10
|
+
} from "../../home.ts";
|
|
5
11
|
import { writeLane } from "../../lane.ts";
|
|
6
12
|
import type { Manifest } from "../../manifest.ts";
|
|
7
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
type SpeakerState,
|
|
15
|
+
speakerState,
|
|
16
|
+
spotifyText,
|
|
17
|
+
stallText,
|
|
18
|
+
} from "./changes.ts";
|
|
8
19
|
import {
|
|
9
20
|
commandFrame,
|
|
10
21
|
fold,
|
|
@@ -99,9 +110,11 @@ export type Aside<T> = { value: T; music: Music; why?: string };
|
|
|
99
110
|
/**
|
|
100
111
|
* The writes, on the object `spotify()` returns. Each resolves once Soloist reports the change it asked for, with the state
|
|
101
112
|
* then (`confirmed: true`), or after `CONFIRM_MS` with the state as it stands (`confirmed: false`). Each rejects with a
|
|
102
|
-
* `ConnectorError` whose `code` is `offline`, `waiting` (nobody has picked the speaker), `refused`, `timeout` or `invalid`.
|
|
113
|
+
* `ConnectorError` whose `code` is `offline`, `waiting` (nobody has picked the speaker), `inactive` (`setVolume` on a speaker that isn't the active device: `activate()` first), `refused`, `timeout` or `invalid`.
|
|
103
114
|
*/
|
|
104
115
|
export type SpotifyActions = {
|
|
116
|
+
/** Make this the account's active Spotify device. Soloist drops volume changes until it is. It makes a new audio stream, so the volume can jump to the level last saved. */
|
|
117
|
+
activate(): Promise<Applied>;
|
|
105
118
|
/** Play a Spotify link or URI (track, album, playlist, artist, episode, show), or resume what is loaded when none is given. */
|
|
106
119
|
play(linkOrUri?: string): Promise<Applied>;
|
|
107
120
|
pause(): Promise<Applied>;
|
|
@@ -137,10 +150,12 @@ const COMMAND_TIMEOUT_MS = 3000;
|
|
|
137
150
|
const QUERY_TIMEOUT_MS = 1000;
|
|
138
151
|
/** A socket that has said nothing this long after opening is closed: Soloist sends `auth_state` at once on connect. */
|
|
139
152
|
const HELLO_MS = 10_000;
|
|
140
|
-
/** After
|
|
153
|
+
/** 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). */
|
|
141
154
|
const CAP_WINDOW_MS = 10_000;
|
|
142
155
|
/** How long a write waits for Soloist to report the change it asked for. Loopback events arrive in milliseconds; a stated guess. */
|
|
143
156
|
const CONFIRM_MS = 2000;
|
|
157
|
+
/** Between the two halves of `setRepeat`: back to back left repeat on `track`, 600 ms apart worked (live, 2026-11-09). */
|
|
158
|
+
const REPEAT_GAP_MS = 600;
|
|
144
159
|
/** Between two commands. Soloist publishes no limit; it is on loopback; a stated guess. */
|
|
145
160
|
const WRITE_GAP_MS = 100;
|
|
146
161
|
/** While playing, a stall check this often: one `get_state`. Soloist issue #5 reports stalls 71–150 s into a track; a stated guess. */
|
|
@@ -149,6 +164,8 @@ const STALL_CHECK_MS = 20_000;
|
|
|
149
164
|
const OVERRUN_MS = 15_000;
|
|
150
165
|
/** After a repair's seek, how long before looking whether the position moves again. */
|
|
151
166
|
const REPAIR_WAIT_MS = 5000;
|
|
167
|
+
/** A reported volume is rounded (phone steps are sixteenths: 43.75 shows as 44), so "it took" allows this much. */
|
|
168
|
+
const VOLUME_SLACK = 1;
|
|
152
169
|
const MIN_BACKOFF_MS = 1000;
|
|
153
170
|
const MAX_BACKOFF_MS = 60_000;
|
|
154
171
|
|
|
@@ -190,15 +207,16 @@ export function spotify(
|
|
|
190
207
|
|
|
191
208
|
let state: SpotifyState = { asOf: asOf(clock) };
|
|
192
209
|
let socket: SocketLike | undefined;
|
|
193
|
-
|
|
194
|
-
let
|
|
195
|
-
|
|
210
|
+
// 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).
|
|
211
|
+
let link: "down" | "open" | "heard" | "whole" = "down";
|
|
212
|
+
const isOpen = () => link !== "down";
|
|
196
213
|
let down: string | undefined; // why the last socket ended, while there is none open
|
|
197
214
|
let statusKnown = false; // Soloist has told this socket its status: only then is idle → playing an edge
|
|
198
215
|
let queueAsked = false; // get_queue sent on this socket
|
|
199
216
|
let signedIn = false; // signed out → in on this socket: the first status after it counts as a start if it is playing
|
|
200
217
|
let capUntil = -Infinity;
|
|
201
|
-
let capped = false; // the cap
|
|
218
|
+
let capped = false; // the cap took in this window
|
|
219
|
+
let capping = false; // a lowering is on its way
|
|
202
220
|
let startBuffering = false; // a start from idle is still buffering
|
|
203
221
|
let capNote: string | undefined; // the last first-play lowering, in `anomaly` until the speaker goes idle
|
|
204
222
|
let frameNote: string | undefined; // a frame the schema rejected, or an error nothing was waiting for
|
|
@@ -214,11 +232,10 @@ export function spotify(
|
|
|
214
232
|
const publish = () => {
|
|
215
233
|
let status: Status;
|
|
216
234
|
let detail: string | undefined;
|
|
217
|
-
if (
|
|
235
|
+
if (link === "down")
|
|
218
236
|
((status = reported || down ? "disconnected" : "connecting"),
|
|
219
237
|
(detail = down));
|
|
220
|
-
else if (
|
|
221
|
-
status = "connecting";
|
|
238
|
+
else if (link !== "whole") status = "connecting";
|
|
222
239
|
else
|
|
223
240
|
((status = "connected"),
|
|
224
241
|
(detail = state.loggedIn === false ? WAITING : note));
|
|
@@ -251,7 +268,7 @@ export function spotify(
|
|
|
251
268
|
) =>
|
|
252
269
|
new Promise<void>((resolve, reject) => {
|
|
253
270
|
const s = socket;
|
|
254
|
-
if (!s || !
|
|
271
|
+
if (!s || !isOpen() || s.readyState !== OPEN) return reject(offline());
|
|
255
272
|
if (state.loggedIn === false && !answers)
|
|
256
273
|
return reject(new ConnectorError(WAITING, { code: "waiting" }));
|
|
257
274
|
const stop = new AbortController();
|
|
@@ -291,12 +308,20 @@ export function spotify(
|
|
|
291
308
|
answers?: string;
|
|
292
309
|
then?: { command: string; fields: Record<string, unknown> };
|
|
293
310
|
took?: (now: SpotifyState, before: SpotifyState, at: number) => boolean;
|
|
311
|
+
activeFirst?: boolean; // an inactive speaker goes active before this, in this write's own turn so the last transport write still wins
|
|
294
312
|
};
|
|
295
313
|
const send = async (body: Body) => {
|
|
314
|
+
if (body.activeFirst && inactive()) {
|
|
315
|
+
await command("activate").catch(() => {}); // best effort: the write itself says how it went
|
|
316
|
+
await waitFor(() => state.active === true || !isOpen(), CONFIRM_MS);
|
|
317
|
+
}
|
|
296
318
|
const before = state;
|
|
297
319
|
const at = clock.now();
|
|
298
320
|
await command(body.command, body.fields, body.answers);
|
|
299
|
-
if (body.then)
|
|
321
|
+
if (body.then) {
|
|
322
|
+
await clock.sleep(REPEAT_GAP_MS);
|
|
323
|
+
await command(body.then.command, body.then.fields); // repeat's second half, still this write's turn
|
|
324
|
+
}
|
|
300
325
|
return { before, at };
|
|
301
326
|
};
|
|
302
327
|
let once = 0; // skips and queued tracks never merge: two nexts are two skips
|
|
@@ -309,7 +334,7 @@ export function spotify(
|
|
|
309
334
|
const change = async (slot: string, body: Body): Promise<Applied> => {
|
|
310
335
|
const sent = await write(slot, body);
|
|
311
336
|
const took = () => body.took?.(state, sent.before, sent.at) ?? true;
|
|
312
|
-
await waitFor(() => took() || !
|
|
337
|
+
await waitFor(() => took() || !isOpen(), CONFIRM_MS);
|
|
313
338
|
return { state, confirmed: took() };
|
|
314
339
|
};
|
|
315
340
|
/** 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). */
|
|
@@ -321,12 +346,17 @@ export function spotify(
|
|
|
321
346
|
!!s.track && s.track.uri !== b.track?.uri;
|
|
322
347
|
const invalid = (message: string) =>
|
|
323
348
|
new ConnectorError(message, { code: "invalid" });
|
|
349
|
+
const near = (volume: number | undefined, v: number) =>
|
|
350
|
+
volume !== undefined && Math.abs(volume - v) <= VOLUME_SLACK;
|
|
324
351
|
const volumeTo = (v: number) =>
|
|
325
352
|
change("volume", {
|
|
326
353
|
command: "set_volume",
|
|
327
354
|
fields: { volume: v },
|
|
328
|
-
took: (s) => s.volume
|
|
355
|
+
took: (s) => near(s.volume, v),
|
|
329
356
|
});
|
|
357
|
+
/** Soloist accepts a `set_volume` while it isn't the active device and drops it (three times live, 2026-11-09). */
|
|
358
|
+
const inactive = () =>
|
|
359
|
+
isOpen() && state.loggedIn !== false && state.active === false;
|
|
330
360
|
/** 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. */
|
|
331
361
|
const ANSWERS = {
|
|
332
362
|
get_state: "playback_state",
|
|
@@ -353,27 +383,50 @@ export function spotify(
|
|
|
353
383
|
publish();
|
|
354
384
|
});
|
|
355
385
|
|
|
356
|
-
/**
|
|
386
|
+
/**
|
|
387
|
+
* Within the window after a start signal, a volume above the cap is lowered to it (issue #23). Only while active, since
|
|
388
|
+
* Soloist drops it otherwise, and retried until Soloist reports it. A volume raised after that is someone's choice;
|
|
389
|
+
* one raised before it is reported can't be told from the start's own jump, so it is lowered too.
|
|
390
|
+
*/
|
|
357
391
|
const applyCap = () => {
|
|
358
392
|
if (
|
|
359
393
|
cap === undefined ||
|
|
360
394
|
capped ||
|
|
395
|
+
capping ||
|
|
361
396
|
state.volume === undefined ||
|
|
362
|
-
|
|
363
|
-
state.
|
|
397
|
+
state.volume <= cap + VOLUME_SLACK ||
|
|
398
|
+
state.active === false ||
|
|
399
|
+
clock.now() > capUntil
|
|
364
400
|
)
|
|
365
401
|
return;
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
402
|
+
capping = true;
|
|
403
|
+
const from = state.volume;
|
|
404
|
+
const window = capUntil;
|
|
405
|
+
volumeTo(cap).then(
|
|
406
|
+
({ confirmed }) => {
|
|
407
|
+
capping = false;
|
|
408
|
+
if (capUntil !== window) return applyCap(); // a new start while it went: this lowering doesn't speak for that one
|
|
409
|
+
if (confirmed) {
|
|
410
|
+
capped = true;
|
|
411
|
+
if (state.status === "idle") return;
|
|
412
|
+
capNote = `started at volume ${from}, above maxVolume ${cap}; lowered it (Soloist issue #23)`;
|
|
413
|
+
note = undefined;
|
|
414
|
+
return publish();
|
|
415
|
+
}
|
|
416
|
+
if (clock.now() > capUntil) {
|
|
417
|
+
note = `could not lower the volume to ${cap}: Soloist has not reported it`;
|
|
418
|
+
return publish();
|
|
419
|
+
}
|
|
420
|
+
applyCap();
|
|
421
|
+
},
|
|
422
|
+
(error) => {
|
|
423
|
+
capping = false;
|
|
424
|
+
if (error instanceof ConnectorError && error.code === "offline") return; // the status says it
|
|
425
|
+
if (capUntil !== window) return applyCap(); // a new start while it went: this failure doesn't speak for that one
|
|
426
|
+
capped = true; // refused or unanswered: not again this window
|
|
427
|
+
note = `lowering the volume to ${cap} failed: ${errorText(error)}`;
|
|
428
|
+
publish();
|
|
429
|
+
},
|
|
377
430
|
);
|
|
378
431
|
};
|
|
379
432
|
|
|
@@ -411,11 +464,16 @@ export function spotify(
|
|
|
411
464
|
}
|
|
412
465
|
const before = state.status;
|
|
413
466
|
const wasSignedIn = state.loggedIn;
|
|
467
|
+
const wasActive = link === "open" ? undefined : state.active; // the first frames on a socket are not a change
|
|
414
468
|
state = fold(state, f, asOf(clock));
|
|
469
|
+
if (state.track?.uri !== repairedFor) repairedFor = undefined; // one repair per play of a track
|
|
470
|
+
// Going active makes a new stream, at WirePlumber's saved level (live, 2026-11-09): the likeliest moment for a loud start.
|
|
471
|
+
if (wasActive === false && state.active === true)
|
|
472
|
+
((capUntil = clock.now() + CAP_WINDOW_MS), (capped = false));
|
|
415
473
|
if (f.type === "auth_state") {
|
|
416
474
|
// Signed out there is no playback_state to wait for, and a later sign-in must not send the part back to connecting.
|
|
417
|
-
if (
|
|
418
|
-
|
|
475
|
+
if (link === "open" || (link === "heard" && f.logged_in === false))
|
|
476
|
+
link = f.logged_in === true ? "heard" : "whole";
|
|
419
477
|
if (f.logged_in === false) statusKnown = false;
|
|
420
478
|
// A first sign-in (someone picked the speaker) sends only auth_state; the docs don't promise a playback_state after it.
|
|
421
479
|
else if (f.logged_in === true && wasSignedIn === false)
|
|
@@ -423,15 +481,15 @@ export function spotify(
|
|
|
423
481
|
}
|
|
424
482
|
if (f.type === "playback_state") {
|
|
425
483
|
if (!queueAsked) ((queueAsked = true), query("get_queue", { limit: 10 })); // queue_changed pushes changes only, so ask once per socket
|
|
426
|
-
|
|
484
|
+
link = "whole";
|
|
427
485
|
}
|
|
428
486
|
if (pending?.answers === f.type) pending.finish();
|
|
429
487
|
if (
|
|
430
488
|
(f.type === "playback_state" || f.type === "playback_changed") &&
|
|
431
489
|
f.status !== undefined
|
|
432
490
|
) {
|
|
433
|
-
// A start is idle → playing or buffering, or a sign-in whose first status is either: a phone picked the speaker and played
|
|
434
|
-
//
|
|
491
|
+
// A start is idle → playing or buffering, or a sign-in whose first status is either: a phone picked the speaker and played.
|
|
492
|
+
// The volume can land just after `playing` (issue #23), so a start through buffering opens the window again then.
|
|
435
493
|
const started =
|
|
436
494
|
((f.status === "playing" || f.status === "buffering") &&
|
|
437
495
|
(statusKnown ? before === "idle" : signedIn)) ||
|
|
@@ -470,10 +528,8 @@ export function spotify(
|
|
|
470
528
|
quiet.abort();
|
|
471
529
|
signal.removeEventListener("abort", stop);
|
|
472
530
|
if (socket === s) socket = undefined;
|
|
473
|
-
const was =
|
|
474
|
-
|
|
475
|
-
heard = false;
|
|
476
|
-
whole = false;
|
|
531
|
+
const was = isOpen();
|
|
532
|
+
link = "down";
|
|
477
533
|
queueAsked = false;
|
|
478
534
|
statusKnown = false;
|
|
479
535
|
signedIn = false;
|
|
@@ -488,17 +544,17 @@ export function spotify(
|
|
|
488
544
|
// An ended socket's late events belong to no session: they must not touch the next socket's state.
|
|
489
545
|
s.onopen = () => {
|
|
490
546
|
if (ended) return;
|
|
491
|
-
|
|
547
|
+
link = "open";
|
|
492
548
|
down = undefined;
|
|
493
549
|
note = undefined; // a new socket: what failed on the last one is history
|
|
494
550
|
void clock.sleep(HELLO_MS, quiet.signal).then(() => {
|
|
495
551
|
if (quiet.signal.aborted) return;
|
|
496
|
-
if (
|
|
552
|
+
if (link === "open")
|
|
497
553
|
return (
|
|
498
554
|
end(`Soloist said nothing in ${HELLO_MS / 1000} s`),
|
|
499
555
|
s.close()
|
|
500
556
|
);
|
|
501
|
-
if (
|
|
557
|
+
if (link === "heard") ((link = "whole"), publish()); // signed in, no playback_state: connected with what there is
|
|
502
558
|
});
|
|
503
559
|
};
|
|
504
560
|
s.onmessage = (ev) => void (ended || handle(ev.data));
|
|
@@ -518,7 +574,7 @@ export function spotify(
|
|
|
518
574
|
|
|
519
575
|
/** Not connected or not playing: the reason `duck` and `interrupt` just run `fn`. */
|
|
520
576
|
const notPlaying = () =>
|
|
521
|
-
!
|
|
577
|
+
!isOpen()
|
|
522
578
|
? "Soloist is not connected"
|
|
523
579
|
: state.status === "playing" || state.status === "buffering"
|
|
524
580
|
? undefined
|
|
@@ -544,7 +600,7 @@ export function spotify(
|
|
|
544
600
|
leave: () => Promise<Left>,
|
|
545
601
|
) => {
|
|
546
602
|
const run = async (): Promise<Aside<T>> => {
|
|
547
|
-
if (
|
|
603
|
+
if (isOpen()) marked(kind);
|
|
548
604
|
try {
|
|
549
605
|
return await within();
|
|
550
606
|
} finally {
|
|
@@ -578,10 +634,20 @@ export function spotify(
|
|
|
578
634
|
};
|
|
579
635
|
|
|
580
636
|
const actions: SpotifyActions = {
|
|
637
|
+
async activate() {
|
|
638
|
+
if (isOpen() && state.loggedIn !== false && state.active === true)
|
|
639
|
+
return { state, confirmed: true }; // a second activate might make a new stream
|
|
640
|
+
return change("activate", {
|
|
641
|
+
command: "activate",
|
|
642
|
+
took: (s) => s.active === true,
|
|
643
|
+
});
|
|
644
|
+
},
|
|
645
|
+
// A `play` while inactive didn't take live; going active first is untested for this. `resume` worked inactive, so it doesn't.
|
|
581
646
|
async play(linkOrUri) {
|
|
582
647
|
if (linkOrUri === undefined)
|
|
583
648
|
return change("transport", {
|
|
584
649
|
command: "play",
|
|
650
|
+
activeFirst: true,
|
|
585
651
|
took: (s) => s.status === "playing",
|
|
586
652
|
});
|
|
587
653
|
const uri = toSpotifyUri(linkOrUri);
|
|
@@ -589,6 +655,7 @@ export function spotify(
|
|
|
589
655
|
return change("transport", {
|
|
590
656
|
command: "play",
|
|
591
657
|
fields: { uri },
|
|
658
|
+
activeFirst: true,
|
|
592
659
|
took: (s) =>
|
|
593
660
|
s.status === "playing" &&
|
|
594
661
|
(s.track?.uri === uri || s.context?.uri === uri),
|
|
@@ -632,6 +699,11 @@ export function spotify(
|
|
|
632
699
|
async setVolume(volume) {
|
|
633
700
|
if (!Number.isFinite(volume) || volume < 0 || volume > 100)
|
|
634
701
|
throw invalid(`volume is 0–100, not ${volume}`);
|
|
702
|
+
if (inactive())
|
|
703
|
+
throw new ConnectorError(
|
|
704
|
+
"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)",
|
|
705
|
+
{ code: "inactive" },
|
|
706
|
+
);
|
|
635
707
|
return volumeTo(
|
|
636
708
|
Math.round(cap === undefined ? volume : Math.min(volume, cap)),
|
|
637
709
|
);
|
|
@@ -673,6 +745,9 @@ export function spotify(
|
|
|
673
745
|
async () => {
|
|
674
746
|
const no = notPlaying();
|
|
675
747
|
if (no) return no;
|
|
748
|
+
// "playing" while inactive is the account playing on another speaker (live, 2026-11-10): a pause would stop that one.
|
|
749
|
+
if (inactive())
|
|
750
|
+
return "it is not the active device, so the music playing is on another speaker";
|
|
676
751
|
track = state.track?.uri;
|
|
677
752
|
try {
|
|
678
753
|
shown = (await actions.pause()).confirmed; // a pause that never shows still runs fn: what interrupts matters more
|
|
@@ -712,6 +787,8 @@ export function spotify(
|
|
|
712
787
|
async () => {
|
|
713
788
|
const no = notPlaying();
|
|
714
789
|
if (no) return no;
|
|
790
|
+
if (inactive())
|
|
791
|
+
return "it is not the active device, so its volume can't be changed";
|
|
715
792
|
if (state.volume === undefined) return "its volume is not known";
|
|
716
793
|
if (state.volume <= to) return `it was already at ${state.volume}`;
|
|
717
794
|
earlier = state.volume;
|
|
@@ -724,7 +801,7 @@ export function spotify(
|
|
|
724
801
|
}
|
|
725
802
|
},
|
|
726
803
|
async () => {
|
|
727
|
-
if (state.volume
|
|
804
|
+
if (!near(state.volume, Math.round(to)))
|
|
728
805
|
return {
|
|
729
806
|
music: "kept",
|
|
730
807
|
why: shown
|
|
@@ -766,7 +843,7 @@ export function spotify(
|
|
|
766
843
|
state = rest;
|
|
767
844
|
}
|
|
768
845
|
let seen: { track: string; ms: number; atMs: number } | undefined; // the last check's reported position
|
|
769
|
-
let repairedFor: string | undefined; // at most one repair per track
|
|
846
|
+
let repairedFor: string | undefined; // at most one repair per play of a track
|
|
770
847
|
/** Ask Soloist where it is, waiting for the answer (or the query's timeout). */
|
|
771
848
|
const fresh = () =>
|
|
772
849
|
write("query-get_state", {
|
|
@@ -793,7 +870,7 @@ export function spotify(
|
|
|
793
870
|
a.track === b.track &&
|
|
794
871
|
b.atMs - a.atMs >= gap &&
|
|
795
872
|
Math.abs(b.ms - a.ms) < 1000;
|
|
796
|
-
const playing = () =>
|
|
873
|
+
const playing = () => isOpen() && state.status === "playing" && !state.aside;
|
|
797
874
|
|
|
798
875
|
/** One look while playing: a fresh position, then frozen, overrun, recovered or fine (Soloist issue #5). */
|
|
799
876
|
const checkStall = async () => {
|
|
@@ -837,7 +914,7 @@ export function spotify(
|
|
|
837
914
|
state.stalled && stallAs({ ...state.stalled, tried: what });
|
|
838
915
|
// Still ours to fix: playing this track, stalled, and no duck, interrupt or person in between.
|
|
839
916
|
const ours = () =>
|
|
840
|
-
|
|
917
|
+
isOpen() &&
|
|
841
918
|
state.status === "playing" &&
|
|
842
919
|
state.track?.uri === track &&
|
|
843
920
|
state.stalled?.track === track &&
|
|
@@ -875,9 +952,11 @@ export function spotify(
|
|
|
875
952
|
/** Ask Soloist for its state again (`get_state`, or `get_auth_state` without a session); while it is unreachable, reconnect now. */
|
|
876
953
|
refresh() {
|
|
877
954
|
const s = socket;
|
|
878
|
-
if (s &&
|
|
955
|
+
if (s && isOpen() && s.readyState === OPEN)
|
|
879
956
|
return query(
|
|
880
|
-
state.loggedIn === false ||
|
|
957
|
+
state.loggedIn === false || link === "open"
|
|
958
|
+
? "get_auth_state"
|
|
959
|
+
: "get_state",
|
|
881
960
|
);
|
|
882
961
|
wake?.abort();
|
|
883
962
|
},
|
|
@@ -935,3 +1014,32 @@ export const manifest: Manifest<SpotifyState> = {
|
|
|
935
1014
|
? [spotifyText(state)]
|
|
936
1015
|
: [],
|
|
937
1016
|
};
|
|
1017
|
+
|
|
1018
|
+
/** The Soloist speaker as a home device: `state` is `speakerState`'s word; `detail` its `SpotifyState`. */
|
|
1019
|
+
export type SpotifyHomeDevice = DeviceRecord<
|
|
1020
|
+
"spotify",
|
|
1021
|
+
"speaker",
|
|
1022
|
+
SpeakerState,
|
|
1023
|
+
SpotifyState
|
|
1024
|
+
>;
|
|
1025
|
+
|
|
1026
|
+
/** The one speaker, once Soloist has said something. Its name is Soloist's `--device-name`, the one phones show. */
|
|
1027
|
+
export function spotifyHomeDevices(
|
|
1028
|
+
part: Part<SpotifyState> | undefined,
|
|
1029
|
+
): SpotifyHomeDevice[] {
|
|
1030
|
+
const s = part?.state;
|
|
1031
|
+
if (!s || (s.loggedIn === undefined && s.status === undefined)) return [];
|
|
1032
|
+
return deviceRecords<SpotifyHomeDevice>(part, [
|
|
1033
|
+
{
|
|
1034
|
+
connector: "spotify",
|
|
1035
|
+
id: "spotify",
|
|
1036
|
+
name: s.deviceName ?? "Soloist",
|
|
1037
|
+
kind: "speaker",
|
|
1038
|
+
available: true,
|
|
1039
|
+
state: speakerState(part),
|
|
1040
|
+
asOf: s.asOf,
|
|
1041
|
+
text: spotifyText(s),
|
|
1042
|
+
detail: s,
|
|
1043
|
+
},
|
|
1044
|
+
]);
|
|
1045
|
+
}
|
|
@@ -7,7 +7,7 @@ Reads the household's spending from Up Bank's API: two months of transactions, s
|
|
|
7
7
|
- **`client.ts`**: `upClient({ token, fetch })`. Reads: `ping`, `accounts(filter)`, `transactions(query)` following cursors to `limit` (default 1000) and `pageCap` (20) and returning `{ transactions, truncated }` so a cut-off list never looks complete, `transaction(id)`, `categories`, `tags`, `webhooks`, `webhook(id)`, `webhookLogs(id)`. Writes: `setCategory(id, categoryId | null)`, `addTags(id, labels)`, `removeTags(id, labels)`, each re-reading and returning the transaction as Up now holds it; `createWebhook`, `deleteWebhook`, `pingWebhook`. A transaction is flattened to cents (Up's `valueInBaseUnits`) and ids.
|
|
8
8
|
- **`spend.ts`**, pure, one owner per number. `notSpend(t)` is the rule and says why: held, money in, `transferAccount` set (a movement between your own accounts), or a description Up gives its own movements ("Cover from", "Forward to", "Round Up", "Quick save transfer"). "Transfer to …" is not on the list: paying someone else is spend. `isSpend`, `spendByCategory` (parents and children, excluded movements counted and summed so a total can be checked), `spendByMerchant`, `uncategorised` (spend with no category; money in is categorisable too but left out), `recurring` (a heuristic: three or more months within 20% of the median), `unusual` (over twice the median of three or more earlier purchases at that merchant), `dollars` for display.
|
|
9
9
|
- **`webhook.ts`**: `verifyWebhook(rawBody, signature, secret)`, the SHA-256 HMAC of the raw body in hex, Web Crypto, compared in constant time; `parseWebhookEvent(body)` gives the event type and the transaction id. A consumer's handler verifies, then calls `home.refresh("up")`.
|
|
10
|
-
- **The integration** (`up({ token, account?, timeZone?, fetch })`): every 15 minutes it reads the categories, the accounts the filter names, and 63 days of transactions, then cuts this month and last in the home's time zone. The snapshot holds each month's spend by category and top five merchants, the ids still to categorise, and the latest 20 purchases trimmed to id, time, description, amount, category, status and tags (movements between your own accounts are left out: their descriptions name the accounts). Never kept: raw text, message, note, who spent (`performingCustomer`), account ids, balances. A 401 is `unavailable`; a read that hit its page cap is an `anomaly`, as is an amount missing or not in AUD. It never writes.
|
|
10
|
+
- **The integration** (`up({ token, account?, timeZone?, fetch })`, with `client` on it for a consumer's writes): every 15 minutes it reads the categories, the accounts the filter names, and 63 days of transactions, then cuts this month and last in the home's time zone. The snapshot holds each month's spend by category and top five merchants, the ids still to categorise, and the latest 20 purchases trimmed to id, time, description, amount, category, status and tags (movements between your own accounts are left out: their descriptions name the accounts). Never kept: raw text, message, note, who spent (`performingCustomer`), account ids, balances. A 401 is `unavailable`; a read that hit its page cap is an `anomaly`, as is an amount missing or not in AUD. It never writes.
|
|
11
11
|
- **Config**: `{ connector: "up", key: "UP_TOKEN", account: { ownershipType: "JOINT", accountType: "TRANSACTIONAL" } }`. Accounts are chosen by type and ownership, never by id. Without `account`, every account is read; transfers between them are excluded by `transferAccount`.
|
|
12
12
|
- **CLI**: `home up` (this month, last month, to categorise, top five categories), `home up transactions [--since] [--until] [--category] [--tag] [--status] [--limit] [--json|--csv]`, `categories`, `uncategorised`, `spend [--by category|merchant|recurring]`, and the writes `categorise <id> <category|none>`, `tag <id> <label> [--remove]`, `webhooks [get|logs|ping|create|delete]`. `UP_ACCOUNT` ("JOINT TRANSACTIONAL") is the filter's env form.
|
|
13
13
|
- **Household filter** (`household: { categories, tags }` in the config, `UP_HOUSEHOLD="home tag:home"` in env): the totals, top merchants and recent rows count only purchases in a listed category or its parent, or with a listed tag, so a family dashboard shows groceries and utilities, not personal spending. Tag anything else `home` in the Up app to bring it in. "To categorise" still looks at every purchase, since a category is how one joins. A listed category Up does not have is an `anomaly`, not a silent zero. `inHousehold` is the one owner of the rule; `home up spend` applies it too. `webhooks create` writes the secret Up returns once to `~/.config/home/up-webhook-<id>.secret` (0600); it is never printed.
|
|
@@ -241,13 +241,16 @@ export function toSnapshot(
|
|
|
241
241
|
};
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
-
/** Up as a home integration: two months of transactions now, then every 15 minutes.
|
|
245
|
-
export function up(config: UpConfig): Integration<UpSnapshot, "up"> {
|
|
244
|
+
/** 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. */
|
|
245
|
+
export function up(config: UpConfig): Integration<UpSnapshot, "up"> & {
|
|
246
|
+
client: UpClient;
|
|
247
|
+
} {
|
|
246
248
|
const client = upClient(config);
|
|
247
249
|
const clock = config.clock ?? realClock;
|
|
248
250
|
const poll = poller(clock);
|
|
249
251
|
return {
|
|
250
252
|
name: "up",
|
|
253
|
+
client,
|
|
251
254
|
refresh: poll.nudge,
|
|
252
255
|
async run(signal, update) {
|
|
253
256
|
let last: UpSnapshot | undefined;
|
|
@@ -288,6 +291,7 @@ export function up(config: UpConfig): Integration<UpSnapshot, "up"> {
|
|
|
288
291
|
if (error instanceof UpError && error.status === 401)
|
|
289
292
|
return update({
|
|
290
293
|
status: "unavailable",
|
|
294
|
+
reason: "auth",
|
|
291
295
|
detail: "Up rejected the token",
|
|
292
296
|
state: last,
|
|
293
297
|
});
|
|
@@ -62,7 +62,7 @@ Plain `fetch` cannot read `ftp://` (Bun 1.4.2: "protocol must be http:, https: o
|
|
|
62
62
|
|
|
63
63
|
**Open-Meteo** (`open-meteo.ts`, [docs](https://open-meteo.com/en/docs)). One keyless JSON call per poll: `current` is 15-minutely model data (`interval: 900`), so it is read every poll, and the daily and hourly parts, from models that update hourly at best (6-hourly for IFS), are added to one call an hour. That call reads 48 hours from today's local midnight (`forecast_days=2`) for `hourly`; `sky.hours` is the 6 from the hour it was read. Open-Meteo gives local times and one `utc_offset_seconds`, and applies that offset to every time, across a daylight-saving change too (it returned 02:00 on the night Sydney skipped it; live, 2026-10-16), so `wall − offset` is right for all of them. `dayBlocks` labels each UTC hour in the real zone. 96 calls a day at the default, against 10,000 free. No `models` parameter: [Open-Meteo's BOM page](https://open-meteo.com/en/docs/bom-api) says BOM's open-data delivery is still suspended, and its `bom_access_global` answers only nulls. Radiation is the mean over the preceding interval. `precipitation` is the last 15 minutes, so `now.rainMm` stays empty rather than pretending to be since 9am. 1.3 s a call from Sydney, nearly all of it the TLS handshake with a server in Germany. Plain `fetch` of the JSON API, not the `openmeteo` SDK: the SDK forces FlatBuffers, reads variables by position, calls the global `fetch` (no injection for tests) and adds about 700 KB.
|
|
64
64
|
|
|
65
|
-
**Google Weather** (`google.ts`, [docs](https://developers.google.com/maps/documentation/weather)). Built from the docs' example responses (`test/fixtures/weather/google-*.json`, the complete objects only; the docs elide the rest) and checked live against Sydney on 2026-10-13: `now`, `forecast` and `sky` in 1.19 s. `google({ key })` reads `forecast/minutes:lookup` every 15 minutes (its floor), `currentConditions:lookup` every 30 and `forecast/days:lookup?days=2` and `forecast/hours:lookup?hours=6` hourly, since Google documents nothing that changes faster. That is 192 calls a day, about 5,800–5,950 a month. Google bills each call as one Weather Usage event: 10,000 free a month, then $0.15 per 1,000 ([pricing](https://developers.google.com/maps/billing-and-pricing/pricing)). Any other use of the same key shares that quota. Whether the minute forecast bills as the same event is unverified: its docs page shows no price. Every slice is `modelled: true`: the docs never say it is observed. A day's rain chance is the higher of its day and night halves, its amount their sum; `currentConditionsHistory.qpf` is a rolling 24 hours, so it is not used as rain since 9am. A
|
|
65
|
+
**Google Weather** (`google.ts`, [docs](https://developers.google.com/maps/documentation/weather)). Built from the docs' example responses (`test/fixtures/weather/google-*.json`, the complete objects only; the docs elide the rest) and checked live against Sydney on 2026-10-13: `now`, `forecast` and `sky` in 1.19 s. `google({ key })` reads `forecast/minutes:lookup` every 15 minutes (its floor), `currentConditions:lookup` every 30 and `forecast/days:lookup?days=2` and `forecast/hours:lookup?hours=6` hourly, since Google documents nothing that changes faster. That is 192 calls a day, about 5,800–5,950 a month. Google bills each call as one Weather Usage event: 10,000 free a month, then $0.15 per 1,000 ([pricing](https://developers.google.com/maps/billing-and-pricing/pricing)). Any other use of the same key shares that quota. The key goes in the `X-Goog-Api-Key` header: all four lookups answered 200 that way, live against a public Sydney point on 2026-11-10 (Bun 1.4.4). A bad key and a bad latitude both come back HTTP 400 `INVALID_ARGUMENT`; only the key's error body carries `API_KEY_INVALID`, which is how the source tells `auth` from `config`, and no key at all is 403. Other key failures (expired, the API not enabled) are assumed to be 401, 403 or an `API_KEY_…` reason: unverified. Whether the minute forecast bills as the same event is unverified: its docs page shows no price. Every slice is `modelled: true`: the docs never say it is observed. A day's rain chance is the higher of its day and night halves, its amount their sum; `currentConditionsHistory.qpf` is a rolling 24 hours, so it is not used as rain since 9am. A rejected key or a refused request stops the source (`stop`, with `stopReason`), with no retry loop.
|
|
66
66
|
|
|
67
67
|
**Google minute forecast** (`toRain` in `google.ts`, [docs](https://developers.google.com/maps/documentation/weather/minute-forecast)). `GET /v1/forecast/minutes:lookup` for a point returns `overallPredictionTimeframe` and 24 `segments` (6 hours) of 15 minutes, each with `type` (`NONE`, `RAIN`, `SNOW`, `HAIL`), `probability` 0–100, `qpf` in mm and `intensity`. Probed for Sydney CBD on 2026-10-15 (curl): HTTP 200 in 1.0 s, every segment dry; the first segment began 12 minutes before the forecast's start. Google calls it Experimental, so its failure is a `problem` on `rain` and never stops the other slices. The docs do not list its coverage by country, say nothing of how it is made, and allow keeping minute values for one hour at most: `rain` owns 15 minutes of age on top of one poll, so a stale slice is dropped, not served (with a `pollMinutes` above 45 the slice outlives that hour: keep it at 15), and the test fixture is invented values in the real shape. We keep the segments that start within 2 hours (`RAIN_HORIZON_MINUTES`). **`rainSoon(rain, at, windowMin = 120)` is the owner**: when the first wet stretch starts and ends, its chance, amount and weight, and `coveredMin`, how much of the window the forecast covers. A segment with no `type` is unknown, not dry. Next: if the minute forecast fails, fall back to the hourly rain chance (`forecast/hours:lookup` already carries `precipitation`; not read yet).
|
|
68
68
|
|
|
@@ -284,6 +284,7 @@ export function bom(options: BomOptions = {}): WeatherSource {
|
|
|
284
284
|
if (!options.fetch && base.startsWith("ftp:"))
|
|
285
285
|
return {
|
|
286
286
|
stop: "bom: plain fetch cannot read ftp://; pass fetch: ftpFetch() from @khalido/home/weather/ftp",
|
|
287
|
+
stopReason: "config",
|
|
287
288
|
nextAt: Infinity,
|
|
288
289
|
};
|
|
289
290
|
if (force || at >= obsDue) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import {
|
|
2
|
+
import { ConnectorError, fetchTimeout } from "../../connector.ts";
|
|
3
3
|
import { RAIN_INTENSITIES } from "./rain.ts";
|
|
4
4
|
import { sunDisagrees } from "./sun.ts";
|
|
5
5
|
import {
|
|
@@ -324,6 +324,33 @@ export type GoogleOptions = {
|
|
|
324
324
|
export const MIN_POLL_MINUTES = 15;
|
|
325
325
|
export const CURRENT_MINUTES = 30;
|
|
326
326
|
|
|
327
|
+
/**
|
|
328
|
+
* Why Google refused: the `reason` of its error body's ErrorInfo ("API_KEY_INVALID"), since the status alone doesn't say.
|
|
329
|
+
* A bad key and a bad latitude are both HTTP 400 INVALID_ARGUMENT; only the key's carries a reason (live, 2026-11-10).
|
|
330
|
+
*/
|
|
331
|
+
async function refusal(res: Response): Promise<string | undefined> {
|
|
332
|
+
try {
|
|
333
|
+
const body = (await res.json()) as {
|
|
334
|
+
error?: { details?: { reason?: unknown }[] };
|
|
335
|
+
};
|
|
336
|
+
const reason = body.error?.details?.find((d) => d.reason)?.reason;
|
|
337
|
+
return typeof reason === "string" ? reason : undefined;
|
|
338
|
+
} catch {
|
|
339
|
+
return undefined; // not JSON: the status is all there is
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Google's refusal: the HTTP status, and `why`, the reason its error body gave. */
|
|
344
|
+
class GoogleError extends ConnectorError {
|
|
345
|
+
constructor(
|
|
346
|
+
message: string,
|
|
347
|
+
status: number,
|
|
348
|
+
readonly why?: string,
|
|
349
|
+
) {
|
|
350
|
+
super(message, { status });
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
327
354
|
/**
|
|
328
355
|
* Google Weather as a source: modelled `now`, `forecast` (10 days available, 2 read), `sky` without radiation, and `rain` from the
|
|
329
356
|
* minute forecast (Experimental at Google, so its failure is a `problem` on `rain` and never stops the other slices).
|
|
@@ -353,12 +380,28 @@ export function google(options: GoogleOptions): WeatherSource {
|
|
|
353
380
|
signal,
|
|
354
381
|
}): Promise<SourceReading> {
|
|
355
382
|
const q = (extra: string) =>
|
|
356
|
-
`
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
383
|
+
`location.latitude=${place.latitude}&location.longitude=${place.longitude}${extra}`;
|
|
384
|
+
// 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).
|
|
385
|
+
const json = async (path: string): Promise<unknown> => {
|
|
386
|
+
const res = await fetchTimeout(
|
|
387
|
+
fetcher,
|
|
388
|
+
`${base}/${path}`,
|
|
389
|
+
{
|
|
390
|
+
headers: {
|
|
391
|
+
"user-agent": USER_AGENT,
|
|
392
|
+
"x-goog-api-key": options.key,
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
{ signal },
|
|
396
|
+
);
|
|
397
|
+
if (res.ok) return res.json();
|
|
398
|
+
const why = await refusal(res);
|
|
399
|
+
throw new GoogleError(
|
|
400
|
+
`HTTP ${res.status}${why ? ` ${why}` : ""} on GET /${path.split("?")[0]}`,
|
|
401
|
+
res.status,
|
|
402
|
+
why,
|
|
403
|
+
);
|
|
404
|
+
};
|
|
362
405
|
// Started first and never throws: a failed minute forecast must not sink the rest.
|
|
363
406
|
const minutes = json(`forecast/minutes:lookup?${q("")}`).then(
|
|
364
407
|
(value) => ({ value }),
|
|
@@ -385,10 +428,23 @@ export function google(options: GoogleOptions): WeatherSource {
|
|
|
385
428
|
slowDue = nextPoll(at, Math.max(pollMinutes, CURRENT_MINUTES));
|
|
386
429
|
}
|
|
387
430
|
} catch (error) {
|
|
388
|
-
|
|
389
|
-
|
|
431
|
+
// Only a GoogleError is Google's answer; a timeout or an abort is a DOMException, with fields of its own.
|
|
432
|
+
const { status, why } =
|
|
433
|
+
error instanceof GoogleError
|
|
434
|
+
? error
|
|
435
|
+
: ({} as { status?: number; why?: string });
|
|
436
|
+
const said = `HTTP ${status}${why ? ` ${why}` : ""}`;
|
|
437
|
+
if (status === 401 || status === 403 || why?.startsWith("API_KEY_"))
|
|
438
|
+
return {
|
|
439
|
+
stop: `google: the key was rejected (${said})`,
|
|
440
|
+
stopReason: "auth",
|
|
441
|
+
nextAt: Infinity,
|
|
442
|
+
};
|
|
443
|
+
// A 400 with a good key is the request itself, and here that is the location: asking again won't change it.
|
|
444
|
+
if (status === 400)
|
|
390
445
|
return {
|
|
391
|
-
stop: `google:
|
|
446
|
+
stop: `google: the request was refused (${said}); check the latitude and longitude`,
|
|
447
|
+
stopReason: "config",
|
|
392
448
|
nextAt: Infinity,
|
|
393
449
|
};
|
|
394
450
|
problem = `google: ${(error as Error).message}`; // the old slices stay; the minute forecast below is still used
|