@khalido/home 0.0.0-stage → 0.1.1
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 +47 -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 +428 -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 +18 -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 +754 -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 +280 -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 +197 -0
- package/dist/discover.js +452 -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 +48 -0
- package/dist/finders.js +176 -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 +41 -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 +25 -0
- package/skills/draw-home/SKILL.md +80 -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 +182 -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 +44 -0
- package/src/connectors/cast/codec.ts +126 -0
- package/src/connectors/cast/index.ts +552 -0
- package/src/connectors/cast/schemas.ts +101 -0
- package/src/connectors/cast/serve.ts +82 -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 +83 -0
- package/src/connectors/spotify/changes.ts +127 -0
- package/src/connectors/spotify/client.ts +387 -0
- package/src/connectors/spotify/index.ts +937 -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 +396 -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 +638 -0
- package/src/energy.ts +221 -0
- package/src/finders-entry.ts +2 -0
- package/src/finders.ts +250 -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/dist/discover.js
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
import { applyEdits, modify } from "jsonc-parser";
|
|
2
|
+
import { errorText } from "./connector.js";
|
|
3
|
+
/** mDNS service types worth looking for at home, and what this package does with each. Extend the list as needed. */
|
|
4
|
+
export const KNOWN_SERVICES = {
|
|
5
|
+
"_hue._tcp": { what: "Philips Hue bridge", connector: "hue" },
|
|
6
|
+
"_dkapi._tcp": {
|
|
7
|
+
what: "Daikin AC (Home Assistant's zeroconf type)",
|
|
8
|
+
connector: "daikin",
|
|
9
|
+
},
|
|
10
|
+
"_enphase-envoy._tcp": { what: "Enphase IQ Gateway", connector: "enphase" },
|
|
11
|
+
"_googlecast._tcp": {
|
|
12
|
+
what: "Google Cast (Nest speakers and hubs, Chromecast, TVs, speaker groups)",
|
|
13
|
+
connector: "cast",
|
|
14
|
+
},
|
|
15
|
+
"_ipp._tcp": { what: "AirPrint printer (IPP)", connector: "printers" },
|
|
16
|
+
"_ipps._tcp": {
|
|
17
|
+
what: "AirPrint printer (IPP over TLS)",
|
|
18
|
+
connector: "printers",
|
|
19
|
+
},
|
|
20
|
+
"_airplay._tcp": { what: "AirPlay (Apple TV, HomePod, speakers)" },
|
|
21
|
+
"_raop._tcp": { what: "AirPlay audio receiver" },
|
|
22
|
+
"_matter._tcp": { what: "Matter device (commissioned)" },
|
|
23
|
+
"_matterc._udp": { what: "Matter device waiting to be commissioned" },
|
|
24
|
+
"_hap._tcp": { what: "HomeKit accessory" },
|
|
25
|
+
"_sonos._tcp": { what: "Sonos speaker" },
|
|
26
|
+
"_esphomelib._tcp": { what: "ESPHome device" },
|
|
27
|
+
"_shelly._tcp": { what: "Shelly relay or plug" },
|
|
28
|
+
"_wled._tcp": { what: "WLED light strip" },
|
|
29
|
+
"_nanoleafapi._tcp": { what: "Nanoleaf panels" },
|
|
30
|
+
"_eero._tcp": { what: "eero Wi-Fi router (cloud-only API)" },
|
|
31
|
+
"_home-assistant._tcp": { what: "Home Assistant server" },
|
|
32
|
+
};
|
|
33
|
+
/** Which config field holds each connector's identity, as its discovery reports it. */
|
|
34
|
+
export const IDENTITY = {
|
|
35
|
+
hue: "bridgeId",
|
|
36
|
+
daikin: "mac",
|
|
37
|
+
enphase: "serial",
|
|
38
|
+
cast: "castId",
|
|
39
|
+
printers: "uuid",
|
|
40
|
+
};
|
|
41
|
+
/** Connectors with many devices in one integration (every Cast device and group, every printer); the rest allow one device for now. */
|
|
42
|
+
export const MANY = new Set(["cast", "printers"]);
|
|
43
|
+
/** An identity without case, separators or a `urn:uuid:` prefix: "00:17:88:B0" is "001788b0", "urn:uuid:AB-CD" is "abcd". */
|
|
44
|
+
export const idKey = (id) => id
|
|
45
|
+
.toLowerCase()
|
|
46
|
+
.replace(/^urn:uuid:/, "")
|
|
47
|
+
.replace(/[^0-9a-z]/g, "");
|
|
48
|
+
/** Identities compared by `idKey`. */
|
|
49
|
+
export const sameId = (a, b) => idKey(a) === idKey(b);
|
|
50
|
+
/** A new config entry for a found device, with the CLI's default key name and file paths, which `home config check` then verifies. */
|
|
51
|
+
function newEntry(f) {
|
|
52
|
+
if (f.connector === "cast") {
|
|
53
|
+
const name = f.name ?? f.id;
|
|
54
|
+
return {
|
|
55
|
+
id: name
|
|
56
|
+
.toLowerCase()
|
|
57
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
58
|
+
.replace(/^-|-$/g, "") || "cast",
|
|
59
|
+
connector: "cast",
|
|
60
|
+
name,
|
|
61
|
+
castId: f.id,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
if (f.connector === "printers") {
|
|
65
|
+
const name = f.name ?? f.id;
|
|
66
|
+
return {
|
|
67
|
+
id: name
|
|
68
|
+
.toLowerCase()
|
|
69
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
70
|
+
.replace(/^-|-$/g, "") || "printer",
|
|
71
|
+
connector: "printers",
|
|
72
|
+
name,
|
|
73
|
+
uuid: f.id,
|
|
74
|
+
host: f.host,
|
|
75
|
+
...(f.port === undefined ? {} : { port: f.port }),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const base = {
|
|
79
|
+
id: f.connector,
|
|
80
|
+
connector: f.connector,
|
|
81
|
+
host: f.host,
|
|
82
|
+
[IDENTITY[f.connector]]: f.id,
|
|
83
|
+
};
|
|
84
|
+
if (f.connector === "hue")
|
|
85
|
+
return { ...base, key: "HUE_APPLICATION_KEY", ca: "hue-root-ca.pem" };
|
|
86
|
+
if (f.connector === "enphase")
|
|
87
|
+
return { ...base, token: "~/.config/home/enphase-token.json" };
|
|
88
|
+
return base;
|
|
89
|
+
}
|
|
90
|
+
/** `id`, or `id-2`, `id-3`… : the first not in `taken`. */
|
|
91
|
+
function unique(id, taken) {
|
|
92
|
+
let out = id;
|
|
93
|
+
for (let n = 2; taken.has(out); n++)
|
|
94
|
+
out = `${id}-${n}`;
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Match what discovery found against the config by identity. An entry with no identity yet is matched by host, and a
|
|
99
|
+
* device at an entry's host with a different identity is a conflict, not a match. New entries get ids no entry has.
|
|
100
|
+
*/
|
|
101
|
+
export function matchFound(config, found) {
|
|
102
|
+
const devices = config.devices ?? [];
|
|
103
|
+
const match = {
|
|
104
|
+
moved: [],
|
|
105
|
+
renamed: [],
|
|
106
|
+
added: [],
|
|
107
|
+
skipped: [],
|
|
108
|
+
known: [],
|
|
109
|
+
};
|
|
110
|
+
const taken = new Set(devices.map((d) => d.id));
|
|
111
|
+
const identity = (x, field) => x[field];
|
|
112
|
+
const hostOf = (x) => x?.host;
|
|
113
|
+
for (const f of found) {
|
|
114
|
+
const field = IDENTITY[f.connector];
|
|
115
|
+
const same = devices.filter((x) => x.connector === f.connector);
|
|
116
|
+
const index = devices.findIndex((d) => d.connector === f.connector &&
|
|
117
|
+
typeof identity(d, field) === "string" &&
|
|
118
|
+
sameId(identity(d, field), f.id));
|
|
119
|
+
const d = devices[index];
|
|
120
|
+
const atHost = same.find((x) => hostOf(x) === f.host);
|
|
121
|
+
const from = hostOf(d);
|
|
122
|
+
const named = d && f.connector === "cast" && f.name !== undefined
|
|
123
|
+
? d.name
|
|
124
|
+
: undefined;
|
|
125
|
+
if (d && named !== undefined && named !== f.name)
|
|
126
|
+
match.renamed.push({
|
|
127
|
+
index,
|
|
128
|
+
id: d.id,
|
|
129
|
+
connector: f.connector,
|
|
130
|
+
from: named,
|
|
131
|
+
to: f.name,
|
|
132
|
+
});
|
|
133
|
+
if (d && from !== undefined && from !== f.host)
|
|
134
|
+
match.moved.push({
|
|
135
|
+
index,
|
|
136
|
+
id: d.id,
|
|
137
|
+
connector: f.connector,
|
|
138
|
+
from,
|
|
139
|
+
to: f.host,
|
|
140
|
+
});
|
|
141
|
+
else if (d)
|
|
142
|
+
match.known.push({ id: d.id, connector: f.connector });
|
|
143
|
+
else if (atHost && identity(atHost, field) === undefined)
|
|
144
|
+
match.known.push({
|
|
145
|
+
id: atHost.id,
|
|
146
|
+
connector: f.connector,
|
|
147
|
+
hint: `add "${field}": "${f.id}" to recognise it after a move`,
|
|
148
|
+
});
|
|
149
|
+
else if (atHost)
|
|
150
|
+
match.skipped.push({
|
|
151
|
+
found: f,
|
|
152
|
+
why: `answers at ${atHost.id}'s host with a different ${field}`,
|
|
153
|
+
});
|
|
154
|
+
else if (!MANY.has(f.connector) &&
|
|
155
|
+
same.some((x) => identity(x, field) === undefined))
|
|
156
|
+
match.skipped.push({
|
|
157
|
+
found: f,
|
|
158
|
+
why: `could be ${same.find((x) => identity(x, field) === undefined).id} moved; add "${field}" to tell`,
|
|
159
|
+
});
|
|
160
|
+
else if (!MANY.has(f.connector) &&
|
|
161
|
+
(same.length ||
|
|
162
|
+
match.added.some((a) => a.found.connector === f.connector)))
|
|
163
|
+
match.skipped.push({
|
|
164
|
+
found: f,
|
|
165
|
+
why: `a second ${f.connector} device; one per connector for now`,
|
|
166
|
+
});
|
|
167
|
+
else {
|
|
168
|
+
const entry = newEntry(f);
|
|
169
|
+
entry.id = unique(String(entry.id), taken);
|
|
170
|
+
taken.add(String(entry.id));
|
|
171
|
+
match.added.push({ found: f, entry });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return match;
|
|
175
|
+
}
|
|
176
|
+
/** One entry as one line of JSON, keys in order: `{ "id": "ac", "connector": "daikin", "host": "…" }`. */
|
|
177
|
+
const oneLine = (entry) => `{ ${Object.entries(entry)
|
|
178
|
+
.map(([k, v]) => `${JSON.stringify(k)}: ${JSON.stringify(v)}`)
|
|
179
|
+
.join(", ")} }`;
|
|
180
|
+
/**
|
|
181
|
+
* The config's text with moved hosts and renamed Cast entries updated and new devices appended, through jsonc-parser's edits: comments, unknown
|
|
182
|
+
* keys and every other line stay as they were. A new device goes on its own line at its neighbour's indent.
|
|
183
|
+
*/
|
|
184
|
+
export function applyFound(text, match, hasDevices) {
|
|
185
|
+
// hasDevices: the config has a non-empty devices list
|
|
186
|
+
let out = text;
|
|
187
|
+
for (const m of match.moved)
|
|
188
|
+
out = applyEdits(out, modify(out, ["devices", m.index, "host"], m.to, {}));
|
|
189
|
+
for (const r of match.renamed)
|
|
190
|
+
out = applyEdits(out, modify(out, ["devices", r.index, "name"], r.to, {}));
|
|
191
|
+
if (!hasDevices && match.added.length)
|
|
192
|
+
return applyEdits(out, modify(out, ["devices"], match.added.map((a) => a.entry), { formattingOptions: { insertSpaces: true, tabSize: 2 } }));
|
|
193
|
+
for (const { entry } of match.added) {
|
|
194
|
+
const edits = modify(out, ["devices", -1], entry, {
|
|
195
|
+
isArrayInsertion: true,
|
|
196
|
+
});
|
|
197
|
+
const before = out;
|
|
198
|
+
out = applyEdits(out, edits.map((e) => {
|
|
199
|
+
const indent = before
|
|
200
|
+
.slice(before.lastIndexOf("\n", e.offset - 1) + 1)
|
|
201
|
+
.match(/^\s*/)[0];
|
|
202
|
+
return {
|
|
203
|
+
...e,
|
|
204
|
+
content: e.content.replace(JSON.stringify(entry), `\n${indent}${oneLine(entry)}`),
|
|
205
|
+
};
|
|
206
|
+
}));
|
|
207
|
+
}
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
/** mDNS and Daikin's broadcast drop answers now and then, so discovery runs this many passes by default… */
|
|
211
|
+
export const DISCOVERY_PASSES = 2;
|
|
212
|
+
/** …this far apart. */
|
|
213
|
+
export const DISCOVERY_GAP_MS = 5000;
|
|
214
|
+
/** A device's key, across discovery passes and in the address cache: its connector and identity, without case or separators. */
|
|
215
|
+
export const addressKey = (connector, id) => `${connector}:${idKey(id)}`;
|
|
216
|
+
export const foundKey = (f) => addressKey(f.connector, f.id);
|
|
217
|
+
/**
|
|
218
|
+
* One run's view of an address cache. `fresh` is a cached address confirmed since the config was edited (`configAt`,
|
|
219
|
+
* the config file's time; a config edited since wins); `seen` records an address that answered, writing through when it
|
|
220
|
+
* changed or was not fresh, so an address confirmed after a config edit is trusted again. A Cast name, when given, is the
|
|
221
|
+
* device's own (discovery's fn, or its own multizone status, never a config's), so a new one is written too; a call
|
|
222
|
+
* without a name keeps the one cached.
|
|
223
|
+
*/
|
|
224
|
+
export function addressBook(cache, options = {}) {
|
|
225
|
+
const entries = cache?.read() ?? {};
|
|
226
|
+
const now = options.now ?? Date.now;
|
|
227
|
+
// `seenAt` has whole milliseconds and a file time a fraction: a confirmation in the same millisecond as the edit counts.
|
|
228
|
+
const isFresh = (c) => options.configAt === undefined ||
|
|
229
|
+
Date.parse(c.seenAt) >= Math.floor(options.configAt);
|
|
230
|
+
return {
|
|
231
|
+
fresh(connector, id) {
|
|
232
|
+
const c = id === undefined ? undefined : entries[addressKey(connector, id)];
|
|
233
|
+
return c && isFresh(c) ? c : undefined;
|
|
234
|
+
},
|
|
235
|
+
seen(connector, id, address) {
|
|
236
|
+
if (!cache || id === undefined)
|
|
237
|
+
return;
|
|
238
|
+
const key = addressKey(connector, id);
|
|
239
|
+
const c = entries[key];
|
|
240
|
+
if (c &&
|
|
241
|
+
c.host === address.host &&
|
|
242
|
+
c.port === address.port &&
|
|
243
|
+
isFresh(c) &&
|
|
244
|
+
(!address.name || address.name === c.name))
|
|
245
|
+
return;
|
|
246
|
+
const name = address.name || c?.name;
|
|
247
|
+
entries[key] = {
|
|
248
|
+
host: address.host,
|
|
249
|
+
...(address.port === undefined ? {} : { port: address.port }),
|
|
250
|
+
...(name ? { name } : {}),
|
|
251
|
+
seenAt: new Date(now()).toISOString(),
|
|
252
|
+
};
|
|
253
|
+
cache.write({ ...entries });
|
|
254
|
+
},
|
|
255
|
+
entries: () => ({ ...entries }),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Run one discovery `passes` times, `gapMs` apart, and return the union (first answer wins). `onPass` sees each pass's
|
|
260
|
+
* additions as they come; `done` ends it early once what was looked for has answered. A pass that throws adds nothing;
|
|
261
|
+
* when every pass threw, discovery never ran, so it throws the last error rather than reporting nothing found. An error
|
|
262
|
+
* `stopOn` accepts is thrown at once: no later pass could do better.
|
|
263
|
+
*/
|
|
264
|
+
export async function discover(pass, options) {
|
|
265
|
+
const key = options.key ?? foundKey;
|
|
266
|
+
const all = new Map();
|
|
267
|
+
const passes = options.passes ?? DISCOVERY_PASSES;
|
|
268
|
+
let ran = 0;
|
|
269
|
+
const errors = [];
|
|
270
|
+
for (let n = 1; n <= passes && !options.signal?.aborted; n++) {
|
|
271
|
+
if (n > 1)
|
|
272
|
+
await options.clock.sleep(options.gapMs ?? DISCOVERY_GAP_MS, options.signal);
|
|
273
|
+
if (options.signal?.aborted)
|
|
274
|
+
break;
|
|
275
|
+
ran++;
|
|
276
|
+
const answers = await pass().catch((error) => {
|
|
277
|
+
if (options.stopOn?.(error))
|
|
278
|
+
throw error;
|
|
279
|
+
errors.push(error);
|
|
280
|
+
return [];
|
|
281
|
+
});
|
|
282
|
+
const added = answers.filter((t) => !all.has(key(t)) && all.set(key(t), t));
|
|
283
|
+
options.onPass?.(n, added);
|
|
284
|
+
if (options.done?.([...all.values()]))
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
if (ran && errors.length === ran)
|
|
288
|
+
throw errors.at(-1);
|
|
289
|
+
return [...all.values()];
|
|
290
|
+
}
|
|
291
|
+
/** Discovery runs after this many failed reads in a row. */
|
|
292
|
+
export const RESCAN_AFTER_FAILURES = 3;
|
|
293
|
+
/** Then again after these delays, then hourly for as long as reads fail. */
|
|
294
|
+
export const RESCAN_DELAYS_MS = [60_000, 5 * 60_000, 15 * 60_000];
|
|
295
|
+
export const HOUR = 3_600_000;
|
|
296
|
+
/** The wait before the next scan after `attempt` scans have missed: 1, 5 and 15 minutes, then hourly. The relocator and Cast's re-discovery share it. */
|
|
297
|
+
export const rescanDelay = (attempt) => RESCAN_DELAYS_MS[attempt] ?? HOUR;
|
|
298
|
+
/** After this long without a read, the detail says a day has passed; the scans carry on hourly. */
|
|
299
|
+
export const DAY_MS = 24 * HOUR;
|
|
300
|
+
/**
|
|
301
|
+
* A polling connector's address, rescanned only on failure. `failed()` after each failed read runs `find` once reads
|
|
302
|
+
* have failed `RESCAN_AFTER_FAILURES` times in a row, then on the backoff, then hourly with no end, so a device that comes
|
|
303
|
+
* back is found without a restart. `ok()` after a good read, `reset()` on a refresh (the next failure scans at once).
|
|
304
|
+
* Without `find` or an identity it never scans. `cached`: the starting host came from an address cache, so the first
|
|
305
|
+
* failure scans at once. `failed(signal)` takes the run's signal, so a shutdown does not wait out a scan.
|
|
306
|
+
*/
|
|
307
|
+
export function relocator(options) {
|
|
308
|
+
let host = options.host;
|
|
309
|
+
let failures = 0;
|
|
310
|
+
let fromCache = options.cached === true; // a cached address that fails is stale: scan on its first failure
|
|
311
|
+
let attempts = 0;
|
|
312
|
+
let firstScan;
|
|
313
|
+
let nextScan = 0;
|
|
314
|
+
let seenHere;
|
|
315
|
+
let discoveryFailed;
|
|
316
|
+
const reset = () => ((attempts = 0),
|
|
317
|
+
(firstScan = undefined),
|
|
318
|
+
(nextScan = 0),
|
|
319
|
+
(seenHere = undefined),
|
|
320
|
+
(discoveryFailed = undefined));
|
|
321
|
+
return {
|
|
322
|
+
host: () => host,
|
|
323
|
+
ok: () => ((failures = 0), (fromCache = false), reset()),
|
|
324
|
+
reset,
|
|
325
|
+
async failed(signal) {
|
|
326
|
+
failures++;
|
|
327
|
+
const { find, id, clock } = options;
|
|
328
|
+
if (!find || !id || failures < (fromCache ? 1 : RESCAN_AFTER_FAILURES))
|
|
329
|
+
return {};
|
|
330
|
+
const now = clock.now();
|
|
331
|
+
const state = () => ({
|
|
332
|
+
...(firstScan !== undefined && now - firstScan >= DAY_MS
|
|
333
|
+
? { overADay: true }
|
|
334
|
+
: {}),
|
|
335
|
+
...(seenHere === undefined ? {} : { seenHere }),
|
|
336
|
+
...(discoveryFailed === undefined ? {} : { discoveryFailed }),
|
|
337
|
+
});
|
|
338
|
+
if (now < nextScan)
|
|
339
|
+
return state();
|
|
340
|
+
firstScan ??= now;
|
|
341
|
+
let at;
|
|
342
|
+
try {
|
|
343
|
+
at = (await discover(find, {
|
|
344
|
+
clock,
|
|
345
|
+
signal,
|
|
346
|
+
done: (all) => all.some((f) => sameId(f.id, id)),
|
|
347
|
+
})).find((f) => sameId(f.id, id))?.host;
|
|
348
|
+
discoveryFailed = undefined;
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
discoveryFailed = errorText(error);
|
|
352
|
+
}
|
|
353
|
+
if (at && at !== host) {
|
|
354
|
+
const from = host;
|
|
355
|
+
host = at;
|
|
356
|
+
failures = 0;
|
|
357
|
+
reset();
|
|
358
|
+
return { moved: `moved from ${from} to ${at}` };
|
|
359
|
+
}
|
|
360
|
+
seenHere = discoveryFailed === undefined ? at === host : undefined;
|
|
361
|
+
nextScan = now + rescanDelay(attempts);
|
|
362
|
+
attempts++;
|
|
363
|
+
return state();
|
|
364
|
+
},
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
/** A failed read's detail, saying what discovery found: never "not found" unless a scan looked and missed it. */
|
|
368
|
+
export function lostDetail(lost, error) {
|
|
369
|
+
const what = lost.discoveryFailed !== undefined
|
|
370
|
+
? `discovery failed: ${lost.discoveryFailed}`
|
|
371
|
+
: lost.seenHere === true
|
|
372
|
+
? "discovery finds it at its address but reads fail"
|
|
373
|
+
: lost.seenHere === false
|
|
374
|
+
? "not found by discovery"
|
|
375
|
+
: "";
|
|
376
|
+
const why = errorText(error);
|
|
377
|
+
return [lost.overADay ? "no answer for a day" : "", what, why]
|
|
378
|
+
.filter(Boolean)
|
|
379
|
+
.join("; ");
|
|
380
|
+
}
|
|
381
|
+
/** Instance names from `dns-sd -B`, each once. */
|
|
382
|
+
export const parseBrowse = (text) => [
|
|
383
|
+
...new Set([...text.matchAll(/ Add .*?\.\s+\S+\.\s+(.+)$/gm)].map((m) => m[1].trim())),
|
|
384
|
+
];
|
|
385
|
+
const utf8 = new TextEncoder();
|
|
386
|
+
/** Undo DNS-SD text escapes, as `dns-sd` and `avahi-browse` print them: `\ ` or `\.` for the character, `\226\128\153` for each byte of a UTF-8 character. */
|
|
387
|
+
function unescapeDnsSd(token) {
|
|
388
|
+
const bytes = [];
|
|
389
|
+
for (let i = 0; i < token.length; i++) {
|
|
390
|
+
const decimal = token[i] === "\\" ? token.slice(i + 1, i + 4) : "";
|
|
391
|
+
if (/^\d{3}$/.test(decimal))
|
|
392
|
+
(bytes.push(Number(decimal)), (i += 3));
|
|
393
|
+
else
|
|
394
|
+
bytes.push(...utf8.encode(token[token[i] === "\\" ? ++i : i]));
|
|
395
|
+
}
|
|
396
|
+
return new TextDecoder().decode(Uint8Array.from(bytes));
|
|
397
|
+
}
|
|
398
|
+
/** TXT strings as a record: `key=value`, and a bare `key` or `key=` as an empty value. */
|
|
399
|
+
const txtRecord = (pairs) => Object.fromEntries(pairs
|
|
400
|
+
.filter((p) => p && !p.startsWith("="))
|
|
401
|
+
.map((p) => p.includes("=")
|
|
402
|
+
? [p.slice(0, p.indexOf("=")), p.slice(p.indexOf("=") + 1)]
|
|
403
|
+
: [p, ""]));
|
|
404
|
+
/**
|
|
405
|
+
* `avahi-browse --parsable --resolve` (Linux): each resolved instance once, IPv4 only (the config keeps IPv4 addresses).
|
|
406
|
+
* A resolved line is `=;interface;IPv4;name;type;domain;hostname;address;port;"txt" "txt"`; names and TXT strings use
|
|
407
|
+
* the same escapes as `dns-sd` (`\059` for a `;` in a name, `\"` inside TXT). An instance seen on two interfaces is kept
|
|
408
|
+
* once, the first; loopback is skipped. Checked against avahi-utils on Debian trixie, 2026-10-16.
|
|
409
|
+
*/
|
|
410
|
+
export function parseAvahi(text) {
|
|
411
|
+
const seen = new Map();
|
|
412
|
+
for (const line of text.split("\n")) {
|
|
413
|
+
const f = line.split(";");
|
|
414
|
+
// By the address itself: column 3 is the protocol the service was seen on, not necessarily the address's. Loopback is a service on this machine.
|
|
415
|
+
if (f[0] !== "=" ||
|
|
416
|
+
f.length < 10 ||
|
|
417
|
+
!/^\d+\.\d+\.\d+\.\d+$/.test(f[7]) ||
|
|
418
|
+
f[1] === "lo" ||
|
|
419
|
+
f[7].startsWith("127."))
|
|
420
|
+
continue;
|
|
421
|
+
const instance = unescapeDnsSd(f[3]);
|
|
422
|
+
const port = Number(f[8]);
|
|
423
|
+
if (seen.has(instance) || !f[7] || !Number.isInteger(port))
|
|
424
|
+
continue;
|
|
425
|
+
const txt = txtRecord([
|
|
426
|
+
...f
|
|
427
|
+
.slice(9)
|
|
428
|
+
.join(";")
|
|
429
|
+
.matchAll(/"((?:\\.|[^"\\])*)"/g),
|
|
430
|
+
].map((m) => unescapeDnsSd(m[1])));
|
|
431
|
+
seen.set(instance, { instance, hostname: f[6], host: f[7], port, txt });
|
|
432
|
+
}
|
|
433
|
+
return [...seen.values()];
|
|
434
|
+
}
|
|
435
|
+
/** Instance names from `avahi-browse --parsable` (a `+` line per instance and interface), each once. */
|
|
436
|
+
export const parseAvahiBrowse = (text) => [
|
|
437
|
+
...new Set(text
|
|
438
|
+
.split("\n")
|
|
439
|
+
.flatMap((l) => l.startsWith("+;") ? [unescapeDnsSd(l.split(";")[3] ?? "")] : [])),
|
|
440
|
+
].filter(Boolean);
|
|
441
|
+
/**
|
|
442
|
+
* One `dns-sd -L` answer: the host and port it can be reached at, and its TXT record. TXT values come escaped (`\ ` for
|
|
443
|
+
* a space, `\226\128\153` for each byte of a UTF-8 character); these are undone, so `fn` reads as the Google Home app shows it.
|
|
444
|
+
*/
|
|
445
|
+
export function parseResolve(text) {
|
|
446
|
+
const at = text.match(/can be reached at (\S+?)\.?:(\d+)/);
|
|
447
|
+
if (!at)
|
|
448
|
+
return undefined;
|
|
449
|
+
const line = text.slice(at.index).split("\n")[1] ?? "";
|
|
450
|
+
const tokens = (line.trim().match(/(?:\\.|[^\s\\])+/g) ?? []).map(unescapeDnsSd);
|
|
451
|
+
return { hostname: at[1], port: Number(at[2]), txt: txtRecord(tokens) };
|
|
452
|
+
}
|
package/dist/energy.d.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { AmberState, PriceInterval } from "./connectors/amber/client.ts";
|
|
2
|
+
import type { EnphaseState } from "./connectors/enphase/client.ts";
|
|
3
|
+
import type { EnphaseSnapshot } from "./connectors/enphase/index.ts";
|
|
4
|
+
/** The home's energy right now: power from Enphase, prices from Amber, and what the grid flow costs per hour. */
|
|
5
|
+
export type EnergyNow = {
|
|
6
|
+
solarW?: number;
|
|
7
|
+
solarPeakTodayW?: number;
|
|
8
|
+
usingW?: number;
|
|
9
|
+
gridW?: number;
|
|
10
|
+
importCents?: number;
|
|
11
|
+
exportCents?: number;
|
|
12
|
+
centsPerHour?: number;
|
|
13
|
+
renewables?: number;
|
|
14
|
+
priceDescriptor?: string;
|
|
15
|
+
spike?: string;
|
|
16
|
+
estimate?: boolean;
|
|
17
|
+
negativePrice?: "paid to use" | "exporting at a loss";
|
|
18
|
+
flow?: "importing" | "exporting" | "balanced";
|
|
19
|
+
surplusW?: number;
|
|
20
|
+
surplusCentsPerHour?: number;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Within this many watts either way the grid flow reads as "balanced": a display rule for the word and the animation,
|
|
24
|
+
* so a meter wobbling around zero does not flip between importing and exporting. `gridW` and `surplusW` stay as read.
|
|
25
|
+
*/
|
|
26
|
+
export declare const BALANCED_W = 20;
|
|
27
|
+
/** One owner for the combined number, so every view (CLI, jaldi, the bot) shows the same thing. */
|
|
28
|
+
export declare function energyNow(power?: EnphaseState & Partial<Pick<EnphaseSnapshot, "productionWPeakToday">>, prices?: AmberState): EnergyNow;
|
|
29
|
+
/**
|
|
30
|
+
* How brightly to draw the panels: `solarW` as a fraction of the array's rated `capacityW` when the config has it,
|
|
31
|
+
* else of today's peak so far, with which scale it is (they mean different things, so a drawing says which).
|
|
32
|
+
* Never clamped: above 1 is above the scale. Undefined when there is no solar reading or nothing to scale by.
|
|
33
|
+
*/
|
|
34
|
+
export declare function solarLevel(now: EnergyNow, capacityW?: number): {
|
|
35
|
+
fraction: number;
|
|
36
|
+
scale: "capacity" | "today's peak";
|
|
37
|
+
} | undefined;
|
|
38
|
+
/**
|
|
39
|
+
* The grid flow in words every view shares: "exporting 1.2 kW, earning 42 c/h", "importing 0.8 kW, costing 33 c/h",
|
|
40
|
+
* "balanced (-10 W)", or "" with no grid reading. Rounded for reading only; the numbers above stay as read.
|
|
41
|
+
*/
|
|
42
|
+
export declare function gridText(now: EnergyNow): string;
|
|
43
|
+
/** A run of the forecast and its time-weighted averages: import price, feed-in price and the grid's renewable share. */
|
|
44
|
+
export type PriceWindow = {
|
|
45
|
+
start: string;
|
|
46
|
+
end: string;
|
|
47
|
+
importCents?: number;
|
|
48
|
+
exportCents?: number;
|
|
49
|
+
renewables?: number;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* The best `hours` to run something in, from Amber's forecast: the cheapest by import price (Amber's `perKwh`), or the
|
|
53
|
+
* greenest by the grid's renewable share. The window runs over unbroken intervals that each have the number it is judged by,
|
|
54
|
+
* starting no earlier than `from` (pass the clock's now; it defaults to the forecast's first interval, which began up to
|
|
55
|
+
* 5 minutes ago). Prices are flat within an interval, so the best window starts or ends on an interval's edge: both are
|
|
56
|
+
* tried. Averages weigh each interval by the time it covers; the earliest wins a tie. Undefined when nothing fits.
|
|
57
|
+
*/
|
|
58
|
+
export declare function bestWindow(forecast: PriceInterval[] | undefined, hours: number, options?: {
|
|
59
|
+
by?: "price" | "renewables";
|
|
60
|
+
from?: number;
|
|
61
|
+
}): PriceWindow | undefined;
|
package/dist/energy.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { kw } from "./manifest.js";
|
|
2
|
+
/**
|
|
3
|
+
* Within this many watts either way the grid flow reads as "balanced": a display rule for the word and the animation,
|
|
4
|
+
* so a meter wobbling around zero does not flip between importing and exporting. `gridW` and `surplusW` stay as read.
|
|
5
|
+
*/
|
|
6
|
+
export const BALANCED_W = 20;
|
|
7
|
+
/** The grid flow in a word, from the raw `gridW`. */
|
|
8
|
+
function flowOf(gridW) {
|
|
9
|
+
if (gridW === undefined)
|
|
10
|
+
return undefined;
|
|
11
|
+
return Math.abs(gridW) <= BALANCED_W
|
|
12
|
+
? "balanced"
|
|
13
|
+
: gridW > 0
|
|
14
|
+
? "importing"
|
|
15
|
+
: "exporting";
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* `"paid to use"` when the import price is below zero, else `"exporting at a loss"` when the export price is (Amber's
|
|
19
|
+
* feed-in `perKwh` above zero), else undefined. Both can hold in one interval; the import side wins.
|
|
20
|
+
*/
|
|
21
|
+
function negativePrice(prices) {
|
|
22
|
+
if (prices?.importCents !== undefined && prices.importCents < 0)
|
|
23
|
+
return "paid to use";
|
|
24
|
+
if (prices?.exportCents !== undefined && prices.exportCents < 0)
|
|
25
|
+
return "exporting at a loss";
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
/** One owner for the combined number, so every view (CLI, jaldi, the bot) shows the same thing. */
|
|
29
|
+
export function energyNow(power, prices) {
|
|
30
|
+
const gridW = power?.netW;
|
|
31
|
+
const surplusW = gridW === undefined ? undefined : gridW < 0 ? -gridW : 0;
|
|
32
|
+
const rate = gridW === undefined
|
|
33
|
+
? undefined
|
|
34
|
+
: gridW >= 0
|
|
35
|
+
? prices?.importCents
|
|
36
|
+
: prices?.exportCents;
|
|
37
|
+
return {
|
|
38
|
+
solarW: power?.productionW,
|
|
39
|
+
solarPeakTodayW: power?.productionWPeakToday,
|
|
40
|
+
usingW: power?.consumptionW,
|
|
41
|
+
gridW,
|
|
42
|
+
importCents: prices?.importCents,
|
|
43
|
+
exportCents: prices?.exportCents,
|
|
44
|
+
centsPerHour: gridW === undefined || rate === undefined
|
|
45
|
+
? undefined
|
|
46
|
+
: (gridW / 1000) * rate, // export: negative kW × earned c/kWh = negative cost
|
|
47
|
+
renewables: prices?.renewables,
|
|
48
|
+
priceDescriptor: prices?.priceDescriptor,
|
|
49
|
+
spike: prices?.spike,
|
|
50
|
+
estimate: prices?.estimate,
|
|
51
|
+
negativePrice: negativePrice(prices),
|
|
52
|
+
flow: flowOf(gridW),
|
|
53
|
+
surplusW,
|
|
54
|
+
surplusCentsPerHour: surplusW === undefined || prices?.exportCents === undefined
|
|
55
|
+
? undefined
|
|
56
|
+
: (surplusW / 1000) * prices.exportCents,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* How brightly to draw the panels: `solarW` as a fraction of the array's rated `capacityW` when the config has it,
|
|
61
|
+
* else of today's peak so far, with which scale it is (they mean different things, so a drawing says which).
|
|
62
|
+
* Never clamped: above 1 is above the scale. Undefined when there is no solar reading or nothing to scale by.
|
|
63
|
+
*/
|
|
64
|
+
export function solarLevel(now, capacityW) {
|
|
65
|
+
if (now.solarW === undefined)
|
|
66
|
+
return undefined;
|
|
67
|
+
if (capacityW !== undefined && capacityW > 0)
|
|
68
|
+
return { fraction: now.solarW / capacityW, scale: "capacity" };
|
|
69
|
+
if (now.solarPeakTodayW !== undefined && now.solarPeakTodayW > 0)
|
|
70
|
+
return {
|
|
71
|
+
fraction: now.solarW / now.solarPeakTodayW,
|
|
72
|
+
scale: "today's peak",
|
|
73
|
+
};
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The grid flow in words every view shares: "exporting 1.2 kW, earning 42 c/h", "importing 0.8 kW, costing 33 c/h",
|
|
78
|
+
* "balanced (-10 W)", or "" with no grid reading. Rounded for reading only; the numbers above stay as read.
|
|
79
|
+
*/
|
|
80
|
+
export function gridText(now) {
|
|
81
|
+
const rate = (c, positive, negative) => c === undefined
|
|
82
|
+
? ""
|
|
83
|
+
: `, ${c >= 0 ? positive : negative} ${Math.round(Math.abs(c))} c/h`;
|
|
84
|
+
if (now.flow === "exporting" && now.surplusW !== undefined)
|
|
85
|
+
return `exporting ${kw(now.surplusW)}${rate(now.surplusCentsPerHour, "earning", "costing")}`;
|
|
86
|
+
if (now.flow === "importing" && now.gridW !== undefined)
|
|
87
|
+
return `importing ${kw(now.gridW)}${rate(now.centsPerHour, "costing", "earning")}`;
|
|
88
|
+
return now.flow === "balanced" ? `balanced (${now.gridW} W)` : "";
|
|
89
|
+
}
|
|
90
|
+
const HOUR_MS = 3_600_000;
|
|
91
|
+
/**
|
|
92
|
+
* The best `hours` to run something in, from Amber's forecast: the cheapest by import price (Amber's `perKwh`), or the
|
|
93
|
+
* greenest by the grid's renewable share. The window runs over unbroken intervals that each have the number it is judged by,
|
|
94
|
+
* starting no earlier than `from` (pass the clock's now; it defaults to the forecast's first interval, which began up to
|
|
95
|
+
* 5 minutes ago). Prices are flat within an interval, so the best window starts or ends on an interval's edge: both are
|
|
96
|
+
* tried. Averages weigh each interval by the time it covers; the earliest wins a tie. Undefined when nothing fits.
|
|
97
|
+
*/
|
|
98
|
+
export function bestWindow(forecast, hours, options = {}) {
|
|
99
|
+
if (!(hours > 0 && Number.isFinite(hours)))
|
|
100
|
+
throw new RangeError(`bestWindow: hours must be above 0, got ${hours}`);
|
|
101
|
+
const by = options.by ?? "price";
|
|
102
|
+
const rows = (forecast ?? []).map((r) => ({
|
|
103
|
+
...r,
|
|
104
|
+
t0: Date.parse(r.start),
|
|
105
|
+
t1: Date.parse(r.end),
|
|
106
|
+
}));
|
|
107
|
+
const from = options.from ?? rows[0]?.t0 ?? 0;
|
|
108
|
+
const length = Math.round(hours * HOUR_MS); // whole ms, so the covered time adds up to it exactly
|
|
109
|
+
const starts = [
|
|
110
|
+
...new Set(rows
|
|
111
|
+
.flatMap((r) => [Math.max(r.t0, from), r.t1 - length])
|
|
112
|
+
.filter((t) => t >= from)),
|
|
113
|
+
].sort((a, b) => a - b);
|
|
114
|
+
let best;
|
|
115
|
+
let bestScore = Infinity;
|
|
116
|
+
for (const start of starts) {
|
|
117
|
+
const window = windowAt(rows, start, length, by);
|
|
118
|
+
const score = window && (by === "price" ? window.importCents : -window.renewables);
|
|
119
|
+
if (window && score < bestScore)
|
|
120
|
+
((best = window), (bestScore = score));
|
|
121
|
+
}
|
|
122
|
+
return best;
|
|
123
|
+
}
|
|
124
|
+
/** The window of `length` ms from `start`, or undefined when an interval in it is missing, or lacks the number `by` needs. Any other average is left out unless every interval has it. */
|
|
125
|
+
function windowAt(rows, start, length, by) {
|
|
126
|
+
const end = start + length;
|
|
127
|
+
const sum = {
|
|
128
|
+
importCents: 0,
|
|
129
|
+
exportCents: 0,
|
|
130
|
+
renewables: 0,
|
|
131
|
+
importMs: 0,
|
|
132
|
+
exportMs: 0,
|
|
133
|
+
renewablesMs: 0,
|
|
134
|
+
};
|
|
135
|
+
let at = start;
|
|
136
|
+
for (const r of rows) {
|
|
137
|
+
if (r.t1 <= at)
|
|
138
|
+
continue;
|
|
139
|
+
if (at >= end)
|
|
140
|
+
break;
|
|
141
|
+
if (r.t0 > at ||
|
|
142
|
+
(by === "price" ? r.importCents : r.renewables) === undefined)
|
|
143
|
+
return undefined; // a gap, or no number to judge by
|
|
144
|
+
const ms = Math.min(r.t1, end) - at;
|
|
145
|
+
if (r.importCents !== undefined)
|
|
146
|
+
((sum.importCents += r.importCents * ms), (sum.importMs += ms));
|
|
147
|
+
if (r.exportCents !== undefined)
|
|
148
|
+
((sum.exportCents += r.exportCents * ms), (sum.exportMs += ms));
|
|
149
|
+
if (r.renewables !== undefined)
|
|
150
|
+
((sum.renewables += r.renewables * ms), (sum.renewablesMs += ms));
|
|
151
|
+
at += ms;
|
|
152
|
+
}
|
|
153
|
+
if (at < end)
|
|
154
|
+
return undefined;
|
|
155
|
+
return {
|
|
156
|
+
start: new Date(start).toISOString(),
|
|
157
|
+
end: new Date(end).toISOString(),
|
|
158
|
+
importCents: sum.importMs === length ? sum.importCents / length : undefined,
|
|
159
|
+
exportCents: sum.exportMs === length ? sum.exportCents / length : undefined,
|
|
160
|
+
renewables: sum.renewablesMs === length ? sum.renewables / length : undefined,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { FINDERS, fileCache } from "./finders.ts";
|