@khalido/home 0.1.2 → 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 +33 -2
- package/README.md +1 -1
- package/dist/clock.js +2 -0
- package/dist/connector.d.ts +9 -2
- package/dist/connectors/amber/index.js +1 -0
- package/dist/connectors/cast/index.d.ts +8 -6
- package/dist/connectors/cast/index.js +30 -4
- package/dist/connectors/cast/state.d.ts +52 -0
- package/dist/connectors/cast/state.js +122 -0
- package/dist/connectors/cast/writes.d.ts +0 -2
- package/dist/connectors/cast/writes.js +25 -7
- 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/index.d.ts +6 -1
- package/dist/connectors/spotify/index.js +26 -1
- package/dist/connectors/up/index.js +1 -0
- 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/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/docs/cli.md +1 -1
- package/docs/config.md +8 -3
- package/docs/decisions.md +4 -0
- package/docs/using.md +17 -0
- package/package.json +1 -1
- package/skills/use-home/SKILL.md +7 -7
- package/src/cli/enphase.ts +5 -1
- package/src/cli/status.ts +26 -3
- package/src/clock.ts +1 -0
- package/src/connector.ts +18 -2
- package/src/connectors/amber/index.ts +1 -0
- package/src/connectors/cast/README.md +7 -3
- package/src/connectors/cast/index.ts +59 -7
- package/src/connectors/cast/state.ts +177 -0
- package/src/connectors/cast/writes.ts +29 -8
- 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 +14 -2
- package/src/connectors/spotify/index.ts +46 -2
- package/src/connectors/up/index.ts +1 -0
- 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/health.ts +4 -2
- package/src/home.ts +75 -8
- package/src/index.ts +5 -1
|
@@ -8,13 +8,16 @@ import {
|
|
|
8
8
|
MultizoneStatus,
|
|
9
9
|
ReceiverStatus,
|
|
10
10
|
} from "./schemas.ts";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
type CastDevice,
|
|
13
|
+
castKey,
|
|
14
|
+
DEFAULT_MEDIA_RECEIVER,
|
|
15
|
+
findCast,
|
|
16
|
+
} from "./state.ts";
|
|
12
17
|
|
|
13
18
|
// Writes over the sockets `cast()` already holds: volume, mute, stop, and an announcement that plays a clip and puts
|
|
14
|
-
// the device back as it was. Never called by the integration itself;
|
|
19
|
+
// the device back as it was. Never called by the integration itself; announce, volume and mute have run on real devices (the README has the dates).
|
|
15
20
|
|
|
16
|
-
/** Google's own player for a URL of audio or video. */
|
|
17
|
-
export const DEFAULT_MEDIA_RECEIVER = "CC1AD845";
|
|
18
21
|
/** Each request (a status, a volume, LAUNCH, LOAD, STOP) gets this long to answer. */
|
|
19
22
|
export const ANSWER_MS = 10_000;
|
|
20
23
|
/** How long an announcement waits for its clip to finish, beyond the clip's own length when the device reports it. */
|
|
@@ -200,9 +203,7 @@ export function castActions(ctx: WriteContext): CastActions {
|
|
|
200
203
|
|
|
201
204
|
const find = (target: string): CastDevice => {
|
|
202
205
|
const all = Object.values(ctx.devices());
|
|
203
|
-
const d =
|
|
204
|
-
all.find((x) => x.id === castKey(target)) ??
|
|
205
|
-
all.find((x) => x.name.toLowerCase() === target.toLowerCase());
|
|
206
|
+
const d = findCast(ctx.devices(), target);
|
|
206
207
|
if (!d)
|
|
207
208
|
throw new Error(
|
|
208
209
|
`no Cast device or group "${target}"; have: ${
|
|
@@ -427,7 +428,7 @@ export function castActions(ctx: WriteContext): CastActions {
|
|
|
427
428
|
/**
|
|
428
429
|
* Read the target's state, set the volume if asked and unmute a muted target, launch the Default Media Receiver, play the clip to its end, then
|
|
429
430
|
* put back what it can, always: what the Default Media Receiver was playing is loaded again where it was, an idle
|
|
430
|
-
* device gets STOP, then the volume (a group's, member by member through each member's own socket). Another app
|
|
431
|
+
* device gets STOP, then the volume (a group's own first, then member by member through each member's own socket). Another app
|
|
431
432
|
* (Spotify, YouTube) is interrupted and cannot be resumed from here; the result says so. Throws only before anything changed.
|
|
432
433
|
*/
|
|
433
434
|
async announce(target, clip, options = {}) {
|
|
@@ -549,6 +550,26 @@ export function castActions(ctx: WriteContext): CastActions {
|
|
|
549
550
|
muted: before.volume?.muted,
|
|
550
551
|
},
|
|
551
552
|
];
|
|
553
|
+
// A group keeps a level of its own, which its members' levels don't bring back (live, 2026-11-10: it stayed at the
|
|
554
|
+
// announcement's 30 with every member restored). First, so whatever it does to the members, their own levels land last.
|
|
555
|
+
if (d.kind === "group" && options.volume !== undefined) {
|
|
556
|
+
const level = before.volume?.level;
|
|
557
|
+
if (level === undefined)
|
|
558
|
+
result.notRestored.push({
|
|
559
|
+
name: d.name,
|
|
560
|
+
why: "its volume before was not reported",
|
|
561
|
+
});
|
|
562
|
+
else
|
|
563
|
+
try {
|
|
564
|
+
await receiver(s, { type: "SET_VOLUME", volume: { level } });
|
|
565
|
+
result.restored.push(`${d.name}: volume`);
|
|
566
|
+
} catch (error) {
|
|
567
|
+
result.notRestored.push({
|
|
568
|
+
name: d.name,
|
|
569
|
+
why: `volume not restored: ${errorText(error)}`,
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
}
|
|
552
573
|
for (const m of each) {
|
|
553
574
|
const ms = m.id === d.id ? s : ctx.session(m.id);
|
|
554
575
|
if (m.level === undefined)
|
|
@@ -63,6 +63,10 @@ All setpoint and fan fields, read from `e_3001` with `md` (setpoint is one byte,
|
|
|
63
63
|
|
|
64
64
|
Unmapped and unread: `p_27` (`0A00`, like auto), `p_34` (one signed byte, `00`, `mi FA`, `mx 00`), and the `p_05`–`p_08`, `p_20`–`p_25` fields (3- and 4-byte values, mostly `0F…`, likely schedules or limits). Meaning unknown.
|
|
65
65
|
|
|
66
|
+
## Read from a Pi (2026-11-10)
|
|
67
|
+
|
|
68
|
+
`home daikin` on 0.1.2 (Bun 1.4.4; chota-bot's session): "connected · off · dry · room 26 °C · outdoor unit 28 °C". `home discover` found the unit by UDP. No write was sent.
|
|
69
|
+
|
|
66
70
|
## Sources
|
|
67
71
|
|
|
68
72
|
Both Daikin protocol sources are GPL-3.0, so they are read for facts only (endpoints, field paths, value maps) and no code is copied; that keeps this package MIT.
|
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
import { asOf, type Clock, realClock } from "../../clock.ts";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import
|
|
3
|
+
import {
|
|
4
|
+
type DeviceRecord,
|
|
5
|
+
deviceRecords,
|
|
6
|
+
type Integration,
|
|
7
|
+
type Part,
|
|
8
|
+
} from "../../home.ts";
|
|
4
9
|
import { type Finder, lostDetail, relocator } from "../../discover.ts";
|
|
5
10
|
import type { Manifest } from "../../manifest.ts";
|
|
6
11
|
import { poller } from "../../poll.ts";
|
|
7
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
daikinClient,
|
|
14
|
+
DaikinError,
|
|
15
|
+
type DaikinState,
|
|
16
|
+
type Mode,
|
|
17
|
+
} from "./client.ts";
|
|
8
18
|
|
|
9
19
|
export {
|
|
10
20
|
DaikinError,
|
|
@@ -143,3 +153,32 @@ export const manifest: Manifest<DaikinSnapshot> = {
|
|
|
143
153
|
],
|
|
144
154
|
lines: ({ state }) => (state ? [acText(state)] : []),
|
|
145
155
|
};
|
|
156
|
+
|
|
157
|
+
/** The AC as a home device: `state` is `off`, its mode while on, or `on` when it hasn't said which; `detail` its snapshot. */
|
|
158
|
+
export type DaikinHomeDevice = DeviceRecord<
|
|
159
|
+
"daikin",
|
|
160
|
+
"ac",
|
|
161
|
+
"off" | "on" | Mode,
|
|
162
|
+
DaikinSnapshot
|
|
163
|
+
>;
|
|
164
|
+
|
|
165
|
+
/** The one unit, once it has been read. It reports no name or id of its own: a home config's entry names and places it. */
|
|
166
|
+
export function daikinHomeDevices(
|
|
167
|
+
part: Part<DaikinSnapshot> | undefined,
|
|
168
|
+
): DaikinHomeDevice[] {
|
|
169
|
+
const s = part?.state;
|
|
170
|
+
if (!s) return [];
|
|
171
|
+
return deviceRecords<DaikinHomeDevice>(part, [
|
|
172
|
+
{
|
|
173
|
+
connector: "daikin",
|
|
174
|
+
id: "daikin",
|
|
175
|
+
name: manifest.title,
|
|
176
|
+
kind: "ac",
|
|
177
|
+
available: true,
|
|
178
|
+
...(s.on === undefined ? {} : { state: s.on ? (s.mode ?? "on") : "off" }),
|
|
179
|
+
asOf: s.asOf,
|
|
180
|
+
text: `${manifest.title}: ${acText(s)}`,
|
|
181
|
+
detail: s,
|
|
182
|
+
},
|
|
183
|
+
]);
|
|
184
|
+
}
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { type Clock, localDay, realClock } from "../../clock.ts";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
type DeviceRecord,
|
|
5
|
+
deviceRecords,
|
|
6
|
+
filePath,
|
|
7
|
+
type Integration,
|
|
8
|
+
type Part,
|
|
9
|
+
} from "../../home.ts";
|
|
4
10
|
import { type Finder, lostDetail, relocator } from "../../discover.ts";
|
|
5
11
|
import { kw, type Manifest } from "../../manifest.ts";
|
|
6
12
|
import { poller } from "../../poll.ts";
|
|
@@ -102,7 +108,16 @@ export function enphase(
|
|
|
102
108
|
name: "enphase",
|
|
103
109
|
refresh: () => (where.reset(), poll.nudge()),
|
|
104
110
|
async run(signal, update) {
|
|
105
|
-
|
|
111
|
+
let expiresAt: Date;
|
|
112
|
+
try {
|
|
113
|
+
expiresAt = tokenExpiry(config.token); // a bad token fails this integration only
|
|
114
|
+
} catch {
|
|
115
|
+
return update({
|
|
116
|
+
status: "unavailable",
|
|
117
|
+
reason: "auth",
|
|
118
|
+
detail: "the token is unreadable; run `home enphase login`",
|
|
119
|
+
});
|
|
120
|
+
}
|
|
106
121
|
let last: EnphaseSnapshot | undefined;
|
|
107
122
|
let day:
|
|
108
123
|
| {
|
|
@@ -168,6 +183,7 @@ export function enphase(
|
|
|
168
183
|
if (error instanceof EnphaseError && error.status === 401)
|
|
169
184
|
return update({
|
|
170
185
|
status: "unavailable",
|
|
186
|
+
reason: "auth",
|
|
171
187
|
detail:
|
|
172
188
|
"the gateway rejected the token; run `home enphase login`",
|
|
173
189
|
state: last,
|
|
@@ -239,3 +255,34 @@ export const manifest: Manifest<EnphaseSnapshot> = {
|
|
|
239
255
|
files: ["token"],
|
|
240
256
|
lines: ({ state }) => (state ? [enphaseText(state)] : []),
|
|
241
257
|
};
|
|
258
|
+
|
|
259
|
+
/** The gateway as a home device: `state` is `producing` while the solar makes power, else `idle`; `detail` its snapshot. */
|
|
260
|
+
export type EnphaseHomeDevice = DeviceRecord<
|
|
261
|
+
"enphase",
|
|
262
|
+
"solar",
|
|
263
|
+
"producing" | "idle",
|
|
264
|
+
EnphaseSnapshot
|
|
265
|
+
>;
|
|
266
|
+
|
|
267
|
+
/** The one gateway, once it has been read. */
|
|
268
|
+
export function enphaseHomeDevices(
|
|
269
|
+
part: Part<EnphaseSnapshot> | undefined,
|
|
270
|
+
): EnphaseHomeDevice[] {
|
|
271
|
+
const s = part?.state;
|
|
272
|
+
if (!s) return [];
|
|
273
|
+
return deviceRecords<EnphaseHomeDevice>(part, [
|
|
274
|
+
{
|
|
275
|
+
connector: "enphase",
|
|
276
|
+
id: "enphase",
|
|
277
|
+
name: manifest.title,
|
|
278
|
+
kind: "solar",
|
|
279
|
+
available: true,
|
|
280
|
+
...(s.productionW === undefined
|
|
281
|
+
? {}
|
|
282
|
+
: { state: s.productionW > 0 ? "producing" : "idle" }),
|
|
283
|
+
asOf: s.asOf,
|
|
284
|
+
text: `${manifest.title}: ${enphaseText(s)}`,
|
|
285
|
+
detail: s,
|
|
286
|
+
},
|
|
287
|
+
]);
|
|
288
|
+
}
|
|
@@ -5,6 +5,8 @@ import { type Finder, lostDetail, relocator } from "../../discover.ts";
|
|
|
5
5
|
import {
|
|
6
6
|
envName,
|
|
7
7
|
filePath,
|
|
8
|
+
type DeviceRecord,
|
|
9
|
+
deviceRecords,
|
|
8
10
|
type Integration,
|
|
9
11
|
type Part,
|
|
10
12
|
type Status,
|
|
@@ -14,8 +16,10 @@ import { bunPinnedFetch, type HueClient, hueClient } from "./client.ts";
|
|
|
14
16
|
import { hueEvents, isRejectedKey } from "./events.ts";
|
|
15
17
|
import type { HueEvent } from "./schemas.ts";
|
|
16
18
|
import {
|
|
19
|
+
type GroupView,
|
|
17
20
|
groups,
|
|
18
21
|
type HueSnapshot,
|
|
22
|
+
type LightView,
|
|
19
23
|
lights,
|
|
20
24
|
reduce,
|
|
21
25
|
snapshotFrom,
|
|
@@ -189,6 +193,7 @@ export function hue(
|
|
|
189
193
|
if (isRejectedKey(error))
|
|
190
194
|
return update({
|
|
191
195
|
status: "unavailable",
|
|
196
|
+
reason: "auth",
|
|
192
197
|
detail: "the bridge rejected the key; pair again",
|
|
193
198
|
state: snapshot,
|
|
194
199
|
});
|
|
@@ -215,6 +220,56 @@ const view = (s: HueSnapshot) => ({
|
|
|
215
220
|
})),
|
|
216
221
|
});
|
|
217
222
|
|
|
223
|
+
/** "Kitchen: on · 40%", "Kitchen: off", or the name alone when the bridge hasn't said. */
|
|
224
|
+
const onText = (v: {
|
|
225
|
+
name?: string;
|
|
226
|
+
id: string;
|
|
227
|
+
on?: boolean;
|
|
228
|
+
brightness?: number;
|
|
229
|
+
}) =>
|
|
230
|
+
`${v.name ?? v.id}${v.on === undefined ? "" : `: ${v.on ? `on${v.brightness === undefined ? "" : ` · ${Math.round(v.brightness)}%`}` : "off"}`}`;
|
|
231
|
+
|
|
232
|
+
/** A Hue light, or a room or zone, as a home device: `detail` is its `LightView` or `GroupView`. */
|
|
233
|
+
export type HueHomeDevice =
|
|
234
|
+
| DeviceRecord<"hue", "light", "on" | "off", LightView>
|
|
235
|
+
| DeviceRecord<"hue", "group", "on" | "off", GroupView>;
|
|
236
|
+
|
|
237
|
+
/** Every light, then every room and zone. A light's `available` is its Zigbee link; a group's `members` are its lights. */
|
|
238
|
+
export function hueHomeDevices(
|
|
239
|
+
part: Part<HueSnapshot> | undefined,
|
|
240
|
+
): HueHomeDevice[] {
|
|
241
|
+
const s = part?.state;
|
|
242
|
+
if (!s) return [];
|
|
243
|
+
const on = (v: { on?: boolean }) =>
|
|
244
|
+
v.on === undefined
|
|
245
|
+
? {}
|
|
246
|
+
: { state: v.on ? ("on" as const) : ("off" as const) };
|
|
247
|
+
return deviceRecords<HueHomeDevice>(part, [
|
|
248
|
+
...lights(s).map((l): HueHomeDevice => ({
|
|
249
|
+
connector: "hue",
|
|
250
|
+
id: l.id,
|
|
251
|
+
name: l.name ?? l.id,
|
|
252
|
+
kind: "light",
|
|
253
|
+
...(l.reachable === undefined ? {} : { available: l.reachable }),
|
|
254
|
+
...on(l),
|
|
255
|
+
asOf: s.asOf,
|
|
256
|
+
text: onText(l),
|
|
257
|
+
detail: l,
|
|
258
|
+
})),
|
|
259
|
+
...groups(s).map((g): HueHomeDevice => ({
|
|
260
|
+
connector: "hue",
|
|
261
|
+
id: g.id,
|
|
262
|
+
name: g.name ?? g.id,
|
|
263
|
+
kind: "group",
|
|
264
|
+
...on(g),
|
|
265
|
+
asOf: s.asOf,
|
|
266
|
+
text: onText(g),
|
|
267
|
+
members: g.lightIds,
|
|
268
|
+
detail: g,
|
|
269
|
+
})),
|
|
270
|
+
]);
|
|
271
|
+
}
|
|
272
|
+
|
|
218
273
|
export const manifest: Manifest<HueSnapshot> = {
|
|
219
274
|
name: "hue",
|
|
220
275
|
title: "Philips Hue",
|
|
@@ -248,14 +303,8 @@ export const manifest: Manifest<HueSnapshot> = {
|
|
|
248
303
|
/** "3 lights on", then "Kitchen: on · 40%" per room and zone. */
|
|
249
304
|
lines({ state }) {
|
|
250
305
|
if (!state) return [];
|
|
251
|
-
const { lightsOn
|
|
252
|
-
return [
|
|
253
|
-
`${lightsOn} lights on`,
|
|
254
|
-
...rooms.map(
|
|
255
|
-
(r) =>
|
|
256
|
-
`${r.name}: ${r.on ? `on${r.brightness === undefined ? "" : ` · ${Math.round(r.brightness)}%`}` : "off"}`,
|
|
257
|
-
),
|
|
258
|
-
];
|
|
306
|
+
const { lightsOn } = view(state);
|
|
307
|
+
return [`${lightsOn} lights on`, ...groups(state).map(onText)];
|
|
259
308
|
},
|
|
260
309
|
record: ({ state }) => (state ? view(state) : {}),
|
|
261
310
|
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Printers
|
|
2
2
|
|
|
3
|
-
AirPrint printers, found by mDNS and read over IPP on the local network. `iot_class`: **local_polling** (one Get-Printer-Attributes per printer every 120 s). Built read-only;
|
|
3
|
+
AirPrint printers, found by mDNS and read over IPP on the local network. `iot_class`: **local_polling** (one Get-Printer-Attributes per printer every 120 s). Built read-only; first read of a live printer 2026-11-10 (see Live findings).
|
|
4
4
|
|
|
5
5
|
Try it: `home printers`
|
|
6
6
|
|
|
@@ -34,7 +34,9 @@ Everything, until a real printer answers: the request bytes (the codec is checke
|
|
|
34
34
|
|
|
35
35
|
## Live findings
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
**2026-11-10, from a Pi (Bun 1.4.4, 0.1.2; chota-bot's session).** `home discover` found the Brother by mDNS and `home printers` read it: "Brother HL-L2350DW series: idle · BK 80% · A4 ready". So discovery, the request, the default path, a real `marker-levels` and `media-ready` work on this model. Still unverified from the list above: `printer-uuid` against TXT `UUID`, a sleeping printer waking, `printer-alert`, and the macOS local-network prompt.
|
|
38
|
+
|
|
39
|
+
2026-10-14 (Bun 1.4.3): `home discover` (two passes) and `home printers` found no `_ipp._tcp` or `_ipps._tcp` instance on the network, so the Brother was not advertising (off, asleep, or on another network).
|
|
38
40
|
|
|
39
41
|
## Sources
|
|
40
42
|
|
|
@@ -11,7 +11,13 @@ import {
|
|
|
11
11
|
lostDetail,
|
|
12
12
|
rescanDelay,
|
|
13
13
|
} from "../../discover.ts";
|
|
14
|
-
import
|
|
14
|
+
import {
|
|
15
|
+
type DeviceRecord,
|
|
16
|
+
deviceRecords,
|
|
17
|
+
type Integration,
|
|
18
|
+
type Part,
|
|
19
|
+
type Status,
|
|
20
|
+
} from "../../home.ts";
|
|
15
21
|
import type { Manifest } from "../../manifest.ts";
|
|
16
22
|
import { poller } from "../../poll.ts";
|
|
17
23
|
import { getPrinterAttributes, printerUrl } from "./client.ts";
|
|
@@ -19,6 +25,7 @@ import { attributesOf } from "./codec.ts";
|
|
|
19
25
|
import {
|
|
20
26
|
type Printer,
|
|
21
27
|
printerKey,
|
|
28
|
+
type PrinterStatus,
|
|
22
29
|
printerText,
|
|
23
30
|
type PrintersState,
|
|
24
31
|
type Reading,
|
|
@@ -383,3 +390,38 @@ export const manifest: Manifest<PrintersState> = {
|
|
|
383
390
|
];
|
|
384
391
|
},
|
|
385
392
|
};
|
|
393
|
+
|
|
394
|
+
/** A printer as a home device: `state` is the printer's own (`idle`, `printing`, `stopped`, `offline`); `detail` its `Printer`. */
|
|
395
|
+
export type PrinterHomeDevice = DeviceRecord<
|
|
396
|
+
"printers",
|
|
397
|
+
"printer",
|
|
398
|
+
PrinterStatus,
|
|
399
|
+
Printer
|
|
400
|
+
>;
|
|
401
|
+
|
|
402
|
+
/** Every printer, by name. One that has stopped answering is not `available`, and keeps its last values. */
|
|
403
|
+
export function printersHomeDevices(
|
|
404
|
+
part: Part<PrintersState> | undefined,
|
|
405
|
+
): PrinterHomeDevice[] {
|
|
406
|
+
if (!part?.state) return [];
|
|
407
|
+
return deviceRecords(
|
|
408
|
+
part,
|
|
409
|
+
printerList(part.state).map((p): PrinterHomeDevice => ({
|
|
410
|
+
connector: "printers",
|
|
411
|
+
id: p.uuid,
|
|
412
|
+
name: p.name,
|
|
413
|
+
kind: "printer",
|
|
414
|
+
...(p.state === undefined ? {} : { available: p.state !== "offline" }),
|
|
415
|
+
...(p.state === undefined ? {} : { state: p.state }),
|
|
416
|
+
// An offline printer's values are from when it last answered, not from this check.
|
|
417
|
+
...(p.state === "offline"
|
|
418
|
+
? p.lastSeen === undefined
|
|
419
|
+
? {}
|
|
420
|
+
: { asOf: p.lastSeen }
|
|
421
|
+
: { asOf: p.asOf }),
|
|
422
|
+
text: printerText(p),
|
|
423
|
+
...(p.room ? { room: p.room } : {}),
|
|
424
|
+
detail: p,
|
|
425
|
+
})),
|
|
426
|
+
);
|
|
427
|
+
}
|
|
@@ -18,7 +18,7 @@ Soloist is Spotify's own headless Spotify Connect client (Linux arm64, armv7, x8
|
|
|
18
18
|
|
|
19
19
|
- Each resolves `{ state, confirmed }`: after Soloist accepts (`command_result`), it waits up to 2 s for the event that shows _this caller's_ change (the volume sent, `paused` or `playing`, a new track URI, the context or track played, a position near the seek, the shuffle or repeat set, the track in `upNext`), then gives the state. No such event: `confirmed: false`, the state as it stands. A write merged into a later one confirms only if its own change shows; a write of what is already so confirms at once; a drop ends the wait. `next` also confirms on idle (end of queue) or a restart, `prev` on a restart from 5 s or more in.
|
|
20
20
|
- A volume reported within 1 of the request confirms, since Soloist rounds it (phone steps are sixteenths).
|
|
21
|
-
- **While inactive** (`state.active: false`), Soloist accepts `set_volume` and drops it, three times live. `setVolume` is refused unsent with code `inactive`; `duck`
|
|
21
|
+
- **While inactive** (`state.active: false`), Soloist accepts `set_volume` and drops it, three times live. `setVolume` is refused unsent with code `inactive`; `duck` and `interrupt` run `fn` over the music with `music: "untouched"`: "playing" while inactive is the account playing on another speaker, which a pause would stop. `activate()` fixes it, but makes a new audio stream, so the volume can jump to the level last saved (62 to 40, live). `play` while inactive goes active first (a `play` alone didn't take live; going active first is untested). `resume` doesn't: it worked inactive, and `interrupt` relies on that.
|
|
22
22
|
- Errors are `ConnectorError` with `code`: `offline`, `waiting`, `inactive`, `refused` (Soloist's `error`), `timeout` (3 s), `invalid` (bad input, nothing sent).
|
|
23
23
|
- One write lane, 100 ms apart (a guess). `volume`, `seek`, `shuffle`, `repeat` merge to the last; `play`, `pause`, `resume` share a slot, so the last wins; each `next`, `prev`, `queue` is its own. Queries go through the lane too, at most 1 s each, since Soloist's `error` names no command.
|
|
24
24
|
- `setRepeat` is two commands in the docs' order (clear the other mode, then set this one), 600 ms apart: back to back, `off` from `track` stayed on `track` live, and 600 ms apart worked. If the second is refused, repeat may be left off.
|
|
@@ -28,7 +28,7 @@ Soloist is Spotify's own headless Spotify Connect client (Linux arm64, armv7, x8
|
|
|
28
28
|
|
|
29
29
|
**`duck(fn, { to })` and `interrupt(fn)`** turn the music down, or pause it, around `fn`, then put it back. Both resolve `{ value, music, why? }`, `music` one of:
|
|
30
30
|
|
|
31
|
-
- `untouched`: not playing or buffering, not connected, (duck) the volume unknown or already at `to` or below, or the lowering or pause failed, so `fn` ran over it.
|
|
31
|
+
- `untouched`: not playing or buffering, not connected, not the active device, (duck) the volume unknown or already at `to` or below, or the lowering or pause failed, so `fn` ran over it.
|
|
32
32
|
- `restored`: put back as it was.
|
|
33
33
|
- `kept`: someone changed it meanwhile (resumed, skipped, set the volume), or the pause or lower volume never showed. Theirs stays.
|
|
34
34
|
- `failed`: the restore failed or was never reported; it may be left quiet or paused.
|
|
@@ -68,6 +68,18 @@ Writes through this connector, and what a live read hasn't shown. In particular:
|
|
|
68
68
|
|
|
69
69
|
## Live findings
|
|
70
70
|
|
|
71
|
+
**2026-11-10, Raspberry Pi 5, Bun 1.4.4, on 0.1.2, through this connector** (chota-bot's session, KO listening).
|
|
72
|
+
|
|
73
|
+
- Confirmed with `maxVolume: 60`, the speaker active and playing: `activate`, `setVolume(50)`, `setVolume(90)` (came back 60), `seek`, `setShuffle`, `setRepeat("context")` and `("off")` (about 2.1 s each), `queue` (first in `upNext`), `play` of a playlist URI. `duck(fn, { to: 20 })` around a Cast announce: 20 inside, `music: "restored"`, 60 after, 5.2 s in all.
|
|
74
|
+
- **The level can jump above the cap on a pause, not only on a start.** One-shot CLI, cap 60: `volume 45` read 45, `play` 45, then `pause` read 62 and the next `play` 62 (62 was the level before the test). Nothing lowered it: a pause is not a start signal, and each CLI command is a new process.
|
|
75
|
+
- **That jump did not repeat, and it is not WirePlumber.** A second pass the same day, the speaker already active: 60, `volume 45`, `pause`, `play`, `volume 60`. Soloist's number and the stream's live level (`wpctl get-volume`) matched at every step (0.60, 0.45, 0.45, 0.45, 0.60), WirePlumber 0.5.8 never wrote a saved entry for the stream in `stream-properties`, and the stream kept its id across pause and play. The run that jumped had just been made active with `activate`, minutes after the account was casting from a phone, which points at the account side. One case each way.
|
|
76
|
+
- **`active: false` with `status: "playing"`:** the account playing on another speaker. `speakerState` says `playing`, `spotifyText` prints it as this speaker's, and `spotifyChanges` fired `resumed`, with the box silent.
|
|
77
|
+
- Through chota-bot's wrapper, later the same day: `play` of a playlist confirmed in 241 ms, `next` in 134 ms, `pause` in 17 ms; `duck` to 15 and `interrupt`, 2.5 s each, both `restored`. `play()` with the music paused on a Cast speaker: confirmed in 1.3 s, playing on the box at the same position.
|
|
78
|
+
- **`interrupt` paused music that was on another speaker** (found in chota's switch): it checked `playing` and not `active`. Fixed after 0.1.2.
|
|
79
|
+
- `positionMs` around a duck and an interrupt read 2 s, 2 s, 3 s, 5 s, then 4 s after a pause: it can step back a second.
|
|
80
|
+
- A consumer's test with a fake socket: a write goes out behind the connector's own queries (`get_state`, `get_queue`), up to about 1 s on a fresh socket, so "the command was sent" can't be checked straight after the call.
|
|
81
|
+
- Not run: the first-play cap from idle (the box never went active), stall events.
|
|
82
|
+
|
|
71
83
|
**2026-11-09, Raspberry Pi 5, Bun 1.4.4, Soloist 1.3.10.51, PipeWire and WirePlumber 0.5.8, on 0.1.1** (chota-bot's session; writes with KO's OK).
|
|
72
84
|
|
|
73
85
|
- Confirmed: pause, resume, next, prev, seek, shuffle, repeat to context and track, queue, all under 900 ms. `interrupt` restored the music.
|
|
@@ -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,
|
|
@@ -411,6 +422,7 @@ export function spotify(
|
|
|
411
422
|
(error) => {
|
|
412
423
|
capping = false;
|
|
413
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
|
|
414
426
|
capped = true; // refused or unanswered: not again this window
|
|
415
427
|
note = `lowering the volume to ${cap} failed: ${errorText(error)}`;
|
|
416
428
|
publish();
|
|
@@ -733,6 +745,9 @@ export function spotify(
|
|
|
733
745
|
async () => {
|
|
734
746
|
const no = notPlaying();
|
|
735
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";
|
|
736
751
|
track = state.track?.uri;
|
|
737
752
|
try {
|
|
738
753
|
shown = (await actions.pause()).confirmed; // a pause that never shows still runs fn: what interrupts matters more
|
|
@@ -999,3 +1014,32 @@ export const manifest: Manifest<SpotifyState> = {
|
|
|
999
1014
|
? [spotifyText(state)]
|
|
1000
1015
|
: [],
|
|
1001
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
|
+
}
|
|
@@ -291,6 +291,7 @@ export function up(config: UpConfig): Integration<UpSnapshot, "up"> & {
|
|
|
291
291
|
if (error instanceof UpError && error.status === 401)
|
|
292
292
|
return update({
|
|
293
293
|
status: "unavailable",
|
|
294
|
+
reason: "auth",
|
|
294
295
|
detail: "Up rejected the token",
|
|
295
296
|
state: last,
|
|
296
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
|