@khalido/home 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +60 -2
- package/README.md +12 -12
- package/dist/clock.js +2 -0
- package/dist/config.js +3 -1
- package/dist/connector.d.ts +9 -2
- package/dist/connectors/amber/index.js +1 -0
- package/dist/connectors/cast/index.d.ts +10 -5
- package/dist/connectors/cast/index.js +36 -4
- package/dist/connectors/cast/schemas.d.ts +2 -0
- package/dist/connectors/cast/schemas.js +1 -0
- package/dist/connectors/cast/state.d.ts +56 -0
- package/dist/connectors/cast/state.js +134 -0
- package/dist/connectors/cast/writes.d.ts +9 -2
- package/dist/connectors/cast/writes.js +67 -13
- package/dist/connectors/daikin/index.d.ts +6 -2
- package/dist/connectors/daikin/index.js +21 -1
- package/dist/connectors/enphase/index.d.ts +5 -1
- package/dist/connectors/enphase/index.js +34 -2
- package/dist/connectors/hue/index.d.ts +6 -2
- package/dist/connectors/hue/index.js +39 -6
- package/dist/connectors/printers/index.d.ts +6 -2
- package/dist/connectors/printers/index.js +23 -0
- package/dist/connectors/spotify/changes.d.ts +1 -1
- package/dist/connectors/spotify/changes.js +4 -2
- package/dist/connectors/spotify/client.d.ts +1 -1
- package/dist/connectors/spotify/index.d.ts +9 -2
- package/dist/connectors/spotify/index.js +130 -45
- package/dist/connectors/up/index.d.ts +4 -2
- package/dist/connectors/up/index.js +3 -1
- package/dist/connectors/weather/bom.js +1 -0
- package/dist/connectors/weather/google.js +52 -9
- package/dist/connectors/weather/index.d.ts +2 -1
- package/dist/connectors/weather/index.js +7 -0
- package/dist/connectors/weather/source.d.ts +2 -0
- package/dist/devices.d.ts +27 -0
- package/dist/devices.js +61 -0
- package/dist/discover.d.ts +2 -0
- package/dist/discover.js +4 -1
- package/dist/health.d.ts +2 -1
- package/dist/health.js +2 -1
- package/dist/home.d.ts +41 -3
- package/dist/home.js +24 -7
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/lane.js +24 -20
- package/docs/cast.md +1 -1
- package/docs/cli.md +3 -3
- package/docs/config.md +8 -3
- package/docs/decisions.md +5 -0
- package/docs/using.md +17 -0
- package/package.json +1 -1
- package/skills/use-home/SKILL.md +7 -7
- package/src/cli/cast.ts +19 -11
- package/src/cli/enphase.ts +5 -1
- package/src/cli/spotify.ts +2 -1
- package/src/cli/status.ts +26 -3
- package/src/clock.ts +1 -0
- package/src/config.ts +3 -1
- package/src/connector.ts +18 -2
- package/src/connectors/amber/index.ts +1 -0
- package/src/connectors/cast/README.md +10 -5
- package/src/connectors/cast/index.ts +74 -9
- package/src/connectors/cast/schemas.ts +1 -0
- package/src/connectors/cast/state.ts +189 -0
- package/src/connectors/cast/writes.ts +87 -17
- package/src/connectors/daikin/README.md +4 -0
- package/src/connectors/daikin/index.ts +41 -2
- package/src/connectors/enphase/index.ts +49 -2
- package/src/connectors/hue/index.ts +57 -8
- package/src/connectors/printers/README.md +4 -2
- package/src/connectors/printers/index.ts +43 -1
- package/src/connectors/spotify/README.md +33 -9
- package/src/connectors/spotify/changes.ts +4 -2
- package/src/connectors/spotify/client.ts +1 -1
- package/src/connectors/spotify/index.ts +157 -49
- package/src/connectors/up/README.md +1 -1
- package/src/connectors/up/index.ts +6 -2
- package/src/connectors/weather/README.md +1 -1
- package/src/connectors/weather/bom.ts +1 -0
- package/src/connectors/weather/google.ts +66 -10
- package/src/connectors/weather/index.ts +16 -2
- package/src/connectors/weather/source.ts +2 -0
- package/src/devices.ts +102 -0
- package/src/discover.ts +6 -2
- package/src/health.ts +4 -2
- package/src/home.ts +75 -8
- package/src/index.ts +5 -1
- package/src/lane.ts +23 -20
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Clock, localTime, realClock } from "../../clock.ts";
|
|
2
|
-
import type { Integration, Status } from "../../home.ts";
|
|
2
|
+
import type { Integration, Reason, Status } from "../../home.ts";
|
|
3
3
|
import { field, type Manifest } from "../../manifest.ts";
|
|
4
4
|
import { poller } from "../../poll.ts";
|
|
5
5
|
import {
|
|
@@ -88,7 +88,13 @@ export type WeatherState = Slices & {
|
|
|
88
88
|
timeZone?: string; // the config's, which the blocks and the text use
|
|
89
89
|
sources: Record<
|
|
90
90
|
string,
|
|
91
|
-
{
|
|
91
|
+
{
|
|
92
|
+
read: boolean;
|
|
93
|
+
problem?: string;
|
|
94
|
+
stopped?: string; // it will not be read again, and why
|
|
95
|
+
reason?: Exclude<Reason, "crashed">; // with `stopped`, for code: a dead key shows here even while another source keeps the part connected
|
|
96
|
+
anomaly?: string;
|
|
97
|
+
}
|
|
92
98
|
>;
|
|
93
99
|
};
|
|
94
100
|
|
|
@@ -149,6 +155,7 @@ export function weather(
|
|
|
149
155
|
read: r !== undefined,
|
|
150
156
|
problem: r?.problem,
|
|
151
157
|
stopped: r?.stop,
|
|
158
|
+
reason: r?.stop ? (r.stopReason ?? "config") : undefined,
|
|
152
159
|
anomaly: r?.anomaly,
|
|
153
160
|
},
|
|
154
161
|
];
|
|
@@ -174,6 +181,13 @@ export function weather(
|
|
|
174
181
|
: undefined;
|
|
175
182
|
update({
|
|
176
183
|
status,
|
|
184
|
+
// Every source has stopped: a rejected key outranks a wrong setting, since it needs a person.
|
|
185
|
+
reason:
|
|
186
|
+
status !== "unavailable"
|
|
187
|
+
? undefined
|
|
188
|
+
: Object.values(report).some((r) => r.reason === "auth")
|
|
189
|
+
? "auth"
|
|
190
|
+
: "config",
|
|
177
191
|
detail,
|
|
178
192
|
anomaly: notes("anomaly"),
|
|
179
193
|
lastGoodAt,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ConnectorError, fetchTimeout } from "../../connector.ts";
|
|
2
|
+
import type { Reason } from "../../home.ts";
|
|
2
3
|
|
|
3
4
|
// The one place a weather provider plugs in: a `WeatherSource` returns slices, and `mergeWeather` picks one per slice.
|
|
4
5
|
|
|
@@ -142,6 +143,7 @@ export type SourceReading = Slices & {
|
|
|
142
143
|
problem?: string; // a failed product, a missing option; the source keeps going
|
|
143
144
|
anomaly?: string; // a value the source sent that could not be read as given
|
|
144
145
|
stop?: string; // a terminal problem (a rejected key): the integration stops calling this source
|
|
146
|
+
stopReason?: Exclude<Reason, "crashed">; // with `stop`: a person must re-key, or fix a setting (the default)
|
|
145
147
|
};
|
|
146
148
|
|
|
147
149
|
/**
|
package/src/devices.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CastHomeDevice,
|
|
3
|
+
castHomeDevices,
|
|
4
|
+
} from "./connectors/cast/index.ts";
|
|
5
|
+
import {
|
|
6
|
+
type DaikinHomeDevice,
|
|
7
|
+
daikinHomeDevices,
|
|
8
|
+
} from "./connectors/daikin/index.ts";
|
|
9
|
+
import {
|
|
10
|
+
type EnphaseHomeDevice,
|
|
11
|
+
enphaseHomeDevices,
|
|
12
|
+
} from "./connectors/enphase/index.ts";
|
|
13
|
+
import { type HueHomeDevice, hueHomeDevices } from "./connectors/hue/index.ts";
|
|
14
|
+
import {
|
|
15
|
+
type PrinterHomeDevice,
|
|
16
|
+
printersHomeDevices,
|
|
17
|
+
} from "./connectors/printers/index.ts";
|
|
18
|
+
import {
|
|
19
|
+
type SpotifyHomeDevice,
|
|
20
|
+
spotifyHomeDevices,
|
|
21
|
+
} from "./connectors/spotify/index.ts";
|
|
22
|
+
import type { HomeConfig } from "./config.ts";
|
|
23
|
+
import { IDENTITY, sameId } from "./discover.ts";
|
|
24
|
+
import type { DeviceRecord, Part } from "./home.ts";
|
|
25
|
+
import { manifestOf } from "./manifests.ts";
|
|
26
|
+
|
|
27
|
+
// Every device in the home in one list, each answering the same few questions (`DeviceRecord`), with its connector's own
|
|
28
|
+
// typed row in `detail`. A connector with devices exports `<name>HomeDevices(part)` and is listed here, once in the union
|
|
29
|
+
// and once in DEVICES; test/devices.test.ts fails for a manifest that is in neither DEVICES nor SERVICES.
|
|
30
|
+
|
|
31
|
+
/** Any device this library reads. `connector`, then `kind` where a connector has more than one, narrows `detail` and `state`. */
|
|
32
|
+
export type HomeDevice =
|
|
33
|
+
| HueHomeDevice
|
|
34
|
+
| DaikinHomeDevice
|
|
35
|
+
| EnphaseHomeDevice
|
|
36
|
+
| CastHomeDevice
|
|
37
|
+
| PrinterHomeDevice
|
|
38
|
+
| SpotifyHomeDevice;
|
|
39
|
+
|
|
40
|
+
/** Each connector's devices from its part, in the order `home status` lists the connectors. */
|
|
41
|
+
export const DEVICES = {
|
|
42
|
+
hue: hueHomeDevices,
|
|
43
|
+
daikin: daikinHomeDevices,
|
|
44
|
+
enphase: enphaseHomeDevices,
|
|
45
|
+
cast: castHomeDevices,
|
|
46
|
+
printers: printersHomeDevices,
|
|
47
|
+
spotify: spotifyHomeDevices,
|
|
48
|
+
} satisfies Record<string, (part: Part<any> | undefined) => DeviceRecord[]>;
|
|
49
|
+
|
|
50
|
+
/** Connectors that read a service, not a device: nothing to list. */
|
|
51
|
+
export const SERVICES: readonly string[] = ["amber", "up", "weather"];
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Every device in the snapshot. With the home `config`, a device takes the `room` its entry gives it, a Hue room or zone
|
|
55
|
+
* (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
|
|
56
|
+
* no name of its own (the Daikin, the Enphase gateway) the entry's `name`. A device's own name is never replaced.
|
|
57
|
+
*/
|
|
58
|
+
export function homeDevices(
|
|
59
|
+
snapshot: Record<string, Part | undefined>,
|
|
60
|
+
config?: Pick<HomeConfig, "devices" | "rooms">,
|
|
61
|
+
): HomeDevice[] {
|
|
62
|
+
const all = Object.entries(DEVICES).flatMap(([name, devices]): HomeDevice[] =>
|
|
63
|
+
devices(snapshot[name] as never),
|
|
64
|
+
);
|
|
65
|
+
if (!config) return all;
|
|
66
|
+
const entries = config.devices ?? [];
|
|
67
|
+
const hueRooms = new Map<string, string>(); // a Hue group's or light's id → the config room showing it
|
|
68
|
+
for (const d of all)
|
|
69
|
+
if (d.connector === "hue" && d.kind === "group") {
|
|
70
|
+
const room = config.rooms?.find(
|
|
71
|
+
(r) =>
|
|
72
|
+
r.hueRoom !== undefined &&
|
|
73
|
+
(r.hueRoom === d.id || r.hueRoom === d.detail.groupedLightId),
|
|
74
|
+
)?.id;
|
|
75
|
+
// A light in a room and a zone takes the room's: rooms come first, and the first one stays.
|
|
76
|
+
if (room)
|
|
77
|
+
for (const id of [d.id, ...d.detail.lightIds])
|
|
78
|
+
if (!hueRooms.has(id)) hueRooms.set(id, room);
|
|
79
|
+
}
|
|
80
|
+
return all.map((d) => {
|
|
81
|
+
const mine = entries.filter((e) => e.connector === d.connector);
|
|
82
|
+
const field = (IDENTITY as Record<string, string>)[d.connector];
|
|
83
|
+
// One device for the connector: its entry is this device. Many: the entry whose identity is this device's id.
|
|
84
|
+
const entry =
|
|
85
|
+
d.id === d.connector
|
|
86
|
+
? mine[0]
|
|
87
|
+
: mine.find((e) => {
|
|
88
|
+
const id = field && (e as Record<string, unknown>)[field];
|
|
89
|
+
return typeof id === "string" && sameId(id, d.id);
|
|
90
|
+
});
|
|
91
|
+
const room = d.room ?? entry?.room ?? hueRooms.get(d.id);
|
|
92
|
+
// A device with no name of its own carries its connector's title: only then does the entry's name replace it.
|
|
93
|
+
const unnamed = d.name === manifestOf(d.connector)?.title;
|
|
94
|
+
const name = unnamed ? (entry?.name ?? d.name) : d.name;
|
|
95
|
+
// Its line opens with its name, so a new name opens the line too.
|
|
96
|
+
const text =
|
|
97
|
+
name !== d.name && d.text.startsWith(`${d.name}: `)
|
|
98
|
+
? name + d.text.slice(d.name.length)
|
|
99
|
+
: d.text;
|
|
100
|
+
return { ...d, name, text, ...(room ? { room } : {}) };
|
|
101
|
+
});
|
|
102
|
+
}
|
package/src/discover.ts
CHANGED
|
@@ -307,6 +307,7 @@ export type CachedAddress = {
|
|
|
307
307
|
host: string;
|
|
308
308
|
port?: number;
|
|
309
309
|
name?: string;
|
|
310
|
+
model?: string; // Cast: what kind of device, without a discovery
|
|
310
311
|
seenAt: string;
|
|
311
312
|
};
|
|
312
313
|
|
|
@@ -345,7 +346,7 @@ export function addressBook(
|
|
|
345
346
|
seen(
|
|
346
347
|
connector: Connector,
|
|
347
348
|
id: string | undefined,
|
|
348
|
-
address: { host: string; port?: number; name?: string },
|
|
349
|
+
address: { host: string; port?: number; name?: string; model?: string },
|
|
349
350
|
): void {
|
|
350
351
|
if (!cache || id === undefined) return;
|
|
351
352
|
const key = addressKey(connector, id);
|
|
@@ -355,14 +356,17 @@ export function addressBook(
|
|
|
355
356
|
c.host === address.host &&
|
|
356
357
|
c.port === address.port &&
|
|
357
358
|
isFresh(c) &&
|
|
358
|
-
(!address.name || address.name === c.name)
|
|
359
|
+
(!address.name || address.name === c.name) &&
|
|
360
|
+
(!address.model || address.model === c.model)
|
|
359
361
|
)
|
|
360
362
|
return;
|
|
361
363
|
const name = address.name || c?.name;
|
|
364
|
+
const model = address.model || c?.model;
|
|
362
365
|
entries[key] = {
|
|
363
366
|
host: address.host,
|
|
364
367
|
...(address.port === undefined ? {} : { port: address.port }),
|
|
365
368
|
...(name ? { name } : {}),
|
|
369
|
+
...(model ? { model } : {}),
|
|
366
370
|
seenAt: new Date(now()).toISOString(),
|
|
367
371
|
};
|
|
368
372
|
cache.write({ ...entries });
|
package/src/health.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
|
import { MANIFESTS } from "./manifests.ts";
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -14,6 +14,7 @@ export type Health = {
|
|
|
14
14
|
staleAfterMs?: number;
|
|
15
15
|
downAfterMs: number;
|
|
16
16
|
detail?: string;
|
|
17
|
+
reason?: Reason;
|
|
17
18
|
};
|
|
18
19
|
|
|
19
20
|
/** 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, */
|
|
@@ -61,7 +62,7 @@ function healthOf(
|
|
|
61
62
|
(part.status !== "connected" && (age(part.downSince) ?? 0) >= downAfterMs);
|
|
62
63
|
const goodAge = age(part.lastGoodAt) ?? age(part.downSince); // never good: as old as the wait for it
|
|
63
64
|
const stale = staleAfterMs !== undefined && (goodAge ?? 0) >= staleAfterMs;
|
|
64
|
-
const { status, lastGoodAt, downSince, detail } = part;
|
|
65
|
+
const { status, lastGoodAt, downSince, detail, reason } = part;
|
|
65
66
|
return {
|
|
66
67
|
level: down ? "down" : stale ? "stale" : "ok",
|
|
67
68
|
status,
|
|
@@ -70,5 +71,6 @@ function healthOf(
|
|
|
70
71
|
staleAfterMs,
|
|
71
72
|
downAfterMs,
|
|
72
73
|
detail,
|
|
74
|
+
reason,
|
|
73
75
|
};
|
|
74
76
|
}
|
package/src/home.ts
CHANGED
|
@@ -9,15 +9,18 @@ export const envName = z
|
|
|
9
9
|
/^[A-Z_][A-Z0-9_]*$/,
|
|
10
10
|
"an environment variable name (e.g. AMBER_KEY), not the secret itself",
|
|
11
11
|
);
|
|
12
|
-
/** A file a connector needs (a CA, a token), relative to the config file
|
|
12
|
+
/** 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. */
|
|
13
13
|
export const filePath = z.string().min(1);
|
|
14
14
|
|
|
15
15
|
/** How an integration is doing. "unavailable" means it stopped and will not retry by itself. */
|
|
16
16
|
export type Status =
|
|
17
17
|
"connecting" | "connected" | "disconnected" | "unavailable";
|
|
18
18
|
|
|
19
|
+
/** 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. */
|
|
20
|
+
export type Reason = "auth" | "config" | "crashed";
|
|
21
|
+
|
|
19
22
|
/**
|
|
20
|
-
* One integration's slice of the home: its status, a human detail, anything odd the device reported, and its state.
|
|
23
|
+
* 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.
|
|
21
24
|
* `createHome` stamps the times (UTC ISO). `lastGoodAt`: its last report while connected, or the read time a connected report
|
|
22
25
|
* gives when it repeats an older read (weather's 5-minute sun tick). `downSince`: when it stopped being connected (or started,
|
|
23
26
|
* if it never has); none while connected. Any other time an integration sets is ignored.
|
|
@@ -25,12 +28,68 @@ export type Status =
|
|
|
25
28
|
export type Part<S = unknown> = {
|
|
26
29
|
status: Status;
|
|
27
30
|
detail?: string;
|
|
31
|
+
reason?: Reason;
|
|
28
32
|
anomaly?: string;
|
|
29
33
|
state?: S;
|
|
30
34
|
lastGoodAt?: string;
|
|
31
35
|
downSince?: string;
|
|
32
36
|
};
|
|
33
37
|
|
|
38
|
+
/**
|
|
39
|
+
* The few things every device answers, whatever it is, in the same words; a connector's `<name>HomeDevices(part)` returns
|
|
40
|
+
* these and `homeDevices(snapshot)` collects them. Everything particular to its kind is in `detail`, the connector's own
|
|
41
|
+
* typed row, and `connector` (with `kind`) says which type that is. Unknown stays absent, never guessed.
|
|
42
|
+
* - `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.
|
|
43
|
+
* - `kind`: light, group, speaker, display, tv, ac, printer, solar (and `cast` for a Cast model this library doesn't know).
|
|
44
|
+
* - `available`: it answered and can be used now. When it is false, `reason` says why in one word: `offline`, or the
|
|
45
|
+
* connector's own reason for being `unavailable` (`auth`, `config`, `crashed`).
|
|
46
|
+
* - `asOf`: when the device last reported (a push connector) or was last read (a polled one). A quiet light can be hours
|
|
47
|
+
* old and current: `available` says whether it is.
|
|
48
|
+
* - `state`: one short word in that kind's vocabulary: on, off, idle, busy, cool, printing.
|
|
49
|
+
* - `text`: one line for people, "Kitchen: on · 60%".
|
|
50
|
+
* - `room`: a room id from the home config, when one places it.
|
|
51
|
+
* - `members`: a group's device ids.
|
|
52
|
+
*/
|
|
53
|
+
export type DeviceRecord<
|
|
54
|
+
C extends string = string,
|
|
55
|
+
K extends string = string,
|
|
56
|
+
S extends string = string,
|
|
57
|
+
D = unknown,
|
|
58
|
+
> = {
|
|
59
|
+
connector: C;
|
|
60
|
+
id: string;
|
|
61
|
+
name: string;
|
|
62
|
+
kind: K;
|
|
63
|
+
available?: boolean;
|
|
64
|
+
reason?: Reason | "offline";
|
|
65
|
+
asOf?: string;
|
|
66
|
+
state?: S;
|
|
67
|
+
text: string;
|
|
68
|
+
room?: string;
|
|
69
|
+
members?: string[];
|
|
70
|
+
detail: D;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A connector's device records from its part. While the connector isn't connected none is `available`, and each keeps its
|
|
75
|
+
* last values, as the part's state does. A device that isn't available gets its `reason`: the part's when it stopped for
|
|
76
|
+
* good, else `offline`.
|
|
77
|
+
*/
|
|
78
|
+
export const deviceRecords = <R extends DeviceRecord>(
|
|
79
|
+
part: Part | undefined,
|
|
80
|
+
records: R[],
|
|
81
|
+
): R[] => {
|
|
82
|
+
const up = part?.status === "connected";
|
|
83
|
+
// 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.
|
|
84
|
+
const why =
|
|
85
|
+
part?.status === "unavailable" ? (part.reason ?? "config") : "offline";
|
|
86
|
+
return records.map((r) =>
|
|
87
|
+
up && r.available !== false
|
|
88
|
+
? r
|
|
89
|
+
: { ...r, available: false, reason: up ? "offline" : why },
|
|
90
|
+
);
|
|
91
|
+
};
|
|
92
|
+
|
|
34
93
|
/**
|
|
35
94
|
* An integration is a folder in `src/` that exports a factory returning this.
|
|
36
95
|
* `run` keeps its state current until `signal` aborts, reporting each change through `update`.
|
|
@@ -94,12 +153,16 @@ export function createHome<I extends Integration<any, string>>(
|
|
|
94
153
|
integrations.map((i) => [i.name, { status: "connecting" }]),
|
|
95
154
|
) as HomeSnapshot<I>;
|
|
96
155
|
const waiting = new Map<string, (() => void)[]>(); // refresh() callers, resolved by the integration's next report
|
|
97
|
-
const finished = new Set<string>(); //
|
|
156
|
+
const finished = new Set<string>(integrations.map((i) => i.name)); // not running, before start or after its run ended: a refresh of one resolves at once, nothing will report
|
|
98
157
|
const set = (name: Name, reported: Part) => {
|
|
99
158
|
const before = (snapshot as Record<string, Part>)[name]!;
|
|
100
159
|
const part = stamped(reported, before, asOf(clock));
|
|
101
160
|
snapshot = { ...snapshot, [name]: part };
|
|
102
|
-
|
|
161
|
+
Promise.try(() =>
|
|
162
|
+
onChange?.(snapshot, { name, before, after: part } as Change<I>),
|
|
163
|
+
).catch((error) =>
|
|
164
|
+
console.error(`onChange threw on ${name}: ${errorText(error)}`),
|
|
165
|
+
); // the caller's bug, thrown or async, not the device's: the integration keeps running
|
|
103
166
|
const w = waiting.get(name);
|
|
104
167
|
if (w) {
|
|
105
168
|
waiting.delete(name);
|
|
@@ -119,7 +182,7 @@ export function createHome<I extends Integration<any, string>>(
|
|
|
119
182
|
Extract<I, { name: N }> | undefined,
|
|
120
183
|
/**
|
|
121
184
|
* Read now: one integration by name, or every one. Results arrive through the snapshot and `onChange` as usual; the
|
|
122
|
-
* promise resolves once each target has reported (its next update, good or bad), or at once for one
|
|
185
|
+
* promise resolves once each target has reported (its next update, good or bad), or at once for one that isn't running.
|
|
123
186
|
*/
|
|
124
187
|
refresh(name?: Name): Promise<void> {
|
|
125
188
|
const targets =
|
|
@@ -154,10 +217,14 @@ export function createHome<I extends Integration<any, string>>(
|
|
|
154
217
|
) as HomeSnapshot<I>;
|
|
155
218
|
await Promise.all(
|
|
156
219
|
integrations.map((i) =>
|
|
157
|
-
i
|
|
158
|
-
.run(signal, (part) => set(i.name, part))
|
|
220
|
+
Promise.try(() => i.run(signal, (part) => set(i.name, part)))
|
|
159
221
|
.catch((error) =>
|
|
160
|
-
set(i.name, {
|
|
222
|
+
set(i.name, {
|
|
223
|
+
status: "unavailable",
|
|
224
|
+
reason: "crashed",
|
|
225
|
+
detail: errorText(error),
|
|
226
|
+
state: (snapshot as Record<string, Part>)[i.name]!.state, // the last good state stays, as it does through a failed read
|
|
227
|
+
}),
|
|
161
228
|
)
|
|
162
229
|
.finally(() => {
|
|
163
230
|
finished.add(i.name);
|
package/src/index.ts
CHANGED
|
@@ -3,12 +3,15 @@
|
|
|
3
3
|
export {
|
|
4
4
|
type Change,
|
|
5
5
|
createHome,
|
|
6
|
+
type DeviceRecord,
|
|
7
|
+
deviceRecords,
|
|
6
8
|
envName,
|
|
7
9
|
filePath,
|
|
8
10
|
settle,
|
|
9
11
|
type HomeSnapshot,
|
|
10
12
|
type Integration,
|
|
11
13
|
type Part,
|
|
14
|
+
type Reason,
|
|
12
15
|
type StateOf,
|
|
13
16
|
type Status,
|
|
14
17
|
} from "./home.ts";
|
|
@@ -19,8 +22,9 @@ export {
|
|
|
19
22
|
PUSH_DOWN_MS,
|
|
20
23
|
STALE_POLLS,
|
|
21
24
|
} from "./health.ts";
|
|
25
|
+
export { type HomeDevice, homeDevices } from "./devices.ts";
|
|
22
26
|
export { asOf, type Clock, realClock } from "./clock.ts";
|
|
23
|
-
export { ConnectorError } from "./connector.ts";
|
|
27
|
+
export { type ConnectorCode, ConnectorError } from "./connector.ts";
|
|
24
28
|
export {
|
|
25
29
|
bestWindow,
|
|
26
30
|
type EnergyNow,
|
package/src/lane.ts
CHANGED
|
@@ -54,28 +54,31 @@ export function writeLane(options: LaneOptions) {
|
|
|
54
54
|
|
|
55
55
|
async function run() {
|
|
56
56
|
running = true;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
57
|
+
try {
|
|
58
|
+
while (queue.length) {
|
|
59
|
+
const now = clock.now();
|
|
60
|
+
const next = queue.find((p) => readyAt(p.slot) <= now);
|
|
61
|
+
if (!next) {
|
|
62
|
+
wake = new AbortController();
|
|
63
|
+
await clock.sleep(
|
|
64
|
+
Math.min(...queue.map((p) => readyAt(p.slot))) - now,
|
|
65
|
+
wake.signal,
|
|
66
|
+
);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
queue.splice(queue.indexOf(next), 1);
|
|
70
|
+
lastAt = now;
|
|
71
|
+
sent.set(next.slot, [...recent(next.slot), now]);
|
|
72
|
+
try {
|
|
73
|
+
const result = await next.write(next.body);
|
|
74
|
+
for (const w of next.waiters) w.resolve(result);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
for (const w of next.waiters) w.reject(error);
|
|
77
|
+
}
|
|
76
78
|
}
|
|
79
|
+
} finally {
|
|
80
|
+
running = false; // a throw here must not leave the lane stuck
|
|
77
81
|
}
|
|
78
|
-
running = false;
|
|
79
82
|
}
|
|
80
83
|
|
|
81
84
|
return {
|