@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
|
@@ -2,7 +2,7 @@ import { asOf, realClock } from "../../clock.js";
|
|
|
2
2
|
import { errorText } from "../../connector.js";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { lostDetail, relocator } from "../../discover.js";
|
|
5
|
-
import { envName, filePath, } from "../../home.js";
|
|
5
|
+
import { envName, filePath, deviceRecords, } from "../../home.js";
|
|
6
6
|
import { bunPinnedFetch, hueClient } from "./client.js";
|
|
7
7
|
import { hueEvents, isRejectedKey } from "./events.js";
|
|
8
8
|
import { groups, lights, reduce, snapshotFrom, } from "./state.js";
|
|
@@ -137,6 +137,7 @@ export function hue(config) {
|
|
|
137
137
|
if (isRejectedKey(error))
|
|
138
138
|
return update({
|
|
139
139
|
status: "unavailable",
|
|
140
|
+
reason: "auth",
|
|
140
141
|
detail: "the bridge rejected the key; pair again",
|
|
141
142
|
state: snapshot,
|
|
142
143
|
});
|
|
@@ -161,6 +162,41 @@ const view = (s) => ({
|
|
|
161
162
|
brightness,
|
|
162
163
|
})),
|
|
163
164
|
});
|
|
165
|
+
/** "Kitchen: on · 40%", "Kitchen: off", or the name alone when the bridge hasn't said. */
|
|
166
|
+
const onText = (v) => `${v.name ?? v.id}${v.on === undefined ? "" : `: ${v.on ? `on${v.brightness === undefined ? "" : ` · ${Math.round(v.brightness)}%`}` : "off"}`}`;
|
|
167
|
+
/** Every light, then every room and zone. A light's `available` is its Zigbee link; a group's `members` are its lights. */
|
|
168
|
+
export function hueHomeDevices(part) {
|
|
169
|
+
const s = part?.state;
|
|
170
|
+
if (!s)
|
|
171
|
+
return [];
|
|
172
|
+
const on = (v) => v.on === undefined
|
|
173
|
+
? {}
|
|
174
|
+
: { state: v.on ? "on" : "off" };
|
|
175
|
+
return deviceRecords(part, [
|
|
176
|
+
...lights(s).map((l) => ({
|
|
177
|
+
connector: "hue",
|
|
178
|
+
id: l.id,
|
|
179
|
+
name: l.name ?? l.id,
|
|
180
|
+
kind: "light",
|
|
181
|
+
...(l.reachable === undefined ? {} : { available: l.reachable }),
|
|
182
|
+
...on(l),
|
|
183
|
+
asOf: s.asOf,
|
|
184
|
+
text: onText(l),
|
|
185
|
+
detail: l,
|
|
186
|
+
})),
|
|
187
|
+
...groups(s).map((g) => ({
|
|
188
|
+
connector: "hue",
|
|
189
|
+
id: g.id,
|
|
190
|
+
name: g.name ?? g.id,
|
|
191
|
+
kind: "group",
|
|
192
|
+
...on(g),
|
|
193
|
+
asOf: s.asOf,
|
|
194
|
+
text: onText(g),
|
|
195
|
+
members: g.lightIds,
|
|
196
|
+
detail: g,
|
|
197
|
+
})),
|
|
198
|
+
]);
|
|
199
|
+
}
|
|
164
200
|
export const manifest = {
|
|
165
201
|
name: "hue",
|
|
166
202
|
title: "Philips Hue",
|
|
@@ -195,11 +231,8 @@ export const manifest = {
|
|
|
195
231
|
lines({ state }) {
|
|
196
232
|
if (!state)
|
|
197
233
|
return [];
|
|
198
|
-
const { lightsOn
|
|
199
|
-
return [
|
|
200
|
-
`${lightsOn} lights on`,
|
|
201
|
-
...rooms.map((r) => `${r.name}: ${r.on ? `on${r.brightness === undefined ? "" : ` · ${Math.round(r.brightness)}%`}` : "off"}`),
|
|
202
|
-
];
|
|
234
|
+
const { lightsOn } = view(state);
|
|
235
|
+
return [`${lightsOn} lights on`, ...groups(state).map(onText)];
|
|
203
236
|
},
|
|
204
237
|
record: ({ state }) => (state ? view(state) : {}),
|
|
205
238
|
};
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { type Clock } from "../../clock.ts";
|
|
3
3
|
import { type Finder } from "../../discover.ts";
|
|
4
|
-
import type
|
|
4
|
+
import { type DeviceRecord, type Integration, type Part } from "../../home.ts";
|
|
5
5
|
import type { Manifest } from "../../manifest.ts";
|
|
6
|
-
import { type Printer, type PrintersState } from "./state.ts";
|
|
6
|
+
import { type Printer, type PrinterStatus, type PrintersState } from "./state.ts";
|
|
7
7
|
export { type Marker, type Printer, type PrintersState, type PrinterStatus, printerText, } from "./state.ts";
|
|
8
8
|
/** One printer in a home config. Everything the printer reports about itself (model, markers, state) comes live; `host` and `port` are only where it last answered. */
|
|
9
9
|
export declare const configSchema: z.ZodObject<{
|
|
@@ -50,3 +50,7 @@ export declare function printers(config?: PrintersConfig): Integration<PrintersS
|
|
|
50
50
|
/** Printers sorted by name. */
|
|
51
51
|
export declare const printerList: (s: PrintersState) => Printer[];
|
|
52
52
|
export declare const manifest: Manifest<PrintersState>;
|
|
53
|
+
/** A printer as a home device: `state` is the printer's own (`idle`, `printing`, `stopped`, `offline`); `detail` its `Printer`. */
|
|
54
|
+
export type PrinterHomeDevice = DeviceRecord<"printers", "printer", PrinterStatus, Printer>;
|
|
55
|
+
/** Every printer, by name. One that has stopped answering is not `available`, and keeps its last values. */
|
|
56
|
+
export declare function printersHomeDevices(part: Part<PrintersState> | undefined): PrinterHomeDevice[];
|
|
@@ -2,6 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
import { asOf, realClock } from "../../clock.js";
|
|
3
3
|
import { errorText } from "../../connector.js";
|
|
4
4
|
import { DAY_MS, discover, HOUR, lostDetail, rescanDelay, } from "../../discover.js";
|
|
5
|
+
import { deviceRecords, } from "../../home.js";
|
|
5
6
|
import { poller } from "../../poll.js";
|
|
6
7
|
import { getPrinterAttributes, printerUrl } from "./client.js";
|
|
7
8
|
import { attributesOf } from "./codec.js";
|
|
@@ -280,3 +281,25 @@ export const manifest = {
|
|
|
280
281
|
];
|
|
281
282
|
},
|
|
282
283
|
};
|
|
284
|
+
/** Every printer, by name. One that has stopped answering is not `available`, and keeps its last values. */
|
|
285
|
+
export function printersHomeDevices(part) {
|
|
286
|
+
if (!part?.state)
|
|
287
|
+
return [];
|
|
288
|
+
return deviceRecords(part, printerList(part.state).map((p) => ({
|
|
289
|
+
connector: "printers",
|
|
290
|
+
id: p.uuid,
|
|
291
|
+
name: p.name,
|
|
292
|
+
kind: "printer",
|
|
293
|
+
...(p.state === undefined ? {} : { available: p.state !== "offline" }),
|
|
294
|
+
...(p.state === undefined ? {} : { state: p.state }),
|
|
295
|
+
// An offline printer's values are from when it last answered, not from this check.
|
|
296
|
+
...(p.state === "offline"
|
|
297
|
+
? p.lastSeen === undefined
|
|
298
|
+
? {}
|
|
299
|
+
: { asOf: p.lastSeen }
|
|
300
|
+
: { asOf: p.asOf }),
|
|
301
|
+
text: printerText(p),
|
|
302
|
+
...(p.room ? { room: p.room } : {}),
|
|
303
|
+
detail: p,
|
|
304
|
+
})));
|
|
305
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { type Clock } from "../../clock.ts";
|
|
3
|
-
import type
|
|
3
|
+
import { type DeviceRecord, type Integration, type Part } from "../../home.ts";
|
|
4
4
|
import type { Manifest } from "../../manifest.ts";
|
|
5
|
+
import { type SpeakerState } from "./changes.ts";
|
|
5
6
|
import { type SocketFactory, type SpotifyState } from "./client.ts";
|
|
6
7
|
export { positionMs, type PositionAnchor, type SocketFactory, type SocketLike, type SpotifyState, type Stall, toSpotifyUri, type Track, } from "./client.ts";
|
|
7
8
|
export { type SpeakerState, speakerState, type SpotifyEvent, spotifyChanges, spotifyText, } from "./changes.ts";
|
|
@@ -92,3 +93,7 @@ export type SpotifyActions = {
|
|
|
92
93
|
*/
|
|
93
94
|
export declare function spotify(config?: SpotifyConfig): Integration<SpotifyState, "spotify"> & SpotifyActions;
|
|
94
95
|
export declare const manifest: Manifest<SpotifyState>;
|
|
96
|
+
/** The Soloist speaker as a home device: `state` is `speakerState`'s word; `detail` its `SpotifyState`. */
|
|
97
|
+
export type SpotifyHomeDevice = DeviceRecord<"spotify", "speaker", SpeakerState, SpotifyState>;
|
|
98
|
+
/** The one speaker, once Soloist has said something. Its name is Soloist's `--device-name`, the one phones show. */
|
|
99
|
+
export declare function spotifyHomeDevices(part: Part<SpotifyState> | undefined): SpotifyHomeDevice[];
|
|
@@ -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";
|
|
@@ -259,6 +260,8 @@ export function spotify(config = {}) {
|
|
|
259
260
|
capping = false;
|
|
260
261
|
if (error instanceof ConnectorError && error.code === "offline")
|
|
261
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
|
|
262
265
|
capped = true; // refused or unanswered: not again this window
|
|
263
266
|
note = `lowering the volume to ${cap} failed: ${errorText(error)}`;
|
|
264
267
|
publish();
|
|
@@ -563,6 +566,9 @@ export function spotify(config = {}) {
|
|
|
563
566
|
const no = notPlaying();
|
|
564
567
|
if (no)
|
|
565
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";
|
|
566
572
|
track = state.track?.uri;
|
|
567
573
|
try {
|
|
568
574
|
shown = (await actions.pause()).confirmed; // a pause that never shows still runs fn: what interrupts matters more
|
|
@@ -812,3 +818,22 @@ export const manifest = {
|
|
|
812
818
|
? [spotifyText(state)]
|
|
813
819
|
: [],
|
|
814
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
|
+
}
|
|
@@ -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[];
|
package/dist/devices.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { castHomeDevices, } from "./connectors/cast/index.js";
|
|
2
|
+
import { daikinHomeDevices, } from "./connectors/daikin/index.js";
|
|
3
|
+
import { enphaseHomeDevices, } from "./connectors/enphase/index.js";
|
|
4
|
+
import { hueHomeDevices } from "./connectors/hue/index.js";
|
|
5
|
+
import { printersHomeDevices, } from "./connectors/printers/index.js";
|
|
6
|
+
import { spotifyHomeDevices, } from "./connectors/spotify/index.js";
|
|
7
|
+
import { IDENTITY, sameId } from "./discover.js";
|
|
8
|
+
import { manifestOf } from "./manifests.js";
|
|
9
|
+
/** Each connector's devices from its part, in the order `home status` lists the connectors. */
|
|
10
|
+
export const DEVICES = {
|
|
11
|
+
hue: hueHomeDevices,
|
|
12
|
+
daikin: daikinHomeDevices,
|
|
13
|
+
enphase: enphaseHomeDevices,
|
|
14
|
+
cast: castHomeDevices,
|
|
15
|
+
printers: printersHomeDevices,
|
|
16
|
+
spotify: spotifyHomeDevices,
|
|
17
|
+
};
|
|
18
|
+
/** Connectors that read a service, not a device: nothing to list. */
|
|
19
|
+
export const SERVICES = ["amber", "up", "weather"];
|
|
20
|
+
/**
|
|
21
|
+
* Every device in the snapshot. With the home `config`, a device takes the `room` its entry gives it, a Hue room or zone
|
|
22
|
+
* (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
|
|
23
|
+
* no name of its own (the Daikin, the Enphase gateway) the entry's `name`. A device's own name is never replaced.
|
|
24
|
+
*/
|
|
25
|
+
export function homeDevices(snapshot, config) {
|
|
26
|
+
const all = Object.entries(DEVICES).flatMap(([name, devices]) => devices(snapshot[name]));
|
|
27
|
+
if (!config)
|
|
28
|
+
return all;
|
|
29
|
+
const entries = config.devices ?? [];
|
|
30
|
+
const hueRooms = new Map(); // a Hue group's or light's id → the config room showing it
|
|
31
|
+
for (const d of all)
|
|
32
|
+
if (d.connector === "hue" && d.kind === "group") {
|
|
33
|
+
const room = config.rooms?.find((r) => r.hueRoom !== undefined &&
|
|
34
|
+
(r.hueRoom === d.id || r.hueRoom === d.detail.groupedLightId))?.id;
|
|
35
|
+
// A light in a room and a zone takes the room's: rooms come first, and the first one stays.
|
|
36
|
+
if (room)
|
|
37
|
+
for (const id of [d.id, ...d.detail.lightIds])
|
|
38
|
+
if (!hueRooms.has(id))
|
|
39
|
+
hueRooms.set(id, room);
|
|
40
|
+
}
|
|
41
|
+
return all.map((d) => {
|
|
42
|
+
const mine = entries.filter((e) => e.connector === d.connector);
|
|
43
|
+
const field = IDENTITY[d.connector];
|
|
44
|
+
// One device for the connector: its entry is this device. Many: the entry whose identity is this device's id.
|
|
45
|
+
const entry = d.id === d.connector
|
|
46
|
+
? mine[0]
|
|
47
|
+
: mine.find((e) => {
|
|
48
|
+
const id = field && e[field];
|
|
49
|
+
return typeof id === "string" && sameId(id, d.id);
|
|
50
|
+
});
|
|
51
|
+
const room = d.room ?? entry?.room ?? hueRooms.get(d.id);
|
|
52
|
+
// A device with no name of its own carries its connector's title: only then does the entry's name replace it.
|
|
53
|
+
const unnamed = d.name === manifestOf(d.connector)?.title;
|
|
54
|
+
const name = unnamed ? (entry?.name ?? d.name) : d.name;
|
|
55
|
+
// Its line opens with its name, so a new name opens the line too.
|
|
56
|
+
const text = name !== d.name && d.text.startsWith(`${d.name}: `)
|
|
57
|
+
? name + d.text.slice(d.name.length)
|
|
58
|
+
: d.text;
|
|
59
|
+
return { ...d, name, text, ...(room ? { room } : {}) };
|
|
60
|
+
});
|
|
61
|
+
}
|
package/dist/health.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Part, Status } from "./home.ts";
|
|
1
|
+
import type { Part, Reason, Status } from "./home.ts";
|
|
2
2
|
/**
|
|
3
3
|
* How one integration is doing, judged by time: `ok`, `stale` (no good read for a while, last values still shown) or `down`.
|
|
4
4
|
* The thresholds come with it, so a page can say "stale after 4 min". Push connectors (Hue, Cast) have no poll to be late
|
|
@@ -12,6 +12,7 @@ export type Health = {
|
|
|
12
12
|
staleAfterMs?: number;
|
|
13
13
|
downAfterMs: number;
|
|
14
14
|
detail?: string;
|
|
15
|
+
reason?: Reason;
|
|
15
16
|
};
|
|
16
17
|
/** A polling connector is stale after this many polls with no good read: two missed, plus half of one so a slow read that ends in a timeout does not flap it, */
|
|
17
18
|
export declare const STALE_POLLS = 2.5;
|
package/dist/health.js
CHANGED
|
@@ -22,7 +22,7 @@ function healthOf(part, now, pollSeconds) {
|
|
|
22
22
|
(part.status !== "connected" && (age(part.downSince) ?? 0) >= downAfterMs);
|
|
23
23
|
const goodAge = age(part.lastGoodAt) ?? age(part.downSince); // never good: as old as the wait for it
|
|
24
24
|
const stale = staleAfterMs !== undefined && (goodAge ?? 0) >= staleAfterMs;
|
|
25
|
-
const { status, lastGoodAt, downSince, detail } = part;
|
|
25
|
+
const { status, lastGoodAt, downSince, detail, reason } = part;
|
|
26
26
|
return {
|
|
27
27
|
level: down ? "down" : stale ? "stale" : "ok",
|
|
28
28
|
status,
|
|
@@ -31,5 +31,6 @@ function healthOf(part, now, pollSeconds) {
|
|
|
31
31
|
staleAfterMs,
|
|
32
32
|
downAfterMs,
|
|
33
33
|
detail,
|
|
34
|
+
reason,
|
|
34
35
|
};
|
|
35
36
|
}
|
package/dist/home.d.ts
CHANGED
|
@@ -2,12 +2,14 @@ import { z } from "zod";
|
|
|
2
2
|
import { type Clock } from "./clock.ts";
|
|
3
3
|
/** A secret in a home config is the name of an environment variable, never the secret: a pasted key fails here. */
|
|
4
4
|
export declare const envName: z.ZodString;
|
|
5
|
-
/** A file a connector needs (a CA, a token), relative to the config file
|
|
5
|
+
/** A file a connector needs (a CA, a token), relative to the config file or from `~/`. The caller reads it, and expands `~/`; the library reads no files. */
|
|
6
6
|
export declare const filePath: z.ZodString;
|
|
7
7
|
/** How an integration is doing. "unavailable" means it stopped and will not retry by itself. */
|
|
8
8
|
export type Status = "connecting" | "connected" | "disconnected" | "unavailable";
|
|
9
|
+
/** Why a part is `unavailable`, for code to switch on; `detail` is the text for people. `auth`: a key or token was rejected or is unusable, so a person must re-key. `config`: a setting or a file is wrong. `crashed`: its `run` threw. */
|
|
10
|
+
export type Reason = "auth" | "config" | "crashed";
|
|
9
11
|
/**
|
|
10
|
-
* One integration's slice of the home: its status, a human detail, anything odd the device reported, and its state.
|
|
12
|
+
* One integration's slice of the home: its status, a human detail, `reason` (why it is `unavailable`, for code to switch on), anything odd the device reported, and its state.
|
|
11
13
|
* `createHome` stamps the times (UTC ISO). `lastGoodAt`: its last report while connected, or the read time a connected report
|
|
12
14
|
* gives when it repeats an older read (weather's 5-minute sun tick). `downSince`: when it stopped being connected (or started,
|
|
13
15
|
* if it never has); none while connected. Any other time an integration sets is ignored.
|
|
@@ -15,11 +17,47 @@ export type Status = "connecting" | "connected" | "disconnected" | "unavailable"
|
|
|
15
17
|
export type Part<S = unknown> = {
|
|
16
18
|
status: Status;
|
|
17
19
|
detail?: string;
|
|
20
|
+
reason?: Reason;
|
|
18
21
|
anomaly?: string;
|
|
19
22
|
state?: S;
|
|
20
23
|
lastGoodAt?: string;
|
|
21
24
|
downSince?: string;
|
|
22
25
|
};
|
|
26
|
+
/**
|
|
27
|
+
* The few things every device answers, whatever it is, in the same words; a connector's `<name>HomeDevices(part)` returns
|
|
28
|
+
* these and `homeDevices(snapshot)` collects them. Everything particular to its kind is in `detail`, the connector's own
|
|
29
|
+
* typed row, and `connector` (with `kind`) says which type that is. Unknown stays absent, never guessed.
|
|
30
|
+
* - `id`: the device's own stable id (a Hue light id, a castId, a printer's UUID), or the connector's name when it has one device.
|
|
31
|
+
* - `kind`: light, group, speaker, display, tv, ac, printer, solar (and `cast` for a Cast model this library doesn't know).
|
|
32
|
+
* - `available`: it answered and can be used now. When it is false, `reason` says why in one word: `offline`, or the
|
|
33
|
+
* connector's own reason for being `unavailable` (`auth`, `config`, `crashed`).
|
|
34
|
+
* - `asOf`: when the device last reported (a push connector) or was last read (a polled one). A quiet light can be hours
|
|
35
|
+
* old and current: `available` says whether it is.
|
|
36
|
+
* - `state`: one short word in that kind's vocabulary: on, off, idle, busy, cool, printing.
|
|
37
|
+
* - `text`: one line for people, "Kitchen: on · 60%".
|
|
38
|
+
* - `room`: a room id from the home config, when one places it.
|
|
39
|
+
* - `members`: a group's device ids.
|
|
40
|
+
*/
|
|
41
|
+
export type DeviceRecord<C extends string = string, K extends string = string, S extends string = string, D = unknown> = {
|
|
42
|
+
connector: C;
|
|
43
|
+
id: string;
|
|
44
|
+
name: string;
|
|
45
|
+
kind: K;
|
|
46
|
+
available?: boolean;
|
|
47
|
+
reason?: Reason | "offline";
|
|
48
|
+
asOf?: string;
|
|
49
|
+
state?: S;
|
|
50
|
+
text: string;
|
|
51
|
+
room?: string;
|
|
52
|
+
members?: string[];
|
|
53
|
+
detail: D;
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* A connector's device records from its part. While the connector isn't connected none is `available`, and each keeps its
|
|
57
|
+
* last values, as the part's state does. A device that isn't available gets its `reason`: the part's when it stopped for
|
|
58
|
+
* good, else `offline`.
|
|
59
|
+
*/
|
|
60
|
+
export declare const deviceRecords: <R extends DeviceRecord>(part: Part | undefined, records: R[]) => R[];
|
|
23
61
|
/**
|
|
24
62
|
* An integration is a folder in `src/` that exports a factory returning this.
|
|
25
63
|
* `run` keeps its state current until `signal` aborts, reporting each change through `update`.
|
|
@@ -64,7 +102,7 @@ export declare function createHome<I extends Integration<any, string>>(integrati
|
|
|
64
102
|
}> | undefined;
|
|
65
103
|
/**
|
|
66
104
|
* Read now: one integration by name, or every one. Results arrive through the snapshot and `onChange` as usual; the
|
|
67
|
-
* promise resolves once each target has reported (its next update, good or bad), or at once for one
|
|
105
|
+
* promise resolves once each target has reported (its next update, good or bad), or at once for one that isn't running.
|
|
68
106
|
*/
|
|
69
107
|
refresh(name?: I["name"]): Promise<void>;
|
|
70
108
|
/** Run every integration until `signal` aborts. One failing marks itself unavailable; the rest carry on. */
|
package/dist/home.js
CHANGED
|
@@ -5,8 +5,21 @@ import { errorText } from "./connector.js";
|
|
|
5
5
|
export const envName = z
|
|
6
6
|
.string()
|
|
7
7
|
.regex(/^[A-Z_][A-Z0-9_]*$/, "an environment variable name (e.g. AMBER_KEY), not the secret itself");
|
|
8
|
-
/** A file a connector needs (a CA, a token), relative to the config file
|
|
8
|
+
/** A file a connector needs (a CA, a token), relative to the config file or from `~/`. The caller reads it, and expands `~/`; the library reads no files. */
|
|
9
9
|
export const filePath = z.string().min(1);
|
|
10
|
+
/**
|
|
11
|
+
* A connector's device records from its part. While the connector isn't connected none is `available`, and each keeps its
|
|
12
|
+
* last values, as the part's state does. A device that isn't available gets its `reason`: the part's when it stopped for
|
|
13
|
+
* good, else `offline`.
|
|
14
|
+
*/
|
|
15
|
+
export const deviceRecords = (part, records) => {
|
|
16
|
+
const up = part?.status === "connected";
|
|
17
|
+
// A part that stopped for good without saying why is a custom integration's: `config` is the nearest true word, since it won't come back by itself.
|
|
18
|
+
const why = part?.status === "unavailable" ? (part.reason ?? "config") : "offline";
|
|
19
|
+
return records.map((r) => up && r.available !== false
|
|
20
|
+
? r
|
|
21
|
+
: { ...r, available: false, reason: up ? "offline" : why });
|
|
22
|
+
};
|
|
10
23
|
/** The times `createHome` keeps on a part, from the one before it: when it was last good, and when it stopped being. */
|
|
11
24
|
function stamped(part, before, now) {
|
|
12
25
|
if (part.status === "connected")
|
|
@@ -29,12 +42,12 @@ export function createHome(integrations, onChange, options = {}) {
|
|
|
29
42
|
const clock = options.clock ?? realClock;
|
|
30
43
|
let snapshot = Object.fromEntries(integrations.map((i) => [i.name, { status: "connecting" }]));
|
|
31
44
|
const waiting = new Map(); // refresh() callers, resolved by the integration's next report
|
|
32
|
-
const finished = new Set(); //
|
|
45
|
+
const finished = new Set(integrations.map((i) => i.name)); // not running, before start or after its run ended: a refresh of one resolves at once, nothing will report
|
|
33
46
|
const set = (name, reported) => {
|
|
34
47
|
const before = snapshot[name];
|
|
35
48
|
const part = stamped(reported, before, asOf(clock));
|
|
36
49
|
snapshot = { ...snapshot, [name]: part };
|
|
37
|
-
onChange?.(snapshot, { name, before, after: part });
|
|
50
|
+
Promise.try(() => onChange?.(snapshot, { name, before, after: part })).catch((error) => console.error(`onChange threw on ${name}: ${errorText(error)}`)); // the caller's bug, thrown or async, not the device's: the integration keeps running
|
|
38
51
|
const w = waiting.get(name);
|
|
39
52
|
if (w) {
|
|
40
53
|
waiting.delete(name);
|
|
@@ -51,7 +64,7 @@ export function createHome(integrations, onChange, options = {}) {
|
|
|
51
64
|
integration: (name) => integrations.find((i) => i.name === name),
|
|
52
65
|
/**
|
|
53
66
|
* Read now: one integration by name, or every one. Results arrive through the snapshot and `onChange` as usual; the
|
|
54
|
-
* promise resolves once each target has reported (its next update, good or bad), or at once for one
|
|
67
|
+
* promise resolves once each target has reported (its next update, good or bad), or at once for one that isn't running.
|
|
55
68
|
*/
|
|
56
69
|
refresh(name) {
|
|
57
70
|
const targets = name === undefined
|
|
@@ -78,9 +91,13 @@ export function createHome(integrations, onChange, options = {}) {
|
|
|
78
91
|
? { ...p, downSince: p.downSince ?? now }
|
|
79
92
|
: p,
|
|
80
93
|
]));
|
|
81
|
-
await Promise.all(integrations.map((i) => i
|
|
82
|
-
.
|
|
83
|
-
|
|
94
|
+
await Promise.all(integrations.map((i) => Promise.try(() => i.run(signal, (part) => set(i.name, part)))
|
|
95
|
+
.catch((error) => set(i.name, {
|
|
96
|
+
status: "unavailable",
|
|
97
|
+
reason: "crashed",
|
|
98
|
+
detail: errorText(error),
|
|
99
|
+
state: snapshot[i.name].state, // the last good state stays, as it does through a failed read
|
|
100
|
+
}))
|
|
84
101
|
.finally(() => {
|
|
85
102
|
finished.add(i.name);
|
|
86
103
|
for (const resolve of waiting.get(i.name) ?? [])
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
export { type Change, createHome, envName, filePath, settle, type HomeSnapshot, type Integration, type Part, type StateOf, type Status, } from "./home.ts";
|
|
1
|
+
export { type Change, createHome, type DeviceRecord, deviceRecords, envName, filePath, settle, type HomeSnapshot, type Integration, type Part, type Reason, type StateOf, type Status, } from "./home.ts";
|
|
2
2
|
export { DOWN_POLLS, type Health, health, PUSH_DOWN_MS, STALE_POLLS, } from "./health.ts";
|
|
3
|
+
export { type HomeDevice, homeDevices } from "./devices.ts";
|
|
3
4
|
export { asOf, type Clock, realClock } from "./clock.ts";
|
|
4
|
-
export { ConnectorError } from "./connector.ts";
|
|
5
|
+
export { type ConnectorCode, ConnectorError } from "./connector.ts";
|
|
5
6
|
export { bestWindow, type EnergyNow, energyNow, gridText, type PriceWindow, solarLevel, } from "./energy.ts";
|
|
6
7
|
export { checkHome, checkPlan, Device, HomeConfig, HomeConfigError, type HomeIntegration, type LoadOptions, loadHome, parseJsonc, Room, type Setup, } from "./config.ts";
|
|
7
8
|
export type { AddressCache, CachedAddress, Connector, Finder, Found, } from "./discover.ts";
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// The root entry: the home, its config, discovery types, the clock, energy, and the sun, rain and day-block helpers. The weather types are in `./weather`.
|
|
2
2
|
// Each connector has its own entry point (`@khalido/home/hue` and so on). Every export is named; test/exports.test.ts lists them.
|
|
3
|
-
export { createHome, envName, filePath, settle, } from "./home.js";
|
|
3
|
+
export { createHome, deviceRecords, envName, filePath, settle, } from "./home.js";
|
|
4
4
|
export { DOWN_POLLS, health, PUSH_DOWN_MS, STALE_POLLS, } from "./health.js";
|
|
5
|
+
export { homeDevices } from "./devices.js";
|
|
5
6
|
export { asOf, realClock } from "./clock.js";
|
|
6
7
|
export { ConnectorError } from "./connector.js";
|
|
7
8
|
export { bestWindow, energyNow, gridText, solarLevel, } from "./energy.js";
|