@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/config.js
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import { parse, printParseErrorCode } from "jsonc-parser";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { isTimeZone, realClock } from "./clock.js";
|
|
4
|
+
import { errorText } from "./connector.js";
|
|
5
|
+
import { amber, configSchema as amberDevice, } from "./connectors/amber/index.js";
|
|
6
|
+
import { daikin, configSchema as daikinDevice, } from "./connectors/daikin/index.js";
|
|
7
|
+
import { cast, configSchema as castDevice, resolvedFrom, } from "./connectors/cast/index.js";
|
|
8
|
+
import { bunGatewayFetch, enphase, configSchema as enphaseDevice, } from "./connectors/enphase/index.js";
|
|
9
|
+
import { addressBook, discover, MANY, sameId, } from "./discover.js";
|
|
10
|
+
import { envName, filePath } from "./home.js";
|
|
11
|
+
import { bunPinnedFetch, hue, configSchema as hueDevice, } from "./connectors/hue/index.js";
|
|
12
|
+
import { printers, configSchema as printersDevice, } from "./connectors/printers/index.js";
|
|
13
|
+
import { spotify, configSchema as spotifyDevice, } from "./connectors/spotify/index.js";
|
|
14
|
+
import { up, configSchema as upDevice } from "./connectors/up/index.js";
|
|
15
|
+
import { manifestOf } from "./manifests.js";
|
|
16
|
+
import { bom, google, openMeteo, weather, } from "./connectors/weather/index.js";
|
|
17
|
+
// One home config (JSONC) that the CLI, jaldi and the bot all read. Devices hold only what it takes to reach them and
|
|
18
|
+
// where they live; what a device reports about itself comes live. Secrets are env names; files are paths the caller reads.
|
|
19
|
+
/** Where a device or marker sits: `id` matches `<g class="room" id="…">` in the plan's SVG. */
|
|
20
|
+
export const Room = z.looseObject({
|
|
21
|
+
id: z.string().startsWith("room-"),
|
|
22
|
+
name: z.string(),
|
|
23
|
+
floor: z.string().optional(),
|
|
24
|
+
hueRoom: z
|
|
25
|
+
.string()
|
|
26
|
+
.optional()
|
|
27
|
+
.describe("the Hue room, zone or grouped_light id whose lights this room's marker shows"),
|
|
28
|
+
});
|
|
29
|
+
/** JSONC (comments and trailing commas) through jsonc-parser, which runs on Node too; a syntax error throws rather than parsing to part of the file. */
|
|
30
|
+
export function parseJsonc(text) {
|
|
31
|
+
const errors = [];
|
|
32
|
+
const value = parse(text, errors, { allowTrailingComma: true });
|
|
33
|
+
if (errors.length)
|
|
34
|
+
throw new SyntaxError(`JSONC: ${printParseErrorCode(errors[0].error)} at offset ${errors[0].offset}`);
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
/** Every device: an id, the room it is in (absent: the whole house), a name if the id is not enough. */
|
|
38
|
+
const placed = {
|
|
39
|
+
id: z.string(),
|
|
40
|
+
room: z.string().startsWith("room-").optional(),
|
|
41
|
+
name: z.string().optional(),
|
|
42
|
+
};
|
|
43
|
+
/** A device is one connector's settings; a new connector adds its `configSchema` here, in one line. */
|
|
44
|
+
export const Device = z.discriminatedUnion("connector", [
|
|
45
|
+
hueDevice.extend(placed),
|
|
46
|
+
daikinDevice.extend(placed),
|
|
47
|
+
enphaseDevice.extend(placed),
|
|
48
|
+
amberDevice.extend(placed),
|
|
49
|
+
upDevice.extend(placed),
|
|
50
|
+
castDevice.extend({ id: placed.id, room: placed.room }), // its name is required: the Google Home name
|
|
51
|
+
printersDevice.extend({ id: placed.id, room: placed.room }), // likewise: the printer's own name
|
|
52
|
+
spotifyDevice.extend(placed),
|
|
53
|
+
]);
|
|
54
|
+
export const WEATHER_SOURCES = ["bom", "open-meteo", "google"];
|
|
55
|
+
export const HomeConfig = z.looseObject({
|
|
56
|
+
$schema: z.string().optional(),
|
|
57
|
+
name: z.string().optional(),
|
|
58
|
+
location: z
|
|
59
|
+
.looseObject({
|
|
60
|
+
latitude: z.number().min(-90).max(90),
|
|
61
|
+
longitude: z.number().min(-180).max(180),
|
|
62
|
+
bomForecast: z
|
|
63
|
+
.string()
|
|
64
|
+
.optional()
|
|
65
|
+
.describe("BOM précis location by name or aac, e.g. Sydney"),
|
|
66
|
+
timezone: z
|
|
67
|
+
.string()
|
|
68
|
+
.refine(isTimeZone, "an IANA time zone, e.g. Australia/Sydney")
|
|
69
|
+
.optional()
|
|
70
|
+
.describe("the home's IANA time zone, where its day starts (Enphase's and Up's today, the weather's day blocks) and the zone weather times show in; the runtime's when left out"),
|
|
71
|
+
})
|
|
72
|
+
.optional(),
|
|
73
|
+
weather: z
|
|
74
|
+
.looseObject({
|
|
75
|
+
sources: z
|
|
76
|
+
.array(z.enum(WEATHER_SOURCES))
|
|
77
|
+
.optional()
|
|
78
|
+
.describe("preference order; default open-meteo"),
|
|
79
|
+
pollMinutes: z.number().min(1).optional(),
|
|
80
|
+
googleKey: envName
|
|
81
|
+
.optional()
|
|
82
|
+
.describe("env name holding a Maps Platform key, when sources has google"),
|
|
83
|
+
})
|
|
84
|
+
.optional(),
|
|
85
|
+
rooms: z.array(Room).optional(),
|
|
86
|
+
devices: z.array(Device).optional(),
|
|
87
|
+
plan: z
|
|
88
|
+
.looseObject({ svg: filePath.optional(), json: filePath.optional() })
|
|
89
|
+
.optional()
|
|
90
|
+
.describe("the drawing, by path relative to this file"),
|
|
91
|
+
});
|
|
92
|
+
/** A config that cannot run. `problems` names each field. */
|
|
93
|
+
export class HomeConfigError extends Error {
|
|
94
|
+
problems;
|
|
95
|
+
constructor(problems) {
|
|
96
|
+
super(problems.join("; "));
|
|
97
|
+
this.problems = problems;
|
|
98
|
+
this.name = "HomeConfigError";
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** A field's path in one format for every problem: `devices[3] (prices).key`. */
|
|
102
|
+
function fieldPath(path, input) {
|
|
103
|
+
const devices = input
|
|
104
|
+
?.devices;
|
|
105
|
+
return path
|
|
106
|
+
.map((p, i) => {
|
|
107
|
+
if (typeof p !== "number")
|
|
108
|
+
return `${i ? "." : ""}${String(p)}`;
|
|
109
|
+
const id = path[i - 1] === "devices" && i === 1 ? devices?.[p]?.id : undefined;
|
|
110
|
+
return `[${p}]${typeof id === "string" ? ` (${id})` : ""}`;
|
|
111
|
+
})
|
|
112
|
+
.join("");
|
|
113
|
+
}
|
|
114
|
+
/** The drawing side of a config: the schema, unique room and device ids, and every device's room present. Problems, not a throw. */
|
|
115
|
+
export function checkPlan(input) {
|
|
116
|
+
const parsed = HomeConfig.safeParse(input);
|
|
117
|
+
if (!parsed.success)
|
|
118
|
+
return {
|
|
119
|
+
problems: parsed.error.issues.map((i) => `${fieldPath(i.path, input) || "(root)"}: ${i.message}`),
|
|
120
|
+
};
|
|
121
|
+
const config = parsed.data;
|
|
122
|
+
const ids = (config.rooms ?? []).map((r) => r.id);
|
|
123
|
+
const rooms = new Set(ids);
|
|
124
|
+
const problems = ids
|
|
125
|
+
.filter((id, i) => ids.indexOf(id) !== i)
|
|
126
|
+
.map((id) => `rooms: ${id} twice`);
|
|
127
|
+
const deviceIds = (config.devices ?? []).map((d) => d.id);
|
|
128
|
+
deviceIds.forEach((id, i) => deviceIds.indexOf(id) !== i &&
|
|
129
|
+
problems.push(`devices[${i}] (${id}).id: a second device with this id`));
|
|
130
|
+
(config.devices ?? []).forEach((d, i) => d.room &&
|
|
131
|
+
!rooms.has(d.room) &&
|
|
132
|
+
problems.push(`devices[${i}] (${d.id}).room: no room ${d.room}`));
|
|
133
|
+
(config.rooms ?? []).forEach((r, i) => "marker" in r &&
|
|
134
|
+
problems.push(`rooms[${i}] (${r.id}).marker: renamed hueRoom; marker now means only an SVG marker id`));
|
|
135
|
+
return { config, problems };
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Validate a parsed config against `env`: `checkPlan`, one device per connector (Cast and printers: any number), and every env name set.
|
|
139
|
+
* Returns the problems rather than throwing, and what would be set up, secrets shown only as set or unset.
|
|
140
|
+
*/
|
|
141
|
+
export function checkHome(input, env) {
|
|
142
|
+
const { config, problems } = checkPlan(input);
|
|
143
|
+
if (!config)
|
|
144
|
+
return { setup: [], problems };
|
|
145
|
+
const secret = (field, name) => {
|
|
146
|
+
if (!env[name])
|
|
147
|
+
problems.push(`${field}: env ${name} is not set`);
|
|
148
|
+
return { [name]: env[name] ? "set" : "unset" };
|
|
149
|
+
};
|
|
150
|
+
const seen = new Map();
|
|
151
|
+
const setup = [];
|
|
152
|
+
(config.devices ?? []).forEach((d, i) => {
|
|
153
|
+
const at = `devices[${i}] (${d.id})`;
|
|
154
|
+
const twin = seen.get(d.connector);
|
|
155
|
+
if (twin && !MANY.has(d.connector))
|
|
156
|
+
problems.push(`${at}: a second ${d.connector} device (first: ${twin}); one per connector for now`);
|
|
157
|
+
seen.set(d.connector, d.id);
|
|
158
|
+
if (d.connector === "cast") {
|
|
159
|
+
const twinCast = (config.devices ?? []).findIndex((x) => x.connector === "cast" && sameId(x.castId, d.castId));
|
|
160
|
+
if (twinCast < i)
|
|
161
|
+
problems.push(`${at}.castId: the same Cast device as devices[${twinCast}] (${config.devices[twinCast].id})`);
|
|
162
|
+
}
|
|
163
|
+
if (d.connector === "printers") {
|
|
164
|
+
const twin = (config.devices ?? []).findIndex((x) => x.connector === "printers" && sameId(x.uuid, d.uuid));
|
|
165
|
+
if (twin < i)
|
|
166
|
+
problems.push(`${at}.uuid: the same printer as devices[${twin}] (${config.devices[twin].id})`);
|
|
167
|
+
}
|
|
168
|
+
const { connector, id, room, name, ...rest } = d;
|
|
169
|
+
// Only the schema's own fields: a loose entry may carry anything, a pasted secret included.
|
|
170
|
+
const known = Device.options.find((o) => o.shape.connector.value === connector).shape;
|
|
171
|
+
const settings = Object.fromEntries(Object.entries(rest).filter(([k, v]) => k in known &&
|
|
172
|
+
k !== "key" &&
|
|
173
|
+
(typeof v === "string" || typeof v === "number")));
|
|
174
|
+
const key = d.key; // any connector's `key` is an env name (the schema's envName)
|
|
175
|
+
setup.push({
|
|
176
|
+
name: connector,
|
|
177
|
+
from: id,
|
|
178
|
+
settings,
|
|
179
|
+
secrets: typeof key === "string" ? secret(`${at}.key`, key) : {},
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
if (config.location) {
|
|
183
|
+
const sources = config.weather?.sources ?? ["open-meteo"];
|
|
184
|
+
const key = config.weather?.googleKey;
|
|
185
|
+
if (sources.includes("google") && !key)
|
|
186
|
+
problems.push("weather.googleKey: needed when weather.sources has google");
|
|
187
|
+
setup.push({
|
|
188
|
+
name: "weather",
|
|
189
|
+
from: "location",
|
|
190
|
+
settings: {
|
|
191
|
+
latitude: config.location.latitude,
|
|
192
|
+
longitude: config.location.longitude,
|
|
193
|
+
sources: sources.join(", "),
|
|
194
|
+
...(config.location.bomForecast
|
|
195
|
+
? { bomForecast: config.location.bomForecast }
|
|
196
|
+
: {}),
|
|
197
|
+
},
|
|
198
|
+
secrets: sources.includes("google") && key
|
|
199
|
+
? secret("weather.googleKey", key)
|
|
200
|
+
: {},
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
else if (config.weather)
|
|
204
|
+
problems.push("weather: needs location");
|
|
205
|
+
return { config, setup, problems };
|
|
206
|
+
}
|
|
207
|
+
/** The token in a JSON token file (`home enphase login` writes `{ token, expiresAt }`); a file that is not JSON says so by name. */
|
|
208
|
+
function tokenFromJson(text, at) {
|
|
209
|
+
try {
|
|
210
|
+
return JSON.parse(text).token;
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
throw new HomeConfigError([`${at}: not JSON (${errorText(error)})`]);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/** Build the integrations a config describes, in its device order, weather last. Throws `HomeConfigError` naming every problem. */
|
|
217
|
+
export function loadHome(input, options) {
|
|
218
|
+
const { config, problems } = checkHome(input, options.env);
|
|
219
|
+
if (config && !options.readFile)
|
|
220
|
+
(config.devices ?? []).forEach((d, i) => {
|
|
221
|
+
for (const f of manifestOf(d.connector)?.files ?? []) {
|
|
222
|
+
const path = d[f];
|
|
223
|
+
if (typeof path === "string")
|
|
224
|
+
problems.push(`devices[${i}] (${d.id}).${f}: needs readFile to read ${path}`);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
const castAt = (config?.devices ?? []).findIndex((d) => d.connector === "cast");
|
|
228
|
+
const notBun = typeof globalThis.Bun === "undefined"; // Bun's fetch takes a `tls` option; elsewhere it is ignored and the read fails
|
|
229
|
+
for (const [i, d] of (config?.devices ?? []).entries())
|
|
230
|
+
if ((d.connector === "hue" || d.connector === "enphase") &&
|
|
231
|
+
!options.tls &&
|
|
232
|
+
notBun)
|
|
233
|
+
problems.push(`devices[${i}] (${d.id}): ${d.connector} needs tls (NODE_TLS from @khalido/home/node) outside Bun`);
|
|
234
|
+
if (castAt >= 0 && !options.castConnect)
|
|
235
|
+
problems.push(`devices[${castAt}] (${config.devices[castAt].id}): Cast needs castConnect (tlsConnect from @khalido/home/cast/tls)`);
|
|
236
|
+
if (castAt >= 0 && !options.finders?.cast)
|
|
237
|
+
problems.push(`devices[${castAt}] (${config.devices[castAt].id}): Cast needs finders.cast (its mDNS discovery)`);
|
|
238
|
+
if (!config || problems.length)
|
|
239
|
+
throw new HomeConfigError(problems);
|
|
240
|
+
const file = (path) => options.readFile(path);
|
|
241
|
+
const env = (name) => options.env[name];
|
|
242
|
+
const net = options.fetch ?? fetch;
|
|
243
|
+
const clock = options.clock ?? realClock;
|
|
244
|
+
const tls = options.tls ?? { hue: bunPinnedFetch, enphase: bunGatewayFetch };
|
|
245
|
+
const book = addressBook(options.cache, { configAt: options.configAt });
|
|
246
|
+
/** Where a polling connector starts: a fresh cached address, else the config's, and its write-back for each good read. */
|
|
247
|
+
const start = (connector, id, configured) => {
|
|
248
|
+
const cached = book.fresh(connector, id);
|
|
249
|
+
return {
|
|
250
|
+
host: cached?.host ?? configured,
|
|
251
|
+
hostCached: cached !== undefined,
|
|
252
|
+
seen: (at) => book.seen(connector, id, { host: at }),
|
|
253
|
+
};
|
|
254
|
+
};
|
|
255
|
+
// Every Cast entry is one integration, at the first Cast entry's place; so is every printer.
|
|
256
|
+
const printersAt = (config.devices ?? []).findIndex((d) => d.connector === "printers");
|
|
257
|
+
const integrations = (config.devices ?? []).flatMap((d, i) => d.connector === "cast"
|
|
258
|
+
? i === castAt
|
|
259
|
+
? [castAll()]
|
|
260
|
+
: []
|
|
261
|
+
: d.connector === "printers"
|
|
262
|
+
? i === printersAt
|
|
263
|
+
? [printersAll()]
|
|
264
|
+
: []
|
|
265
|
+
: [one(d, `devices[${i}] (${d.id})`)]);
|
|
266
|
+
function one(d, at) {
|
|
267
|
+
switch (d.connector) {
|
|
268
|
+
case "hue": {
|
|
269
|
+
const ca = file(d.ca);
|
|
270
|
+
return hue({
|
|
271
|
+
...start("hue", d.bridgeId, d.host),
|
|
272
|
+
bridgeId: d.bridgeId,
|
|
273
|
+
key: env(d.key),
|
|
274
|
+
ca,
|
|
275
|
+
fetch: tls.hue(d.bridgeId, ca),
|
|
276
|
+
find: options.finders?.hue,
|
|
277
|
+
clock,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
case "daikin":
|
|
281
|
+
return daikin({
|
|
282
|
+
...start("daikin", d.mac, d.host),
|
|
283
|
+
mac: d.mac,
|
|
284
|
+
find: options.finders?.daikin,
|
|
285
|
+
fetch: net,
|
|
286
|
+
clock,
|
|
287
|
+
});
|
|
288
|
+
case "enphase": {
|
|
289
|
+
const text = file(d.token).trim();
|
|
290
|
+
const token = text.startsWith("{")
|
|
291
|
+
? tokenFromJson(text, `${at}.token`)
|
|
292
|
+
: text;
|
|
293
|
+
if (!token)
|
|
294
|
+
throw new HomeConfigError([`${at}.token: no token in ${d.token}`]);
|
|
295
|
+
return enphase({
|
|
296
|
+
...start("enphase", d.serial, d.host),
|
|
297
|
+
token,
|
|
298
|
+
serial: d.serial,
|
|
299
|
+
find: options.finders?.enphase,
|
|
300
|
+
fetch: tls.enphase(),
|
|
301
|
+
timeZone: config.location?.timezone,
|
|
302
|
+
clock,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
case "amber":
|
|
306
|
+
return amber({ key: env(d.key), siteId: d.siteId, fetch: net, clock });
|
|
307
|
+
case "up":
|
|
308
|
+
return up({
|
|
309
|
+
token: env(d.key),
|
|
310
|
+
account: d.account,
|
|
311
|
+
household: d.household,
|
|
312
|
+
fetch: net,
|
|
313
|
+
timeZone: config.location?.timezone,
|
|
314
|
+
clock,
|
|
315
|
+
});
|
|
316
|
+
case "spotify":
|
|
317
|
+
return spotify({
|
|
318
|
+
url: d.url,
|
|
319
|
+
maxVolume: d.maxVolume,
|
|
320
|
+
repairStalls: d.repairStalls,
|
|
321
|
+
clock,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
function castAll() {
|
|
326
|
+
const expect = config.devices.flatMap((x) => {
|
|
327
|
+
if (x.connector !== "cast")
|
|
328
|
+
return [];
|
|
329
|
+
const c = book.fresh("cast", x.castId);
|
|
330
|
+
return [
|
|
331
|
+
{
|
|
332
|
+
castId: x.castId,
|
|
333
|
+
name: x.name,
|
|
334
|
+
...(c?.port === undefined ? {} : { host: c.host, port: c.port }),
|
|
335
|
+
},
|
|
336
|
+
];
|
|
337
|
+
});
|
|
338
|
+
// Two passes, as every discovery runs, but done once every entry in the config has answered.
|
|
339
|
+
const all = (found) => expect.every((e) => found.some((f) => sameId(f.id, e.castId)));
|
|
340
|
+
const resolve = async () => resolvedFrom(await discover(options.finders.cast, { clock, done: all }));
|
|
341
|
+
return cast({
|
|
342
|
+
resolve,
|
|
343
|
+
connect: options.castConnect,
|
|
344
|
+
expect,
|
|
345
|
+
serve: options.castServe,
|
|
346
|
+
clock,
|
|
347
|
+
seen: (id, a) => book.seen("cast", id, a),
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
function printersAll() {
|
|
351
|
+
const entries = config.devices.flatMap((x) => {
|
|
352
|
+
if (x.connector !== "printers")
|
|
353
|
+
return [];
|
|
354
|
+
const c = book.fresh("printers", x.uuid);
|
|
355
|
+
return [
|
|
356
|
+
{
|
|
357
|
+
uuid: x.uuid,
|
|
358
|
+
name: x.name,
|
|
359
|
+
host: c?.host ?? x.host,
|
|
360
|
+
port: c?.port ?? x.port,
|
|
361
|
+
room: x.room,
|
|
362
|
+
offlineDays: x.offlineDays,
|
|
363
|
+
},
|
|
364
|
+
];
|
|
365
|
+
});
|
|
366
|
+
return printers({
|
|
367
|
+
printers: entries,
|
|
368
|
+
fetch: net,
|
|
369
|
+
find: options.finders?.printers,
|
|
370
|
+
clock,
|
|
371
|
+
seen: (uuid, a) => book.seen("printers", uuid, a),
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
if (config.location) {
|
|
375
|
+
const { latitude, longitude, bomForecast } = config.location;
|
|
376
|
+
const make = {
|
|
377
|
+
bom: () => bom({ fetch: options.ftpFetch, forecastLocation: bomForecast }),
|
|
378
|
+
"open-meteo": () => openMeteo({ fetch: net }),
|
|
379
|
+
google: () => google({ key: env(config.weather.googleKey), fetch: net }),
|
|
380
|
+
};
|
|
381
|
+
const sources = config.weather?.sources?.map((s) => make[s]());
|
|
382
|
+
integrations.push(weather({
|
|
383
|
+
latitude,
|
|
384
|
+
longitude,
|
|
385
|
+
sources,
|
|
386
|
+
pollMinutes: config.weather?.pollMinutes,
|
|
387
|
+
timeZone: config.location.timezone,
|
|
388
|
+
clock,
|
|
389
|
+
}));
|
|
390
|
+
}
|
|
391
|
+
return integrations;
|
|
392
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
/** No device or service call waits longer than this. */
|
|
3
|
+
export declare const TIMEOUT_MS = 10000;
|
|
4
|
+
/**
|
|
5
|
+
* An error as a detail line: its message ("fetch failed", not "TypeError: fetch failed"), and its cause's when it has one.
|
|
6
|
+
* Node's fetch puts the reason there: "fetch failed: connect ECONNREFUSED 192.0.2.10:443".
|
|
7
|
+
*/
|
|
8
|
+
export declare function errorText(error: unknown): string;
|
|
9
|
+
/** A failed device or service call. `status` is the HTTP status (or a device's own code); `code` a connector's own reason. */
|
|
10
|
+
export declare class ConnectorError extends Error {
|
|
11
|
+
readonly status?: number;
|
|
12
|
+
readonly code?: string;
|
|
13
|
+
constructor(message: string, options?: {
|
|
14
|
+
status?: number;
|
|
15
|
+
code?: string;
|
|
16
|
+
cause?: unknown;
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
/** `fetch` that gives up after `timeoutMs`, or as soon as `signal` (a run's) aborts. Returns the response whatever its status. */
|
|
20
|
+
export declare function fetchTimeout(fetcher: typeof fetch, url: string | URL, init?: Omit<RequestInit, "signal">, options?: {
|
|
21
|
+
timeoutMs?: number;
|
|
22
|
+
signal?: AbortSignal;
|
|
23
|
+
}): Promise<Response>;
|
|
24
|
+
/**
|
|
25
|
+
* GET (or `init.method`) JSON through `fetchTimeout`. A non-ok status throws `error` (ConnectorError by default) with the
|
|
26
|
+
* status, as "HTTP 500 on GET /path" (no query: it can carry a key). With a `schema` the body is parsed by it.
|
|
27
|
+
*/
|
|
28
|
+
export declare function getJson<T = unknown>(fetcher: typeof fetch, url: string | URL, options?: {
|
|
29
|
+
init?: Omit<RequestInit, "signal">;
|
|
30
|
+
timeoutMs?: number;
|
|
31
|
+
signal?: AbortSignal;
|
|
32
|
+
schema?: z.ZodType<T>;
|
|
33
|
+
error?: typeof ConnectorError;
|
|
34
|
+
}): Promise<T>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// What every connector's client shares: one timeout, one error, one fetch that a run's abort can cancel.
|
|
2
|
+
/** No device or service call waits longer than this. */
|
|
3
|
+
export const TIMEOUT_MS = 10_000;
|
|
4
|
+
/**
|
|
5
|
+
* An error as a detail line: its message ("fetch failed", not "TypeError: fetch failed"), and its cause's when it has one.
|
|
6
|
+
* Node's fetch puts the reason there: "fetch failed: connect ECONNREFUSED 192.0.2.10:443".
|
|
7
|
+
*/
|
|
8
|
+
export function errorText(error) {
|
|
9
|
+
if (!(error instanceof Error))
|
|
10
|
+
return String(error);
|
|
11
|
+
const cause = error.cause instanceof Error ? error.cause.message : undefined;
|
|
12
|
+
return cause && cause !== error.message
|
|
13
|
+
? `${error.message}: ${cause}`
|
|
14
|
+
: error.message;
|
|
15
|
+
}
|
|
16
|
+
/** A failed device or service call. `status` is the HTTP status (or a device's own code); `code` a connector's own reason. */
|
|
17
|
+
export class ConnectorError extends Error {
|
|
18
|
+
status;
|
|
19
|
+
code;
|
|
20
|
+
constructor(message, options = {}) {
|
|
21
|
+
super(message, options.cause === undefined ? undefined : { cause: options.cause });
|
|
22
|
+
this.name = new.target.name; // a subclass (HueError, AmberError) names itself in logs; a detail shows only the message
|
|
23
|
+
this.status = options.status;
|
|
24
|
+
this.code = options.code;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** `fetch` that gives up after `timeoutMs`, or as soon as `signal` (a run's) aborts. Returns the response whatever its status. */
|
|
28
|
+
export function fetchTimeout(fetcher, url, init = {}, options = {}) {
|
|
29
|
+
const timeout = AbortSignal.timeout(options.timeoutMs ?? TIMEOUT_MS);
|
|
30
|
+
return fetcher(url, {
|
|
31
|
+
...init,
|
|
32
|
+
signal: options.signal
|
|
33
|
+
? AbortSignal.any([timeout, options.signal])
|
|
34
|
+
: timeout,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* GET (or `init.method`) JSON through `fetchTimeout`. A non-ok status throws `error` (ConnectorError by default) with the
|
|
39
|
+
* status, as "HTTP 500 on GET /path" (no query: it can carry a key). With a `schema` the body is parsed by it.
|
|
40
|
+
*/
|
|
41
|
+
export async function getJson(fetcher, url, options = {}) {
|
|
42
|
+
const res = await fetchTimeout(fetcher, url, options.init, options);
|
|
43
|
+
if (!res.ok)
|
|
44
|
+
throw new (options.error ?? ConnectorError)(`HTTP ${res.status} on ${options.init?.method ?? "GET"} ${new URL(url).pathname}`, { status: res.status });
|
|
45
|
+
const body = await res.json();
|
|
46
|
+
return options.schema ? options.schema.parse(body) : body;
|
|
47
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ConnectorError } from "../../connector.ts";
|
|
3
|
+
/** A failed Amber call. `status` 401 means the key is wrong or revoked; 429 means the rate limit (50 per 5 min). */
|
|
4
|
+
export declare class AmberError extends ConnectorError {
|
|
5
|
+
}
|
|
6
|
+
/** One 5-minute price interval on one channel. Prices are c/kWh including GST, as a cost: negative feed-in means you are paid. */
|
|
7
|
+
export declare const Interval: z.ZodObject<{
|
|
8
|
+
type: z.ZodString;
|
|
9
|
+
channelType: z.ZodString;
|
|
10
|
+
perKwh: z.ZodNumber;
|
|
11
|
+
spotPerKwh: z.ZodOptional<z.ZodNumber>;
|
|
12
|
+
renewables: z.ZodOptional<z.ZodNumber>;
|
|
13
|
+
descriptor: z.ZodOptional<z.ZodString>;
|
|
14
|
+
spikeStatus: z.ZodOptional<z.ZodString>;
|
|
15
|
+
startTime: z.ZodOptional<z.ZodString>;
|
|
16
|
+
endTime: z.ZodOptional<z.ZodString>;
|
|
17
|
+
estimate: z.ZodOptional<z.ZodBoolean>;
|
|
18
|
+
duration: z.ZodOptional<z.ZodNumber>;
|
|
19
|
+
}, z.core.$loose>;
|
|
20
|
+
export type Interval = z.infer<typeof Interval>;
|
|
21
|
+
/** Prices now, as a home view wants them: what a kWh costs to import and earns to export. */
|
|
22
|
+
export type AmberState = {
|
|
23
|
+
importCents?: number;
|
|
24
|
+
exportCents?: number;
|
|
25
|
+
priceDescriptor?: string;
|
|
26
|
+
exportDescriptor?: string;
|
|
27
|
+
spike?: string;
|
|
28
|
+
renewables?: number;
|
|
29
|
+
estimate?: boolean;
|
|
30
|
+
nextImportCents?: number;
|
|
31
|
+
intervalEnds?: string;
|
|
32
|
+
anomaly?: string;
|
|
33
|
+
forecast?: PriceInterval[];
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* One interval of the forecast, both channels. `start` is the interval's own start: Amber's `startTime` is a second past
|
|
37
|
+
* it (07:15:01 for 07:15–07:20), so it is taken to the minute. `exportCents` is what you are paid, as in `AmberState`.
|
|
38
|
+
*/
|
|
39
|
+
export type PriceInterval = {
|
|
40
|
+
start: string;
|
|
41
|
+
end: string;
|
|
42
|
+
importCents?: number;
|
|
43
|
+
exportCents?: number;
|
|
44
|
+
renewables?: number;
|
|
45
|
+
descriptor?: string;
|
|
46
|
+
};
|
|
47
|
+
/** How many intervals to ask for: more than Amber forecasts (48 half-hours), so the reply is as far as Amber has one. */
|
|
48
|
+
export declare const NEXT = 96;
|
|
49
|
+
/** Map `prices/current?next=…` to the state: the CurrentInterval of each channel, the next import forecast, and the forecast. */
|
|
50
|
+
export declare function toPrices(intervals: unknown): AmberState;
|
|
51
|
+
/** A read-only client for Amber's API. The key comes in; nothing is stored. */
|
|
52
|
+
export declare function amberClient(options: {
|
|
53
|
+
key: string;
|
|
54
|
+
fetch: typeof fetch;
|
|
55
|
+
}): {
|
|
56
|
+
get: (path: string, signal?: AbortSignal) => Promise<unknown>;
|
|
57
|
+
/** The account's active site id (the first active one). */
|
|
58
|
+
siteId(signal?: AbortSignal): Promise<string>;
|
|
59
|
+
/** Prices now and the forecast for a site: one call, ~270 ms (measured with `next=1`, Bun 1.4.2). */
|
|
60
|
+
prices(siteId: string, signal?: AbortSignal): Promise<AmberState>;
|
|
61
|
+
};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ConnectorError, getJson } from "../../connector.js";
|
|
3
|
+
// Amber Electric's public API v1 (https://app.amber.com.au/developers; the published spec is 1.0, there is no v2). Shapes checked against live replies,
|
|
4
|
+
// 2026-10-13; every field optional so a change degrades a reading rather than throwing.
|
|
5
|
+
/** A failed Amber call. `status` 401 means the key is wrong or revoked; 429 means the rate limit (50 per 5 min). */
|
|
6
|
+
export class AmberError extends ConnectorError {
|
|
7
|
+
}
|
|
8
|
+
/** One 5-minute price interval on one channel. Prices are c/kWh including GST, as a cost: negative feed-in means you are paid. */
|
|
9
|
+
export const Interval = z.looseObject({
|
|
10
|
+
type: z.string(), // "ActualInterval" | "CurrentInterval" | "ForecastInterval"
|
|
11
|
+
channelType: z.string(), // "general" (import) | "feedIn" (export) | "controlledLoad"
|
|
12
|
+
perKwh: z.number(),
|
|
13
|
+
spotPerKwh: z.number().optional(),
|
|
14
|
+
renewables: z.number().optional(), // percent of the grid
|
|
15
|
+
descriptor: z.string().optional(), // "extremelyLow" … "spike"
|
|
16
|
+
spikeStatus: z.string().optional(), // "none" | "potential" | "spike"
|
|
17
|
+
startTime: z.string().optional(),
|
|
18
|
+
endTime: z.string().optional(),
|
|
19
|
+
estimate: z.boolean().optional(), // CurrentInterval: true until the interval's price is final
|
|
20
|
+
duration: z.number().optional(), // minutes: 5 for the current hour, 30 further out
|
|
21
|
+
});
|
|
22
|
+
const Site = z.looseObject({
|
|
23
|
+
id: z.string(),
|
|
24
|
+
status: z.string().optional(),
|
|
25
|
+
intervalLength: z.number().optional(),
|
|
26
|
+
});
|
|
27
|
+
/** How many intervals to ask for: more than Amber forecasts (48 half-hours), so the reply is as far as Amber has one. */
|
|
28
|
+
export const NEXT = 96;
|
|
29
|
+
/** Map `prices/current?next=…` to the state: the CurrentInterval of each channel, the next import forecast, and the forecast. */
|
|
30
|
+
export function toPrices(intervals) {
|
|
31
|
+
const list = z.array(Interval).parse(intervals);
|
|
32
|
+
const pick = (type, channel) => list.find((i) => i.type === type && i.channelType === channel);
|
|
33
|
+
const buy = pick("CurrentInterval", "general");
|
|
34
|
+
const sell = pick("CurrentInterval", "feedIn");
|
|
35
|
+
return {
|
|
36
|
+
importCents: buy?.perKwh,
|
|
37
|
+
exportCents: sell === undefined ? undefined : -sell.perKwh,
|
|
38
|
+
priceDescriptor: buy?.descriptor,
|
|
39
|
+
exportDescriptor: sell?.descriptor,
|
|
40
|
+
spike: buy?.spikeStatus,
|
|
41
|
+
renewables: buy?.renewables,
|
|
42
|
+
estimate: buy?.estimate,
|
|
43
|
+
nextImportCents: pick("ForecastInterval", "general")?.perKwh,
|
|
44
|
+
intervalEnds: buy?.endTime,
|
|
45
|
+
...forecastOf(list),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const MINUTE = 60_000;
|
|
49
|
+
/**
|
|
50
|
+
* The current and forecast intervals, in time order, each channel's price on one row; none when Amber sent none. An interval
|
|
51
|
+
* whose times do not parse is left out and counted in `anomaly`, so one odd row never fails the prices.
|
|
52
|
+
*/
|
|
53
|
+
function forecastOf(list) {
|
|
54
|
+
const timed = list.filter((i) => i.type === "CurrentInterval" || i.type === "ForecastInterval");
|
|
55
|
+
const ahead = timed.filter((i) => Number.isFinite(Date.parse(i.startTime ?? "")) &&
|
|
56
|
+
Number.isFinite(Date.parse(i.endTime ?? "")));
|
|
57
|
+
const odd = timed.length - ahead.length;
|
|
58
|
+
const feedIn = new Map(ahead.filter((i) => i.channelType === "feedIn").map((i) => [i.endTime, i]));
|
|
59
|
+
const rows = ahead
|
|
60
|
+
.filter((i) => i.channelType === "general")
|
|
61
|
+
.map((i) => {
|
|
62
|
+
const sell = feedIn.get(i.endTime);
|
|
63
|
+
return {
|
|
64
|
+
start: new Date(Math.floor(Date.parse(i.startTime) / MINUTE) * MINUTE).toISOString(),
|
|
65
|
+
end: new Date(Date.parse(i.endTime)).toISOString(),
|
|
66
|
+
importCents: i.perKwh,
|
|
67
|
+
exportCents: sell === undefined ? undefined : -sell.perKwh,
|
|
68
|
+
renewables: i.renewables,
|
|
69
|
+
descriptor: i.descriptor,
|
|
70
|
+
};
|
|
71
|
+
})
|
|
72
|
+
.sort((a, b) => a.start.localeCompare(b.start));
|
|
73
|
+
return {
|
|
74
|
+
forecast: rows.length ? rows : undefined,
|
|
75
|
+
anomaly: odd
|
|
76
|
+
? `${odd} price row(s) left out: their start or end time could not be read`
|
|
77
|
+
: undefined,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/** A read-only client for Amber's API. The key comes in; nothing is stored. */
|
|
81
|
+
export function amberClient(options) {
|
|
82
|
+
const headers = {
|
|
83
|
+
authorization: `Bearer ${options.key}`,
|
|
84
|
+
accept: "application/json",
|
|
85
|
+
};
|
|
86
|
+
const get = (path, signal) => getJson(options.fetch, `https://api.amber.com.au/v1${path}`, {
|
|
87
|
+
init: { headers },
|
|
88
|
+
signal,
|
|
89
|
+
error: AmberError,
|
|
90
|
+
});
|
|
91
|
+
return {
|
|
92
|
+
get,
|
|
93
|
+
/** The account's active site id (the first active one). */
|
|
94
|
+
async siteId(signal) {
|
|
95
|
+
const sites = z.array(Site).parse(await get("/sites", signal));
|
|
96
|
+
const site = sites.find((s) => s.status === "active") ?? sites[0];
|
|
97
|
+
if (!site)
|
|
98
|
+
throw new AmberError("no Amber site on this account");
|
|
99
|
+
return site.id;
|
|
100
|
+
},
|
|
101
|
+
/** Prices now and the forecast for a site: one call, ~270 ms (measured with `next=1`, Bun 1.4.2). */
|
|
102
|
+
async prices(siteId, signal) {
|
|
103
|
+
return toPrices(await get(`/sites/${siteId}/prices/current?next=${NEXT}`, signal));
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type Clock } from "../../clock.ts";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { type Integration } from "../../home.ts";
|
|
4
|
+
import { type Manifest } from "../../manifest.ts";
|
|
5
|
+
import { type AmberState } from "./client.ts";
|
|
6
|
+
export { AmberError, type AmberState, amberClient, type Interval, type PriceInterval, } from "./client.ts";
|
|
7
|
+
/** Poll every 60 s: prices move in 5-minute intervals and the current one is revised inside it; 5 calls in 5 min, limit 50. */
|
|
8
|
+
export declare const POLL_MS = 60000;
|
|
9
|
+
/** Amber in a home config: the key's env name, and the site when the account has more than one. */
|
|
10
|
+
export declare const configSchema: z.ZodObject<{
|
|
11
|
+
connector: z.ZodLiteral<"amber">;
|
|
12
|
+
key: z.ZodString;
|
|
13
|
+
siteId: z.ZodOptional<z.ZodString>;
|
|
14
|
+
}, z.core.$loose>;
|
|
15
|
+
/** Amber settings for a home. `siteId` is looked up once when left out. */
|
|
16
|
+
export type AmberConfig = {
|
|
17
|
+
key: string;
|
|
18
|
+
siteId?: string;
|
|
19
|
+
fetch: typeof fetch;
|
|
20
|
+
clock?: Clock;
|
|
21
|
+
};
|
|
22
|
+
/** What the snapshot holds: prices now and when they were read. */
|
|
23
|
+
export type AmberSnapshot = AmberState & {
|
|
24
|
+
asOf: string;
|
|
25
|
+
};
|
|
26
|
+
/** Amber prices as a home integration: one read now, then every 60 s. */
|
|
27
|
+
export declare function amber(config: AmberConfig): Integration<AmberSnapshot, "amber">;
|
|
28
|
+
/** Amber's prices in a few words: "buy 40.9c low (estimate) · sell 35.3c · 7% renewables", with a spike and a negative price when there is one. */
|
|
29
|
+
export declare function amberText(a: AmberState): string;
|
|
30
|
+
export declare const manifest: Manifest<AmberSnapshot>;
|