@khalido/home 0.0.0-stage → 0.1.0
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/.env.example +25 -0
- package/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +77 -2
- package/dist/clock.d.ts +17 -0
- package/dist/clock.js +43 -0
- package/dist/config.d.ts +273 -0
- package/dist/config.js +392 -0
- package/dist/connector.d.ts +34 -0
- package/dist/connector.js +47 -0
- package/dist/connectors/amber/client.d.ts +61 -0
- package/dist/connectors/amber/client.js +106 -0
- package/dist/connectors/amber/index.d.ts +30 -0
- package/dist/connectors/amber/index.js +94 -0
- package/dist/connectors/cast/codec.d.ts +38 -0
- package/dist/connectors/cast/codec.js +102 -0
- package/dist/connectors/cast/index.d.ts +60 -0
- package/dist/connectors/cast/index.js +426 -0
- package/dist/connectors/cast/schemas.d.ts +167 -0
- package/dist/connectors/cast/schemas.js +87 -0
- package/dist/connectors/cast/serve.d.ts +11 -0
- package/dist/connectors/cast/serve.js +57 -0
- package/dist/connectors/cast/state.d.ts +62 -0
- package/dist/connectors/cast/state.js +133 -0
- package/dist/connectors/cast/tls.d.ts +3 -0
- package/dist/connectors/cast/tls.js +67 -0
- package/dist/connectors/cast/writes.d.ts +80 -0
- package/dist/connectors/cast/writes.js +390 -0
- package/dist/connectors/daikin/client.d.ts +65 -0
- package/dist/connectors/daikin/client.js +221 -0
- package/dist/connectors/daikin/index.d.ts +41 -0
- package/dist/connectors/daikin/index.js +108 -0
- package/dist/connectors/enphase/client.d.ts +65 -0
- package/dist/connectors/enphase/client.js +173 -0
- package/dist/connectors/enphase/index.d.ts +43 -0
- package/dist/connectors/enphase/index.js +174 -0
- package/dist/connectors/enphase/schemas.d.ts +51 -0
- package/dist/connectors/enphase/schemas.js +34 -0
- package/dist/connectors/hue/client.d.ts +48 -0
- package/dist/connectors/hue/client.js +95 -0
- package/dist/connectors/hue/events.d.ts +41 -0
- package/dist/connectors/hue/events.js +156 -0
- package/dist/connectors/hue/index.d.ts +42 -0
- package/dist/connectors/hue/index.js +205 -0
- package/dist/connectors/hue/schemas.d.ts +65 -0
- package/dist/connectors/hue/schemas.js +48 -0
- package/dist/connectors/hue/state.d.ts +43 -0
- package/dist/connectors/hue/state.js +84 -0
- package/dist/connectors/printers/client.d.ts +28 -0
- package/dist/connectors/printers/client.js +74 -0
- package/dist/connectors/printers/codec.d.ts +76 -0
- package/dist/connectors/printers/codec.js +246 -0
- package/dist/connectors/printers/found.d.ts +15 -0
- package/dist/connectors/printers/found.js +35 -0
- package/dist/connectors/printers/index.d.ts +52 -0
- package/dist/connectors/printers/index.js +282 -0
- package/dist/connectors/printers/state.d.ts +60 -0
- package/dist/connectors/printers/state.js +99 -0
- package/dist/connectors/spotify/changes.d.ts +32 -0
- package/dist/connectors/spotify/changes.js +84 -0
- package/dist/connectors/spotify/client.d.ts +199 -0
- package/dist/connectors/spotify/client.js +270 -0
- package/dist/connectors/spotify/index.d.ts +92 -0
- package/dist/connectors/spotify/index.js +745 -0
- package/dist/connectors/up/client.d.ts +183 -0
- package/dist/connectors/up/client.js +308 -0
- package/dist/connectors/up/index.d.ts +91 -0
- package/dist/connectors/up/index.js +213 -0
- package/dist/connectors/up/spend.d.ts +78 -0
- package/dist/connectors/up/spend.js +213 -0
- package/dist/connectors/up/webhook.d.ts +13 -0
- package/dist/connectors/up/webhook.js +32 -0
- package/dist/connectors/weather/blocks.d.ts +51 -0
- package/dist/connectors/weather/blocks.js +106 -0
- package/dist/connectors/weather/bom.d.ts +37 -0
- package/dist/connectors/weather/bom.js +237 -0
- package/dist/connectors/weather/ftp.d.ts +5 -0
- package/dist/connectors/weather/ftp.js +46 -0
- package/dist/connectors/weather/google.d.ts +31 -0
- package/dist/connectors/weather/google.js +364 -0
- package/dist/connectors/weather/index.d.ts +53 -0
- package/dist/connectors/weather/index.js +267 -0
- package/dist/connectors/weather/open-meteo.d.ts +15 -0
- package/dist/connectors/weather/open-meteo.js +285 -0
- package/dist/connectors/weather/rain.d.ts +19 -0
- package/dist/connectors/weather/rain.js +51 -0
- package/dist/connectors/weather/source.d.ts +149 -0
- package/dist/connectors/weather/source.js +77 -0
- package/dist/connectors/weather/sun.d.ts +48 -0
- package/dist/connectors/weather/sun.js +65 -0
- package/dist/discover.d.ts +195 -0
- package/dist/discover.js +446 -0
- package/dist/energy.d.ts +61 -0
- package/dist/energy.js +162 -0
- package/dist/finders-entry.d.ts +1 -0
- package/dist/finders-entry.js +2 -0
- package/dist/finders.d.ts +45 -0
- package/dist/finders.js +173 -0
- package/dist/health.d.ts +28 -0
- package/dist/health.js +35 -0
- package/dist/home.d.ts +81 -0
- package/dist/home.js +114 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +11 -0
- package/dist/lane.d.ts +17 -0
- package/dist/lane.js +74 -0
- package/dist/locate.d.ts +27 -0
- package/dist/locate.js +59 -0
- package/dist/manifest.d.ts +31 -0
- package/dist/manifest.js +4 -0
- package/dist/manifests.d.ts +5 -0
- package/dist/manifests.js +23 -0
- package/dist/node-fetch.d.ts +9 -0
- package/dist/node-fetch.js +25 -0
- package/dist/poll.d.ts +10 -0
- package/dist/poll.js +33 -0
- package/docs/cast.md +133 -0
- package/docs/cli.md +39 -0
- package/docs/config.md +36 -0
- package/docs/decisions.md +150 -0
- package/docs/devices.md +128 -0
- package/docs/enphase-notes.md +24 -0
- package/docs/google.md +92 -0
- package/docs/ideas.md +69 -0
- package/docs/matter.md +55 -0
- package/docs/performance.md +12 -0
- package/docs/philosophy.md +23 -0
- package/docs/up.md +84 -0
- package/docs/using.md +65 -0
- package/docs/weather-probe.md +63 -0
- package/examples/home.jsonc +74 -0
- package/examples/home.svg +76 -0
- package/home.schema.json +474 -0
- package/llms.txt +32 -0
- package/package.json +143 -4
- package/skills/draw-home/README.md +26 -0
- package/skills/draw-home/SKILL.md +82 -0
- package/skills/use-home/SKILL.md +86 -0
- package/src/cli/cache.ts +29 -0
- package/src/cli/cast.ts +172 -0
- package/src/cli/config.ts +52 -0
- package/src/cli/context.ts +118 -0
- package/src/cli/discover.ts +175 -0
- package/src/cli/enphase.ts +62 -0
- package/src/cli/hue.ts +124 -0
- package/src/cli/locate.ts +47 -0
- package/src/cli/main.ts +182 -0
- package/src/cli/printers.ts +21 -0
- package/src/cli/probe.ts +274 -0
- package/src/cli/spotify.ts +76 -0
- package/src/cli/status.ts +228 -0
- package/src/cli/up.ts +350 -0
- package/src/clock.ts +62 -0
- package/src/config.ts +578 -0
- package/src/connector.ts +77 -0
- package/src/connectors/amber/README.md +40 -0
- package/src/connectors/amber/client.ts +157 -0
- package/src/connectors/amber/index.ts +118 -0
- package/src/connectors/cast/README.md +43 -0
- package/src/connectors/cast/codec.ts +126 -0
- package/src/connectors/cast/index.ts +551 -0
- package/src/connectors/cast/schemas.ts +101 -0
- package/src/connectors/cast/serve.ts +72 -0
- package/src/connectors/cast/state.ts +203 -0
- package/src/connectors/cast/tls.ts +100 -0
- package/src/connectors/cast/writes.ts +572 -0
- package/src/connectors/daikin/README.md +78 -0
- package/src/connectors/daikin/client.ts +295 -0
- package/src/connectors/daikin/index.ts +145 -0
- package/src/connectors/enphase/README.md +64 -0
- package/src/connectors/enphase/client.ts +244 -0
- package/src/connectors/enphase/index.ts +241 -0
- package/src/connectors/enphase/schemas.ts +45 -0
- package/src/connectors/hue/README.md +50 -0
- package/src/connectors/hue/client.ts +157 -0
- package/src/connectors/hue/events.ts +182 -0
- package/src/connectors/hue/index.ts +261 -0
- package/src/connectors/hue/schemas.ts +60 -0
- package/src/connectors/hue/state.ts +124 -0
- package/src/connectors/printers/README.md +46 -0
- package/src/connectors/printers/client.ts +114 -0
- package/src/connectors/printers/codec.ts +292 -0
- package/src/connectors/printers/found.ts +46 -0
- package/src/connectors/printers/index.ts +385 -0
- package/src/connectors/printers/state.ts +182 -0
- package/src/connectors/spotify/README.md +80 -0
- package/src/connectors/spotify/changes.ts +127 -0
- package/src/connectors/spotify/client.ts +387 -0
- package/src/connectors/spotify/index.ts +929 -0
- package/src/connectors/up/README.md +35 -0
- package/src/connectors/up/client.ts +518 -0
- package/src/connectors/up/index.ts +370 -0
- package/src/connectors/up/spend.ts +280 -0
- package/src/connectors/up/webhook.ts +62 -0
- package/src/connectors/weather/README.md +84 -0
- package/src/connectors/weather/blocks.ts +173 -0
- package/src/connectors/weather/bom.ts +344 -0
- package/src/connectors/weather/ftp.ts +48 -0
- package/src/connectors/weather/google.ts +425 -0
- package/src/connectors/weather/index.ts +380 -0
- package/src/connectors/weather/open-meteo.ts +309 -0
- package/src/connectors/weather/rain.ts +73 -0
- package/src/connectors/weather/source.ts +236 -0
- package/src/connectors/weather/sun.ts +114 -0
- package/src/discover.ts +634 -0
- package/src/energy.ts +221 -0
- package/src/finders-entry.ts +2 -0
- package/src/finders.ts +247 -0
- package/src/health.ts +74 -0
- package/src/home.ts +205 -0
- package/src/index.ts +62 -0
- package/src/lane.ts +110 -0
- package/src/locate.ts +85 -0
- package/src/manifest.ts +43 -0
- package/src/manifests.ts +27 -0
- package/src/node-fetch.ts +41 -0
- package/src/poll.ts +33 -0
package/src/home.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { asOf, type Clock, realClock } from "./clock.ts";
|
|
3
|
+
import { errorText } from "./connector.ts";
|
|
4
|
+
|
|
5
|
+
/** A secret in a home config is the name of an environment variable, never the secret: a pasted key fails here. */
|
|
6
|
+
export const envName = z
|
|
7
|
+
.string()
|
|
8
|
+
.regex(
|
|
9
|
+
/^[A-Z_][A-Z0-9_]*$/,
|
|
10
|
+
"an environment variable name (e.g. AMBER_KEY), not the secret itself",
|
|
11
|
+
);
|
|
12
|
+
/** A file a connector needs (a CA, a token), relative to the config file. The caller reads it; the library reads no files. */
|
|
13
|
+
export const filePath = z.string().min(1);
|
|
14
|
+
|
|
15
|
+
/** How an integration is doing. "unavailable" means it stopped and will not retry by itself. */
|
|
16
|
+
export type Status =
|
|
17
|
+
"connecting" | "connected" | "disconnected" | "unavailable";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* One integration's slice of the home: its status, a human detail, anything odd the device reported, and its state.
|
|
21
|
+
* `createHome` stamps the times (UTC ISO). `lastGoodAt`: its last report while connected, or the read time a connected report
|
|
22
|
+
* gives when it repeats an older read (weather's 5-minute sun tick). `downSince`: when it stopped being connected (or started,
|
|
23
|
+
* if it never has); none while connected. Any other time an integration sets is ignored.
|
|
24
|
+
*/
|
|
25
|
+
export type Part<S = unknown> = {
|
|
26
|
+
status: Status;
|
|
27
|
+
detail?: string;
|
|
28
|
+
anomaly?: string;
|
|
29
|
+
state?: S;
|
|
30
|
+
lastGoodAt?: string;
|
|
31
|
+
downSince?: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* An integration is a folder in `src/` that exports a factory returning this.
|
|
36
|
+
* `run` keeps its state current until `signal` aborts, reporting each change through `update`.
|
|
37
|
+
* `refresh` asks for a fresh read now, ahead of the integration's schedule; it returns at once and the read reports through `update`.
|
|
38
|
+
*/
|
|
39
|
+
export type Integration<S = unknown, N extends string = string> = {
|
|
40
|
+
name: N;
|
|
41
|
+
run(signal: AbortSignal, update: (part: Part<S>) => void): Promise<void>;
|
|
42
|
+
refresh(): void;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** The state an integration reports. */
|
|
46
|
+
export type StateOf<I> = I extends Integration<infer S, string> ? S : never;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The whole home: one `Part` per integration, by name, each typed by its integration (`snapshot.weather?.state` is a
|
|
50
|
+
* `WeatherState`). A name can be missing: a config need not have every connector. This is the cache every read comes from.
|
|
51
|
+
*/
|
|
52
|
+
export type HomeSnapshot<
|
|
53
|
+
I extends Integration<any, string> = Integration<unknown, string>,
|
|
54
|
+
> = { [X in I as X["name"]]?: Part<StateOf<X>> };
|
|
55
|
+
|
|
56
|
+
/** One report: which integration, its part before and after. Typed by name, so `change.after.state` is that integration's state. */
|
|
57
|
+
export type Change<
|
|
58
|
+
I extends Integration<any, string> = Integration<unknown, string>,
|
|
59
|
+
> = {
|
|
60
|
+
[X in I as X["name"]]: {
|
|
61
|
+
name: X["name"];
|
|
62
|
+
before: Part<StateOf<X>>;
|
|
63
|
+
after: Part<StateOf<X>>;
|
|
64
|
+
};
|
|
65
|
+
}[I["name"]];
|
|
66
|
+
|
|
67
|
+
/** The times `createHome` keeps on a part, from the one before it: when it was last good, and when it stopped being. */
|
|
68
|
+
function stamped(part: Part, before: Part | undefined, now: string): Part {
|
|
69
|
+
if (part.status === "connected")
|
|
70
|
+
return {
|
|
71
|
+
...part,
|
|
72
|
+
lastGoodAt: part.lastGoodAt ?? now,
|
|
73
|
+
downSince: undefined,
|
|
74
|
+
};
|
|
75
|
+
return {
|
|
76
|
+
...part,
|
|
77
|
+
lastGoodAt: before?.lastGoodAt,
|
|
78
|
+
downSince: before?.downSince ?? now,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Load a home: run each integration, keep one snapshot, call `onChange` with it and what changed after every report.
|
|
84
|
+
* `clock` stamps each part's `lastGoodAt` and `downSince`.
|
|
85
|
+
*/
|
|
86
|
+
export function createHome<I extends Integration<any, string>>(
|
|
87
|
+
integrations: readonly I[],
|
|
88
|
+
onChange?: (home: HomeSnapshot<I>, change: Change<I>) => void,
|
|
89
|
+
options: { clock?: Clock } = {},
|
|
90
|
+
) {
|
|
91
|
+
type Name = I["name"];
|
|
92
|
+
const clock = options.clock ?? realClock;
|
|
93
|
+
let snapshot = Object.fromEntries(
|
|
94
|
+
integrations.map((i) => [i.name, { status: "connecting" }]),
|
|
95
|
+
) as HomeSnapshot<I>;
|
|
96
|
+
const waiting = new Map<string, (() => void)[]>(); // refresh() callers, resolved by the integration's next report
|
|
97
|
+
const finished = new Set<string>(); // integrations whose run has ended: a refresh of one resolves at once, nothing will report
|
|
98
|
+
const set = (name: Name, reported: Part) => {
|
|
99
|
+
const before = (snapshot as Record<string, Part>)[name]!;
|
|
100
|
+
const part = stamped(reported, before, asOf(clock));
|
|
101
|
+
snapshot = { ...snapshot, [name]: part };
|
|
102
|
+
onChange?.(snapshot, { name, before, after: part } as Change<I>);
|
|
103
|
+
const w = waiting.get(name);
|
|
104
|
+
if (w) {
|
|
105
|
+
waiting.delete(name);
|
|
106
|
+
for (const resolve of w) resolve();
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
return {
|
|
110
|
+
/** The latest snapshot. Never touches a device. */
|
|
111
|
+
snapshot: () => snapshot,
|
|
112
|
+
/** One integration's part, typed by its name: `home.part("weather")?.state?.blocks`. */
|
|
113
|
+
part: <N extends Name>(name: N): HomeSnapshot<I>[N] => snapshot[name],
|
|
114
|
+
/** One integration by name, for its writes: `home.integration("daikin")?.client.write(setPower(true))`. */
|
|
115
|
+
integration: <N extends Name>(
|
|
116
|
+
name: N,
|
|
117
|
+
): Extract<I, { name: N }> | undefined =>
|
|
118
|
+
integrations.find((i) => i.name === name) as
|
|
119
|
+
Extract<I, { name: N }> | undefined,
|
|
120
|
+
/**
|
|
121
|
+
* 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 whose run has ended.
|
|
123
|
+
*/
|
|
124
|
+
refresh(name?: Name): Promise<void> {
|
|
125
|
+
const targets =
|
|
126
|
+
name === undefined
|
|
127
|
+
? integrations
|
|
128
|
+
: integrations.filter((i) => i.name === name);
|
|
129
|
+
if (!targets.length)
|
|
130
|
+
throw new Error(
|
|
131
|
+
`no integration "${name}"; have ${integrations.map((i) => i.name).join(", ")}`,
|
|
132
|
+
);
|
|
133
|
+
const reported = targets.map(
|
|
134
|
+
(i) =>
|
|
135
|
+
new Promise<void>((resolve) => {
|
|
136
|
+
if (finished.has(i.name)) return resolve();
|
|
137
|
+
waiting.set(i.name, [...(waiting.get(i.name) ?? []), resolve]);
|
|
138
|
+
}),
|
|
139
|
+
);
|
|
140
|
+
for (const i of targets) i.refresh();
|
|
141
|
+
return Promise.all(reported).then(() => undefined);
|
|
142
|
+
},
|
|
143
|
+
/** Run every integration until `signal` aborts. One failing marks itself unavailable; the rest carry on. */
|
|
144
|
+
async start(signal: AbortSignal): Promise<void> {
|
|
145
|
+
finished.clear(); // a second start, after the first has stopped, runs them all again
|
|
146
|
+
const now = asOf(clock); // the wait for a first report counts from here, not from createHome
|
|
147
|
+
snapshot = Object.fromEntries(
|
|
148
|
+
Object.entries(snapshot as Record<string, Part>).map(([name, p]) => [
|
|
149
|
+
name,
|
|
150
|
+
p.status === "connecting"
|
|
151
|
+
? { ...p, downSince: p.downSince ?? now }
|
|
152
|
+
: p,
|
|
153
|
+
]),
|
|
154
|
+
) as HomeSnapshot<I>;
|
|
155
|
+
await Promise.all(
|
|
156
|
+
integrations.map((i) =>
|
|
157
|
+
i
|
|
158
|
+
.run(signal, (part) => set(i.name, part))
|
|
159
|
+
.catch((error) =>
|
|
160
|
+
set(i.name, { status: "unavailable", detail: errorText(error) }),
|
|
161
|
+
)
|
|
162
|
+
.finally(() => {
|
|
163
|
+
finished.add(i.name);
|
|
164
|
+
for (const resolve of waiting.get(i.name) ?? []) resolve(); // nothing more will report
|
|
165
|
+
waiting.delete(i.name);
|
|
166
|
+
}),
|
|
167
|
+
),
|
|
168
|
+
);
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Answered: past connecting, and when the state lists sources (the weather), every source has read. */
|
|
174
|
+
const answered = (p: Part | undefined) =>
|
|
175
|
+
!!p &&
|
|
176
|
+
p.status !== "connecting" &&
|
|
177
|
+
Object.values(
|
|
178
|
+
(p.state as { sources?: Record<string, { read: boolean }> })?.sources ?? {},
|
|
179
|
+
).every((s) => s.read);
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* One read of everything, for a script, a test or a one-shot command: run the integrations until each has answered
|
|
183
|
+
* (connected or not), at most `ms` (15 s by default) or until `signal` aborts, then stop them and return the snapshot.
|
|
184
|
+
*/
|
|
185
|
+
export async function settle<I extends Integration<any, string>>(
|
|
186
|
+
integrations: readonly I[],
|
|
187
|
+
options: { ms?: number; clock?: Clock; signal?: AbortSignal } = {},
|
|
188
|
+
): Promise<HomeSnapshot<I>> {
|
|
189
|
+
const { ms = 15_000, clock = realClock, signal } = options;
|
|
190
|
+
const stop = new AbortController();
|
|
191
|
+
const run = signal ? AbortSignal.any([signal, stop.signal]) : stop.signal; // the caller's signal stops it too, with nothing left on it after
|
|
192
|
+
const home = createHome(
|
|
193
|
+
integrations,
|
|
194
|
+
(snapshot) => {
|
|
195
|
+
if (Object.values(snapshot as Record<string, Part>).every(answered))
|
|
196
|
+
stop.abort();
|
|
197
|
+
},
|
|
198
|
+
{ clock },
|
|
199
|
+
);
|
|
200
|
+
const running = home.start(run);
|
|
201
|
+
await Promise.race([clock.sleep(ms, run), running]);
|
|
202
|
+
stop.abort();
|
|
203
|
+
await running;
|
|
204
|
+
return home.snapshot();
|
|
205
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
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
|
+
// 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 {
|
|
4
|
+
type Change,
|
|
5
|
+
createHome,
|
|
6
|
+
envName,
|
|
7
|
+
filePath,
|
|
8
|
+
settle,
|
|
9
|
+
type HomeSnapshot,
|
|
10
|
+
type Integration,
|
|
11
|
+
type Part,
|
|
12
|
+
type StateOf,
|
|
13
|
+
type Status,
|
|
14
|
+
} from "./home.ts";
|
|
15
|
+
export {
|
|
16
|
+
DOWN_POLLS,
|
|
17
|
+
type Health,
|
|
18
|
+
health,
|
|
19
|
+
PUSH_DOWN_MS,
|
|
20
|
+
STALE_POLLS,
|
|
21
|
+
} from "./health.ts";
|
|
22
|
+
export { asOf, type Clock, realClock } from "./clock.ts";
|
|
23
|
+
export { ConnectorError } from "./connector.ts";
|
|
24
|
+
export {
|
|
25
|
+
bestWindow,
|
|
26
|
+
type EnergyNow,
|
|
27
|
+
energyNow,
|
|
28
|
+
gridText,
|
|
29
|
+
type PriceWindow,
|
|
30
|
+
solarLevel,
|
|
31
|
+
} from "./energy.ts";
|
|
32
|
+
export {
|
|
33
|
+
checkHome,
|
|
34
|
+
checkPlan,
|
|
35
|
+
Device,
|
|
36
|
+
HomeConfig,
|
|
37
|
+
HomeConfigError,
|
|
38
|
+
type HomeIntegration,
|
|
39
|
+
type LoadOptions,
|
|
40
|
+
loadHome,
|
|
41
|
+
parseJsonc,
|
|
42
|
+
Room,
|
|
43
|
+
type Setup,
|
|
44
|
+
} from "./config.ts";
|
|
45
|
+
export type {
|
|
46
|
+
AddressCache,
|
|
47
|
+
CachedAddress,
|
|
48
|
+
Connector,
|
|
49
|
+
Finder,
|
|
50
|
+
Found,
|
|
51
|
+
} from "./discover.ts";
|
|
52
|
+
export { type SunNow, sunNow } from "./connectors/weather/sun.ts";
|
|
53
|
+
export { type RainSoon, rainSoon } from "./connectors/weather/rain.ts";
|
|
54
|
+
export {
|
|
55
|
+
type BlockDef,
|
|
56
|
+
BLOCKS,
|
|
57
|
+
blockText,
|
|
58
|
+
type DayBlock,
|
|
59
|
+
type DayBlocks,
|
|
60
|
+
dayBlocks,
|
|
61
|
+
} from "./connectors/weather/blocks.ts";
|
|
62
|
+
export type { EnvVar, IotClass, Manifest } from "./manifest.ts";
|
package/src/lane.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { type Clock, realClock } from "./clock.ts";
|
|
2
|
+
|
|
3
|
+
// Writes to a device go through a lane: one at a time, spaced by the device's gap, and coalesced per slot. A slot is
|
|
4
|
+
// what one write changes (a light, the AC, a speaker's volume). While a slot's write waits, a newer write to the same
|
|
5
|
+
// slot merges into it: the middle drops, the last value lands. A slot sending faster than `perMinute` is held to that
|
|
6
|
+
// rate rather than refused, so a runaway loop slows down and a person's last change still arrives.
|
|
7
|
+
|
|
8
|
+
export type LaneOptions = {
|
|
9
|
+
/** Minimum time between two sends on the lane, across slots. */
|
|
10
|
+
gapMs: number;
|
|
11
|
+
/** At most this many sends per slot in any minute; more wait their turn, still coalescing. None: no cap. */
|
|
12
|
+
perMinute?: number;
|
|
13
|
+
clock?: Clock;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type Waiter = { resolve(value: unknown): void; reject(error: unknown): void };
|
|
17
|
+
type Pending = {
|
|
18
|
+
slot: string;
|
|
19
|
+
body: unknown;
|
|
20
|
+
write: (body: unknown) => Promise<unknown>;
|
|
21
|
+
waiters: Waiter[];
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const MINUTE = 60_000;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A write lane for one device or bridge. `send(slot, body, write, merge)` resolves with the result of the send that
|
|
28
|
+
* carried its body: a write merged into a later one resolves (or rejects) with that later one. `merge(older, newer)`
|
|
29
|
+
* defaults to the newer body alone.
|
|
30
|
+
*/
|
|
31
|
+
export function writeLane(options: LaneOptions) {
|
|
32
|
+
if (options.perMinute !== undefined && !(options.perMinute >= 1))
|
|
33
|
+
throw new RangeError(
|
|
34
|
+
`writeLane: perMinute must be at least 1, got ${options.perMinute}`,
|
|
35
|
+
);
|
|
36
|
+
const clock = options.clock ?? realClock;
|
|
37
|
+
const queue: Pending[] = []; // in order of each slot's first waiting write
|
|
38
|
+
const sent = new Map<string, number[]>(); // send times per slot, the last minute's
|
|
39
|
+
let lastAt = -Infinity;
|
|
40
|
+
let running = false;
|
|
41
|
+
let wake = new AbortController();
|
|
42
|
+
|
|
43
|
+
const recent = (slot: string) =>
|
|
44
|
+
(sent.get(slot) ?? []).filter((t) => t > clock.now() - MINUTE);
|
|
45
|
+
|
|
46
|
+
function readyAt(slot: string): number {
|
|
47
|
+
const times = recent(slot);
|
|
48
|
+
const capped =
|
|
49
|
+
options.perMinute !== undefined && times.length >= options.perMinute
|
|
50
|
+
? times[times.length - options.perMinute]! + MINUTE
|
|
51
|
+
: -Infinity;
|
|
52
|
+
return Math.max(lastAt + options.gapMs, capped);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function run() {
|
|
56
|
+
running = true;
|
|
57
|
+
while (queue.length) {
|
|
58
|
+
const now = clock.now();
|
|
59
|
+
const next = queue.find((p) => readyAt(p.slot) <= now);
|
|
60
|
+
if (!next) {
|
|
61
|
+
wake = new AbortController();
|
|
62
|
+
await clock.sleep(
|
|
63
|
+
Math.min(...queue.map((p) => readyAt(p.slot))) - now,
|
|
64
|
+
wake.signal,
|
|
65
|
+
);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
queue.splice(queue.indexOf(next), 1);
|
|
69
|
+
lastAt = now;
|
|
70
|
+
sent.set(next.slot, [...recent(next.slot), now]);
|
|
71
|
+
try {
|
|
72
|
+
const result = await next.write(next.body);
|
|
73
|
+
for (const w of next.waiters) w.resolve(result);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
for (const w of next.waiters) w.reject(error);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
running = false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
send<B, T>(
|
|
83
|
+
slot: string,
|
|
84
|
+
body: B,
|
|
85
|
+
write: (body: B) => Promise<T>,
|
|
86
|
+
merge: (older: B, newer: B) => B = (_, newer) => newer,
|
|
87
|
+
): Promise<T> {
|
|
88
|
+
return new Promise<T>((resolve, reject) => {
|
|
89
|
+
const waiter = { resolve, reject } as Waiter;
|
|
90
|
+
const waiting = queue.find((p) => p.slot === slot);
|
|
91
|
+
if (waiting) {
|
|
92
|
+
waiting.body = merge(waiting.body as B, body);
|
|
93
|
+
waiting.write = write as Pending["write"];
|
|
94
|
+
waiting.waiters.push(waiter);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
queue.push({
|
|
98
|
+
slot,
|
|
99
|
+
body,
|
|
100
|
+
write: write as Pending["write"],
|
|
101
|
+
waiters: [waiter],
|
|
102
|
+
});
|
|
103
|
+
if (running)
|
|
104
|
+
wake.abort(); // a sleeping lane looks again: this slot may be ready sooner
|
|
105
|
+
else void run();
|
|
106
|
+
});
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
export type WriteLane = ReturnType<typeof writeLane>;
|
package/src/locate.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { applyEdits, modify, parse } from "jsonc-parser";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
// A street address to the home's latitude and longitude, once, through OpenStreetMap's Nominatim, and that position
|
|
5
|
+
// written into a home config's text. Pure: the caller passes `fetch` and its User-Agent, and writes the file.
|
|
6
|
+
|
|
7
|
+
/** One Nominatim match. It sends the coordinates as strings; they are read as numbers, never rounded. */
|
|
8
|
+
const Place = z.looseObject({
|
|
9
|
+
display_name: z.string(),
|
|
10
|
+
lat: z.coerce.number(),
|
|
11
|
+
lon: z.coerce.number(),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export type Located = { name: string; latitude: number; longitude: number };
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The best match for `address`, or undefined when Nominatim finds none. Its usage policy requires a User-Agent that
|
|
18
|
+
* names the application, and allows one request a second: this is for a one-off lookup, not a loop.
|
|
19
|
+
*/
|
|
20
|
+
export async function geocode(
|
|
21
|
+
address: string,
|
|
22
|
+
options: { fetch?: typeof fetch; userAgent: string; signal?: AbortSignal },
|
|
23
|
+
): Promise<Located | undefined> {
|
|
24
|
+
const url = `https://nominatim.openstreetmap.org/search?${new URLSearchParams({ q: address, format: "jsonv2", limit: "1" })}`;
|
|
25
|
+
const res = await (options.fetch ?? fetch)(url, {
|
|
26
|
+
headers: { "User-Agent": options.userAgent },
|
|
27
|
+
signal: options.signal ?? AbortSignal.timeout(10_000),
|
|
28
|
+
});
|
|
29
|
+
if (!res.ok) throw new Error(`Nominatim answered ${res.status}`);
|
|
30
|
+
const places = z.array(Place).safeParse(await res.json());
|
|
31
|
+
if (!places.success)
|
|
32
|
+
throw new Error(
|
|
33
|
+
"Nominatim's answer was not a list of places with coordinates",
|
|
34
|
+
);
|
|
35
|
+
const p = places.data[0];
|
|
36
|
+
return p && { name: p.display_name, latitude: p.lat, longitude: p.lon };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A config's text with `location.latitude` and `.longitude` set and, when it has none, `.timezone`, through
|
|
41
|
+
* jsonc-parser's edits: comments and every other key stay. With no `location` (absent or commented out) a fresh one is
|
|
42
|
+
* added, and `fresh` says so; a commented-out one is left as it was.
|
|
43
|
+
*/
|
|
44
|
+
export function applyLocation(
|
|
45
|
+
text: string,
|
|
46
|
+
at: { latitude: number; longitude: number },
|
|
47
|
+
timeZone: string,
|
|
48
|
+
): { text: string; fresh: boolean; timezoneSet: boolean } {
|
|
49
|
+
const location = (parse(text) as { location?: unknown } | undefined)
|
|
50
|
+
?.location;
|
|
51
|
+
const format = { formattingOptions: { insertSpaces: true, tabSize: 2 } };
|
|
52
|
+
if (typeof location !== "object" || location === null) {
|
|
53
|
+
return {
|
|
54
|
+
text: applyEdits(
|
|
55
|
+
text,
|
|
56
|
+
modify(
|
|
57
|
+
text,
|
|
58
|
+
["location"],
|
|
59
|
+
{
|
|
60
|
+
latitude: at.latitude,
|
|
61
|
+
longitude: at.longitude,
|
|
62
|
+
timezone: timeZone,
|
|
63
|
+
},
|
|
64
|
+
format,
|
|
65
|
+
),
|
|
66
|
+
),
|
|
67
|
+
fresh: true,
|
|
68
|
+
timezoneSet: true,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
let out = text;
|
|
72
|
+
for (const [key, value] of [
|
|
73
|
+
["latitude", at.latitude],
|
|
74
|
+
["longitude", at.longitude],
|
|
75
|
+
] as const)
|
|
76
|
+
out = applyEdits(out, modify(out, ["location", key], value, format));
|
|
77
|
+
const timezoneSet =
|
|
78
|
+
(location as { timezone?: unknown }).timezone === undefined;
|
|
79
|
+
if (timezoneSet)
|
|
80
|
+
out = applyEdits(
|
|
81
|
+
out,
|
|
82
|
+
modify(out, ["location", "timezone"], timeZone, format),
|
|
83
|
+
);
|
|
84
|
+
return { text: out, fresh: false, timezoneSet };
|
|
85
|
+
}
|
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Part } from "./home.ts";
|
|
2
|
+
|
|
3
|
+
// What each connector declares about itself, so the CLI, `.env.example` and the docs derive from one place. Modelled on
|
|
4
|
+
// Home Assistant's manifest (domain, name, iot_class). Each connector's index.ts exports one as `manifest`.
|
|
5
|
+
|
|
6
|
+
/** Home Assistant's `iot_class`: where the device is and how its state arrives. */
|
|
7
|
+
export type IotClass = "local_push" | "local_polling" | "cloud_polling";
|
|
8
|
+
|
|
9
|
+
/** One environment name the CLI reads for a connector; `.env.example` is written from these. `optional`: `home status` runs without it. */
|
|
10
|
+
export type EnvVar = {
|
|
11
|
+
name: string;
|
|
12
|
+
what: string;
|
|
13
|
+
optional?: boolean;
|
|
14
|
+
example?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type Manifest<S = unknown> = {
|
|
18
|
+
/** The folder, the config's connector literal, the integration's name and the CLI verb: "hue". */
|
|
19
|
+
name: string;
|
|
20
|
+
title: string;
|
|
21
|
+
iotClass: IotClass;
|
|
22
|
+
pollSeconds?: number;
|
|
23
|
+
/** What it reaches and with what key, for `home status`. */
|
|
24
|
+
access: string;
|
|
25
|
+
/** Every env name the CLI reads for this connector, in `.env.example`'s order. */
|
|
26
|
+
env: EnvVar[];
|
|
27
|
+
/** Config fields that are file paths, read relative to the config file. */
|
|
28
|
+
files?: string[];
|
|
29
|
+
/** The status text: the first line is the connector's own summary, any further lines its children (rooms, devices). Empty without state. */
|
|
30
|
+
lines(part: Part<S>): string[];
|
|
31
|
+
/** The state as `home status --json` shows it, when it is not the state itself. */
|
|
32
|
+
record?(part: Part<S>): Record<string, unknown>;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Watts as "1.2 kW", or "" without a number. Rounded for reading only. */
|
|
36
|
+
export const kw = (w: unknown) =>
|
|
37
|
+
typeof w === "number" ? `${(w / 1000).toFixed(1)} kW` : "";
|
|
38
|
+
|
|
39
|
+
/** A value's text when it exists, else "": a missing field is left out, never printed as "undefined". */
|
|
40
|
+
export const field = (
|
|
41
|
+
value: number | undefined,
|
|
42
|
+
text: (v: number) => string,
|
|
43
|
+
) => (value === undefined ? "" : text(value));
|
package/src/manifests.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { manifest as amber } from "./connectors/amber/index.ts";
|
|
2
|
+
import { manifest as cast } from "./connectors/cast/index.ts";
|
|
3
|
+
import { manifest as daikin } from "./connectors/daikin/index.ts";
|
|
4
|
+
import { manifest as enphase } from "./connectors/enphase/index.ts";
|
|
5
|
+
import { manifest as hue } from "./connectors/hue/index.ts";
|
|
6
|
+
import { manifest as printers } from "./connectors/printers/index.ts";
|
|
7
|
+
import { manifest as spotify } from "./connectors/spotify/index.ts";
|
|
8
|
+
import { manifest as up } from "./connectors/up/index.ts";
|
|
9
|
+
import { manifest as weather } from "./connectors/weather/index.ts";
|
|
10
|
+
import type { Manifest } from "./manifest.ts";
|
|
11
|
+
|
|
12
|
+
/** Every connector's manifest, in the order `home status` and `.env.example` list them. */
|
|
13
|
+
export const MANIFESTS: Manifest<any>[] = [
|
|
14
|
+
hue,
|
|
15
|
+
daikin,
|
|
16
|
+
enphase,
|
|
17
|
+
amber,
|
|
18
|
+
up,
|
|
19
|
+
weather,
|
|
20
|
+
cast,
|
|
21
|
+
printers,
|
|
22
|
+
spotify,
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/** One connector's manifest by name, or undefined. */
|
|
26
|
+
export const manifestOf = (name: string): Manifest<any> | undefined =>
|
|
27
|
+
MANIFESTS.find((m) => m.name === name);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Agent, fetch as undiciFetch } from "undici";
|
|
2
|
+
import type { LoadOptions } from "./config.ts";
|
|
3
|
+
|
|
4
|
+
// `@khalido/home/node`: the fetches Hue and Enphase need under Node, where Bun's `tls` fetch option does not exist. Node's
|
|
5
|
+
// global `fetch` takes no CA, so these use undici's own `fetch` and `Agent` (one undici on both sides). Outside the
|
|
6
|
+
// runtime-neutral build, like cast/tls.ts; a Node consumer passes `NODE_TLS` to `loadHome`, or one fetch to `hue()` or `enphase()`.
|
|
7
|
+
|
|
8
|
+
/** undici's `fetch` through `dispatcher`. Takes a URL, not a `Request`, and returns undici's own `Response`. */
|
|
9
|
+
const through = (dispatcher: Agent) =>
|
|
10
|
+
((input: RequestInfo | URL, init?: RequestInit) =>
|
|
11
|
+
undiciFetch(
|
|
12
|
+
input as never,
|
|
13
|
+
{ ...init, dispatcher } as never,
|
|
14
|
+
)) as unknown as typeof fetch;
|
|
15
|
+
|
|
16
|
+
/** A `fetch` that trusts only `ca` and accepts only a certificate whose CN is `bridgeId`: Node's `bunPinnedFetch`. */
|
|
17
|
+
export function nodePinnedFetch(bridgeId: string, ca: string): typeof fetch {
|
|
18
|
+
const checkServerIdentity = (
|
|
19
|
+
_host: string,
|
|
20
|
+
cert: { subject?: { CN?: string | string[] } },
|
|
21
|
+
) => {
|
|
22
|
+
const cn = [cert.subject?.CN].flat()[0];
|
|
23
|
+
return cn?.toLowerCase() === bridgeId.toLowerCase()
|
|
24
|
+
? undefined
|
|
25
|
+
: new Error(
|
|
26
|
+
`Hue bridge certificate is for ${cn ?? "nobody"}, expected ${bridgeId}`,
|
|
27
|
+
);
|
|
28
|
+
};
|
|
29
|
+
return through(new Agent({ connect: { ca, checkServerIdentity } }));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A `fetch` for the Enphase gateway that skips certificate verification, for its requests only: Node's `bunGatewayFetch`. */
|
|
33
|
+
export function nodeGatewayFetch(): typeof fetch {
|
|
34
|
+
return through(new Agent({ connect: { rejectUnauthorized: false } }));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** What `loadHome` takes as `tls` under Node. */
|
|
38
|
+
export const NODE_TLS = {
|
|
39
|
+
hue: nodePinnedFetch,
|
|
40
|
+
enphase: nodeGatewayFetch,
|
|
41
|
+
} satisfies LoadOptions["tls"];
|
package/src/poll.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Clock } from "./clock.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The wait between polls, shared by every polling integration, so each can be told to read now.
|
|
5
|
+
* `wait` resolves after `ms`, on abort, or on `nudge()`, and says whether a nudge woke it. A nudge while no one is waiting (mid-read) makes the next wait
|
|
6
|
+
* return at once, so a refresh asked for during a read still gets a read that starts after it.
|
|
7
|
+
*/
|
|
8
|
+
export function poller(clock: Clock) {
|
|
9
|
+
let wake: (() => void) | undefined;
|
|
10
|
+
let pending = false;
|
|
11
|
+
return {
|
|
12
|
+
async wait(ms: number, signal?: AbortSignal): Promise<boolean> {
|
|
13
|
+
if (signal?.aborted) return false;
|
|
14
|
+
if (pending) return !(pending = false);
|
|
15
|
+
const nudged = new AbortController();
|
|
16
|
+
wake = () => nudged.abort();
|
|
17
|
+
try {
|
|
18
|
+
await clock.sleep(
|
|
19
|
+
ms,
|
|
20
|
+
signal ? AbortSignal.any([signal, nudged.signal]) : nudged.signal,
|
|
21
|
+
);
|
|
22
|
+
return nudged.signal.aborted;
|
|
23
|
+
} finally {
|
|
24
|
+
wake = undefined;
|
|
25
|
+
pending = false;
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
nudge(): void {
|
|
29
|
+
if (wake) wake();
|
|
30
|
+
else pending = true;
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|