@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.
Files changed (217) hide show
  1. package/.env.example +25 -0
  2. package/CHANGELOG.md +47 -0
  3. package/LICENSE +21 -0
  4. package/README.md +77 -2
  5. package/dist/clock.d.ts +17 -0
  6. package/dist/clock.js +43 -0
  7. package/dist/config.d.ts +273 -0
  8. package/dist/config.js +392 -0
  9. package/dist/connector.d.ts +34 -0
  10. package/dist/connector.js +47 -0
  11. package/dist/connectors/amber/client.d.ts +61 -0
  12. package/dist/connectors/amber/client.js +106 -0
  13. package/dist/connectors/amber/index.d.ts +30 -0
  14. package/dist/connectors/amber/index.js +94 -0
  15. package/dist/connectors/cast/codec.d.ts +38 -0
  16. package/dist/connectors/cast/codec.js +102 -0
  17. package/dist/connectors/cast/index.d.ts +60 -0
  18. package/dist/connectors/cast/index.js +428 -0
  19. package/dist/connectors/cast/schemas.d.ts +167 -0
  20. package/dist/connectors/cast/schemas.js +87 -0
  21. package/dist/connectors/cast/serve.d.ts +18 -0
  22. package/dist/connectors/cast/serve.js +57 -0
  23. package/dist/connectors/cast/state.d.ts +62 -0
  24. package/dist/connectors/cast/state.js +133 -0
  25. package/dist/connectors/cast/tls.d.ts +3 -0
  26. package/dist/connectors/cast/tls.js +67 -0
  27. package/dist/connectors/cast/writes.d.ts +80 -0
  28. package/dist/connectors/cast/writes.js +390 -0
  29. package/dist/connectors/daikin/client.d.ts +65 -0
  30. package/dist/connectors/daikin/client.js +221 -0
  31. package/dist/connectors/daikin/index.d.ts +41 -0
  32. package/dist/connectors/daikin/index.js +108 -0
  33. package/dist/connectors/enphase/client.d.ts +65 -0
  34. package/dist/connectors/enphase/client.js +173 -0
  35. package/dist/connectors/enphase/index.d.ts +43 -0
  36. package/dist/connectors/enphase/index.js +174 -0
  37. package/dist/connectors/enphase/schemas.d.ts +51 -0
  38. package/dist/connectors/enphase/schemas.js +34 -0
  39. package/dist/connectors/hue/client.d.ts +48 -0
  40. package/dist/connectors/hue/client.js +95 -0
  41. package/dist/connectors/hue/events.d.ts +41 -0
  42. package/dist/connectors/hue/events.js +156 -0
  43. package/dist/connectors/hue/index.d.ts +42 -0
  44. package/dist/connectors/hue/index.js +205 -0
  45. package/dist/connectors/hue/schemas.d.ts +65 -0
  46. package/dist/connectors/hue/schemas.js +48 -0
  47. package/dist/connectors/hue/state.d.ts +43 -0
  48. package/dist/connectors/hue/state.js +84 -0
  49. package/dist/connectors/printers/client.d.ts +28 -0
  50. package/dist/connectors/printers/client.js +74 -0
  51. package/dist/connectors/printers/codec.d.ts +76 -0
  52. package/dist/connectors/printers/codec.js +246 -0
  53. package/dist/connectors/printers/found.d.ts +15 -0
  54. package/dist/connectors/printers/found.js +35 -0
  55. package/dist/connectors/printers/index.d.ts +52 -0
  56. package/dist/connectors/printers/index.js +282 -0
  57. package/dist/connectors/printers/state.d.ts +60 -0
  58. package/dist/connectors/printers/state.js +99 -0
  59. package/dist/connectors/spotify/changes.d.ts +32 -0
  60. package/dist/connectors/spotify/changes.js +84 -0
  61. package/dist/connectors/spotify/client.d.ts +199 -0
  62. package/dist/connectors/spotify/client.js +270 -0
  63. package/dist/connectors/spotify/index.d.ts +92 -0
  64. package/dist/connectors/spotify/index.js +754 -0
  65. package/dist/connectors/up/client.d.ts +183 -0
  66. package/dist/connectors/up/client.js +308 -0
  67. package/dist/connectors/up/index.d.ts +91 -0
  68. package/dist/connectors/up/index.js +213 -0
  69. package/dist/connectors/up/spend.d.ts +78 -0
  70. package/dist/connectors/up/spend.js +213 -0
  71. package/dist/connectors/up/webhook.d.ts +13 -0
  72. package/dist/connectors/up/webhook.js +32 -0
  73. package/dist/connectors/weather/blocks.d.ts +51 -0
  74. package/dist/connectors/weather/blocks.js +106 -0
  75. package/dist/connectors/weather/bom.d.ts +37 -0
  76. package/dist/connectors/weather/bom.js +237 -0
  77. package/dist/connectors/weather/ftp.d.ts +5 -0
  78. package/dist/connectors/weather/ftp.js +46 -0
  79. package/dist/connectors/weather/google.d.ts +31 -0
  80. package/dist/connectors/weather/google.js +364 -0
  81. package/dist/connectors/weather/index.d.ts +53 -0
  82. package/dist/connectors/weather/index.js +280 -0
  83. package/dist/connectors/weather/open-meteo.d.ts +15 -0
  84. package/dist/connectors/weather/open-meteo.js +285 -0
  85. package/dist/connectors/weather/rain.d.ts +19 -0
  86. package/dist/connectors/weather/rain.js +51 -0
  87. package/dist/connectors/weather/source.d.ts +149 -0
  88. package/dist/connectors/weather/source.js +77 -0
  89. package/dist/connectors/weather/sun.d.ts +48 -0
  90. package/dist/connectors/weather/sun.js +65 -0
  91. package/dist/discover.d.ts +197 -0
  92. package/dist/discover.js +452 -0
  93. package/dist/energy.d.ts +61 -0
  94. package/dist/energy.js +162 -0
  95. package/dist/finders-entry.d.ts +1 -0
  96. package/dist/finders-entry.js +2 -0
  97. package/dist/finders.d.ts +48 -0
  98. package/dist/finders.js +176 -0
  99. package/dist/health.d.ts +28 -0
  100. package/dist/health.js +35 -0
  101. package/dist/home.d.ts +81 -0
  102. package/dist/home.js +114 -0
  103. package/dist/index.d.ts +11 -0
  104. package/dist/index.js +11 -0
  105. package/dist/lane.d.ts +17 -0
  106. package/dist/lane.js +74 -0
  107. package/dist/locate.d.ts +27 -0
  108. package/dist/locate.js +59 -0
  109. package/dist/manifest.d.ts +31 -0
  110. package/dist/manifest.js +4 -0
  111. package/dist/manifests.d.ts +5 -0
  112. package/dist/manifests.js +23 -0
  113. package/dist/node-fetch.d.ts +9 -0
  114. package/dist/node-fetch.js +25 -0
  115. package/dist/poll.d.ts +10 -0
  116. package/dist/poll.js +33 -0
  117. package/docs/cast.md +133 -0
  118. package/docs/cli.md +41 -0
  119. package/docs/config.md +36 -0
  120. package/docs/decisions.md +150 -0
  121. package/docs/devices.md +128 -0
  122. package/docs/enphase-notes.md +24 -0
  123. package/docs/google.md +92 -0
  124. package/docs/ideas.md +69 -0
  125. package/docs/matter.md +55 -0
  126. package/docs/performance.md +12 -0
  127. package/docs/philosophy.md +23 -0
  128. package/docs/up.md +84 -0
  129. package/docs/using.md +65 -0
  130. package/docs/weather-probe.md +63 -0
  131. package/examples/home.jsonc +74 -0
  132. package/examples/home.svg +76 -0
  133. package/home.schema.json +474 -0
  134. package/llms.txt +32 -0
  135. package/package.json +143 -4
  136. package/skills/draw-home/README.md +25 -0
  137. package/skills/draw-home/SKILL.md +80 -0
  138. package/skills/use-home/SKILL.md +86 -0
  139. package/src/cli/cache.ts +29 -0
  140. package/src/cli/cast.ts +172 -0
  141. package/src/cli/config.ts +52 -0
  142. package/src/cli/context.ts +118 -0
  143. package/src/cli/discover.ts +182 -0
  144. package/src/cli/enphase.ts +62 -0
  145. package/src/cli/hue.ts +124 -0
  146. package/src/cli/locate.ts +47 -0
  147. package/src/cli/main.ts +182 -0
  148. package/src/cli/printers.ts +21 -0
  149. package/src/cli/probe.ts +274 -0
  150. package/src/cli/spotify.ts +76 -0
  151. package/src/cli/status.ts +228 -0
  152. package/src/cli/up.ts +350 -0
  153. package/src/clock.ts +62 -0
  154. package/src/config.ts +578 -0
  155. package/src/connector.ts +77 -0
  156. package/src/connectors/amber/README.md +40 -0
  157. package/src/connectors/amber/client.ts +157 -0
  158. package/src/connectors/amber/index.ts +118 -0
  159. package/src/connectors/cast/README.md +44 -0
  160. package/src/connectors/cast/codec.ts +126 -0
  161. package/src/connectors/cast/index.ts +552 -0
  162. package/src/connectors/cast/schemas.ts +101 -0
  163. package/src/connectors/cast/serve.ts +82 -0
  164. package/src/connectors/cast/state.ts +203 -0
  165. package/src/connectors/cast/tls.ts +100 -0
  166. package/src/connectors/cast/writes.ts +572 -0
  167. package/src/connectors/daikin/README.md +78 -0
  168. package/src/connectors/daikin/client.ts +295 -0
  169. package/src/connectors/daikin/index.ts +145 -0
  170. package/src/connectors/enphase/README.md +64 -0
  171. package/src/connectors/enphase/client.ts +244 -0
  172. package/src/connectors/enphase/index.ts +241 -0
  173. package/src/connectors/enphase/schemas.ts +45 -0
  174. package/src/connectors/hue/README.md +50 -0
  175. package/src/connectors/hue/client.ts +157 -0
  176. package/src/connectors/hue/events.ts +182 -0
  177. package/src/connectors/hue/index.ts +261 -0
  178. package/src/connectors/hue/schemas.ts +60 -0
  179. package/src/connectors/hue/state.ts +124 -0
  180. package/src/connectors/printers/README.md +46 -0
  181. package/src/connectors/printers/client.ts +114 -0
  182. package/src/connectors/printers/codec.ts +292 -0
  183. package/src/connectors/printers/found.ts +46 -0
  184. package/src/connectors/printers/index.ts +385 -0
  185. package/src/connectors/printers/state.ts +182 -0
  186. package/src/connectors/spotify/README.md +83 -0
  187. package/src/connectors/spotify/changes.ts +127 -0
  188. package/src/connectors/spotify/client.ts +387 -0
  189. package/src/connectors/spotify/index.ts +937 -0
  190. package/src/connectors/up/README.md +35 -0
  191. package/src/connectors/up/client.ts +518 -0
  192. package/src/connectors/up/index.ts +370 -0
  193. package/src/connectors/up/spend.ts +280 -0
  194. package/src/connectors/up/webhook.ts +62 -0
  195. package/src/connectors/weather/README.md +84 -0
  196. package/src/connectors/weather/blocks.ts +173 -0
  197. package/src/connectors/weather/bom.ts +344 -0
  198. package/src/connectors/weather/ftp.ts +48 -0
  199. package/src/connectors/weather/google.ts +425 -0
  200. package/src/connectors/weather/index.ts +396 -0
  201. package/src/connectors/weather/open-meteo.ts +309 -0
  202. package/src/connectors/weather/rain.ts +73 -0
  203. package/src/connectors/weather/source.ts +236 -0
  204. package/src/connectors/weather/sun.ts +114 -0
  205. package/src/discover.ts +638 -0
  206. package/src/energy.ts +221 -0
  207. package/src/finders-entry.ts +2 -0
  208. package/src/finders.ts +250 -0
  209. package/src/health.ts +74 -0
  210. package/src/home.ts +205 -0
  211. package/src/index.ts +62 -0
  212. package/src/lane.ts +110 -0
  213. package/src/locate.ts +85 -0
  214. package/src/manifest.ts +43 -0
  215. package/src/manifests.ts +27 -0
  216. package/src/node-fetch.ts +41 -0
  217. package/src/poll.ts +33 -0
package/src/config.ts ADDED
@@ -0,0 +1,578 @@
1
+ import { type ParseError, parse, printParseErrorCode } from "jsonc-parser";
2
+ import { z } from "zod";
3
+ import { type Clock, isTimeZone, realClock } from "./clock.ts";
4
+ import { errorText } from "./connector.ts";
5
+ import {
6
+ amber,
7
+ configSchema as amberDevice,
8
+ } from "./connectors/amber/index.ts";
9
+ import {
10
+ daikin,
11
+ configSchema as daikinDevice,
12
+ } from "./connectors/daikin/index.ts";
13
+ import {
14
+ cast,
15
+ type CastConnect,
16
+ configSchema as castDevice,
17
+ resolvedFrom,
18
+ type ServeClip,
19
+ } from "./connectors/cast/index.ts";
20
+ import {
21
+ bunGatewayFetch,
22
+ enphase,
23
+ configSchema as enphaseDevice,
24
+ } from "./connectors/enphase/index.ts";
25
+ import {
26
+ type AddressCache,
27
+ addressBook,
28
+ type Connector,
29
+ discover,
30
+ type Finder,
31
+ type Found,
32
+ MANY,
33
+ sameId,
34
+ } from "./discover.ts";
35
+ import { envName, filePath } from "./home.ts";
36
+ import {
37
+ bunPinnedFetch,
38
+ hue,
39
+ configSchema as hueDevice,
40
+ } from "./connectors/hue/index.ts";
41
+ import {
42
+ printers,
43
+ configSchema as printersDevice,
44
+ } from "./connectors/printers/index.ts";
45
+ import {
46
+ spotify,
47
+ configSchema as spotifyDevice,
48
+ } from "./connectors/spotify/index.ts";
49
+ import { up, configSchema as upDevice } from "./connectors/up/index.ts";
50
+ import { manifestOf } from "./manifests.ts";
51
+ import {
52
+ bom,
53
+ google,
54
+ openMeteo,
55
+ weather,
56
+ type WeatherSource,
57
+ } from "./connectors/weather/index.ts";
58
+
59
+ // One home config (JSONC) that the CLI, jaldi and the bot all read. Devices hold only what it takes to reach them and
60
+ // where they live; what a device reports about itself comes live. Secrets are env names; files are paths the caller reads.
61
+
62
+ /** Where a device or marker sits: `id` matches `<g class="room" id="…">` in the plan's SVG. */
63
+ export const Room = z.looseObject({
64
+ id: z.string().startsWith("room-"),
65
+ name: z.string(),
66
+ floor: z.string().optional(),
67
+ hueRoom: z
68
+ .string()
69
+ .optional()
70
+ .describe(
71
+ "the Hue room, zone or grouped_light id whose lights this room's marker shows",
72
+ ),
73
+ });
74
+
75
+ /** 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. */
76
+ export function parseJsonc(text: string): unknown {
77
+ const errors: ParseError[] = [];
78
+ const value = parse(text, errors, { allowTrailingComma: true });
79
+ if (errors.length)
80
+ throw new SyntaxError(
81
+ `JSONC: ${printParseErrorCode(errors[0]!.error)} at offset ${errors[0]!.offset}`,
82
+ );
83
+ return value;
84
+ }
85
+
86
+ /** Every device: an id, the room it is in (absent: the whole house), a name if the id is not enough. */
87
+ const placed = {
88
+ id: z.string(),
89
+ room: z.string().startsWith("room-").optional(),
90
+ name: z.string().optional(),
91
+ };
92
+
93
+ /** A device is one connector's settings; a new connector adds its `configSchema` here, in one line. */
94
+ export const Device = z.discriminatedUnion("connector", [
95
+ hueDevice.extend(placed),
96
+ daikinDevice.extend(placed),
97
+ enphaseDevice.extend(placed),
98
+ amberDevice.extend(placed),
99
+ upDevice.extend(placed),
100
+ castDevice.extend({ id: placed.id, room: placed.room }), // its name is required: the Google Home name
101
+ printersDevice.extend({ id: placed.id, room: placed.room }), // likewise: the printer's own name
102
+ spotifyDevice.extend(placed),
103
+ ]);
104
+ export type Device = z.infer<typeof Device>;
105
+
106
+ export const WEATHER_SOURCES = ["bom", "open-meteo", "google"] as const;
107
+
108
+ export const HomeConfig = z.looseObject({
109
+ $schema: z.string().optional(),
110
+ name: z.string().optional(),
111
+ location: z
112
+ .looseObject({
113
+ latitude: z.number().min(-90).max(90),
114
+ longitude: z.number().min(-180).max(180),
115
+ bomForecast: z
116
+ .string()
117
+ .optional()
118
+ .describe("BOM précis location by name or aac, e.g. Sydney"),
119
+ timezone: z
120
+ .string()
121
+ .refine(isTimeZone, "an IANA time zone, e.g. Australia/Sydney")
122
+ .optional()
123
+ .describe(
124
+ "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",
125
+ ),
126
+ })
127
+ .optional(),
128
+ weather: z
129
+ .looseObject({
130
+ sources: z
131
+ .array(z.enum(WEATHER_SOURCES))
132
+ .optional()
133
+ .describe("preference order; default open-meteo"),
134
+ pollMinutes: z.number().min(1).optional(),
135
+ googleKey: envName
136
+ .optional()
137
+ .describe(
138
+ "env name holding a Maps Platform key, when sources has google",
139
+ ),
140
+ })
141
+ .optional(),
142
+ rooms: z.array(Room).optional(),
143
+ devices: z.array(Device).optional(),
144
+ plan: z
145
+ .looseObject({ svg: filePath.optional(), json: filePath.optional() })
146
+ .optional()
147
+ .describe("the drawing, by path relative to this file"),
148
+ });
149
+ export type HomeConfig = z.infer<typeof HomeConfig>;
150
+
151
+ /** What a valid config would run: each integration, the device it comes from, its settings, and each secret's env name and whether it is set. */
152
+ export type Setup = {
153
+ name: string;
154
+ from: string;
155
+ settings: Record<string, string | number>;
156
+ secrets: Record<string, "set" | "unset">;
157
+ };
158
+
159
+ /** A config that cannot run. `problems` names each field. */
160
+ export class HomeConfigError extends Error {
161
+ constructor(readonly problems: string[]) {
162
+ super(problems.join("; "));
163
+ this.name = "HomeConfigError";
164
+ }
165
+ }
166
+
167
+ /** A field's path in one format for every problem: `devices[3] (prices).key`. */
168
+ function fieldPath(path: PropertyKey[], input: unknown): string {
169
+ const devices = (input as { devices?: { id?: unknown }[] } | undefined)
170
+ ?.devices;
171
+ return path
172
+ .map((p, i) => {
173
+ if (typeof p !== "number") return `${i ? "." : ""}${String(p)}`;
174
+ const id =
175
+ path[i - 1] === "devices" && i === 1 ? devices?.[p]?.id : undefined;
176
+ return `[${p}]${typeof id === "string" ? ` (${id})` : ""}`;
177
+ })
178
+ .join("");
179
+ }
180
+
181
+ /** The drawing side of a config: the schema, unique room and device ids, and every device's room present. Problems, not a throw. */
182
+ export function checkPlan(input: unknown): {
183
+ config?: HomeConfig;
184
+ problems: string[];
185
+ } {
186
+ const parsed = HomeConfig.safeParse(input);
187
+ if (!parsed.success)
188
+ return {
189
+ problems: parsed.error.issues.map(
190
+ (i) => `${fieldPath(i.path, input) || "(root)"}: ${i.message}`,
191
+ ),
192
+ };
193
+ const config = parsed.data;
194
+ const ids = (config.rooms ?? []).map((r) => r.id);
195
+ const rooms = new Set(ids);
196
+ const problems = ids
197
+ .filter((id, i) => ids.indexOf(id) !== i)
198
+ .map((id) => `rooms: ${id} twice`);
199
+ const deviceIds = (config.devices ?? []).map((d) => d.id);
200
+ deviceIds.forEach(
201
+ (id, i) =>
202
+ deviceIds.indexOf(id) !== i &&
203
+ problems.push(`devices[${i}] (${id}).id: a second device with this id`),
204
+ );
205
+ (config.devices ?? []).forEach(
206
+ (d, i) =>
207
+ d.room &&
208
+ !rooms.has(d.room) &&
209
+ problems.push(`devices[${i}] (${d.id}).room: no room ${d.room}`),
210
+ );
211
+ (config.rooms ?? []).forEach(
212
+ (r, i) =>
213
+ "marker" in r &&
214
+ problems.push(
215
+ `rooms[${i}] (${r.id}).marker: renamed hueRoom; marker now means only an SVG marker id`,
216
+ ),
217
+ );
218
+ return { config, problems };
219
+ }
220
+
221
+ /**
222
+ * Validate a parsed config against `env`: `checkPlan`, one device per connector (Cast and printers: any number), and every env name set.
223
+ * Returns the problems rather than throwing, and what would be set up, secrets shown only as set or unset.
224
+ */
225
+ export function checkHome(
226
+ input: unknown,
227
+ env: Record<string, string | undefined>,
228
+ ): { config?: HomeConfig; setup: Setup[]; problems: string[] } {
229
+ const { config, problems } = checkPlan(input);
230
+ if (!config) return { setup: [], problems };
231
+ const secret = (
232
+ field: string,
233
+ name: string,
234
+ ): Record<string, "set" | "unset"> => {
235
+ if (!env[name]) problems.push(`${field}: env ${name} is not set`);
236
+ return { [name]: env[name] ? "set" : "unset" };
237
+ };
238
+ const seen = new Map<string, string>();
239
+ const setup: Setup[] = [];
240
+ (config.devices ?? []).forEach((d, i) => {
241
+ const at = `devices[${i}] (${d.id})`;
242
+ const twin = seen.get(d.connector);
243
+ if (twin && !MANY.has(d.connector))
244
+ problems.push(
245
+ `${at}: a second ${d.connector} device (first: ${twin}); one per connector for now`,
246
+ );
247
+ seen.set(d.connector, d.id);
248
+ if (d.connector === "cast") {
249
+ const twinCast = (config.devices ?? []).findIndex(
250
+ (x) => x.connector === "cast" && sameId(x.castId, d.castId),
251
+ );
252
+ if (twinCast < i)
253
+ problems.push(
254
+ `${at}.castId: the same Cast device as devices[${twinCast}] (${config.devices![twinCast]!.id})`,
255
+ );
256
+ }
257
+ if (d.connector === "printers") {
258
+ const twin = (config.devices ?? []).findIndex(
259
+ (x) => x.connector === "printers" && sameId(x.uuid, d.uuid),
260
+ );
261
+ if (twin < i)
262
+ problems.push(
263
+ `${at}.uuid: the same printer as devices[${twin}] (${config.devices![twin]!.id})`,
264
+ );
265
+ }
266
+ const { connector, id, room, name, ...rest } = d;
267
+ // Only the schema's own fields: a loose entry may carry anything, a pasted secret included.
268
+ const known = Device.options.find(
269
+ (o) => o.shape.connector.value === connector,
270
+ )!.shape;
271
+ const settings = Object.fromEntries(
272
+ Object.entries(rest).filter(
273
+ ([k, v]) =>
274
+ k in known &&
275
+ k !== "key" &&
276
+ (typeof v === "string" || typeof v === "number"),
277
+ ),
278
+ ) as Record<string, string | number>;
279
+ const key = (d as { key?: unknown }).key; // any connector's `key` is an env name (the schema's envName)
280
+ setup.push({
281
+ name: connector,
282
+ from: id,
283
+ settings,
284
+ secrets: typeof key === "string" ? secret(`${at}.key`, key) : {},
285
+ });
286
+ });
287
+ if (config.location) {
288
+ const sources = config.weather?.sources ?? ["open-meteo"];
289
+ const key = config.weather?.googleKey;
290
+ if (sources.includes("google") && !key)
291
+ problems.push(
292
+ "weather.googleKey: needed when weather.sources has google",
293
+ );
294
+ setup.push({
295
+ name: "weather",
296
+ from: "location",
297
+ settings: {
298
+ latitude: config.location.latitude,
299
+ longitude: config.location.longitude,
300
+ sources: sources.join(", "),
301
+ ...(config.location.bomForecast
302
+ ? { bomForecast: config.location.bomForecast }
303
+ : {}),
304
+ },
305
+ secrets:
306
+ sources.includes("google") && key
307
+ ? secret("weather.googleKey", key)
308
+ : {},
309
+ });
310
+ } else if (config.weather) problems.push("weather: needs location");
311
+ return { config, setup, problems };
312
+ }
313
+
314
+ /**
315
+ * How `loadHome` reaches devices. `fetch` reaches Daikin, Amber, Up, printers and the weather; Hue and Enphase use `tls`,
316
+ * Bun's `tls` fetch option by default, `NODE_TLS` from `@khalido/home/node` under Node.
317
+ */
318
+ export type LoadOptions = {
319
+ env: Record<string, string | undefined>;
320
+ /** Reads a path from the config (a CA, a token file); the caller resolves it against the config file's folder. */
321
+ readFile?: (path: string) => string;
322
+ /** For Daikin, Amber, Up, printers and the weather; the global `fetch` when left out. */
323
+ fetch?: typeof fetch;
324
+ /** Hue's pinned fetch and Enphase's unverified one: Bun's by default, `NODE_TLS` from `@khalido/home/node` under Node. */
325
+ tls?: {
326
+ hue(bridgeId: string, ca: string): typeof fetch;
327
+ enphase(): typeof fetch;
328
+ };
329
+ /** BOM's transport: `ftpFetch()` from `@khalido/home/weather/ftp`. Without it a listed `bom` source stops and says why. */
330
+ ftpFetch?: typeof fetch;
331
+ /** Each connector's discovery (the CLI's mDNS and UDP), used only after reads keep failing, to find a device that moved. Cast needs its finder always: it is how Cast finds every address. */
332
+ finders?: Partial<Record<Connector, Finder>>;
333
+ /** Cast's transport: `tlsConnect` from `@khalido/home/cast/tls`. Needed when the config lists a Cast device. */
334
+ castConnect?: CastConnect;
335
+ /** For Cast's `announce` with a clip of bytes: `serveClip` from `@khalido/home/cast/serve`. A URL clip needs none. */
336
+ castServe?: ServeClip;
337
+ /** Time for every integration; tests pass a fake. */
338
+ clock?: Clock;
339
+ /**
340
+ * Addresses kept between runs (`fileCache` from `@khalido/home/finders`, or the caller's own). A cached address newer
341
+ * than the config is tried first; each address that answers is written back when it changed or predates the config. Discovery stays for a miss.
342
+ */
343
+ cache?: AddressCache;
344
+ /** When the config was last edited (ms): a cached address older than this yields to the config's `host`. */
345
+ configAt?: number;
346
+ };
347
+
348
+ /** Any integration `loadHome` builds, typed by its name and state (and Hue's client, Cast's actions), so `createHome(loadHome(…))` is typed. */
349
+ export type HomeIntegration =
350
+ | ReturnType<typeof hue>
351
+ | ReturnType<typeof daikin>
352
+ | ReturnType<typeof enphase>
353
+ | ReturnType<typeof amber>
354
+ | ReturnType<typeof up>
355
+ | ReturnType<typeof cast>
356
+ | ReturnType<typeof printers>
357
+ | ReturnType<typeof spotify>
358
+ | ReturnType<typeof weather>;
359
+
360
+ /** The token in a JSON token file (`home enphase login` writes `{ token, expiresAt }`); a file that is not JSON says so by name. */
361
+ function tokenFromJson(text: string, at: string): string | undefined {
362
+ try {
363
+ return (JSON.parse(text) as { token?: string }).token;
364
+ } catch (error) {
365
+ throw new HomeConfigError([`${at}: not JSON (${errorText(error)})`]);
366
+ }
367
+ }
368
+
369
+ /** Build the integrations a config describes, in its device order, weather last. Throws `HomeConfigError` naming every problem. */
370
+ export function loadHome(
371
+ input: unknown,
372
+ options: LoadOptions,
373
+ ): HomeIntegration[] {
374
+ const { config, problems } = checkHome(input, options.env);
375
+ if (config && !options.readFile)
376
+ (config.devices ?? []).forEach((d, i) => {
377
+ for (const f of manifestOf(d.connector)?.files ?? []) {
378
+ const path = (d as Record<string, unknown>)[f];
379
+ if (typeof path === "string")
380
+ problems.push(
381
+ `devices[${i}] (${d.id}).${f}: needs readFile to read ${path}`,
382
+ );
383
+ }
384
+ });
385
+ const castAt = (config?.devices ?? []).findIndex(
386
+ (d) => d.connector === "cast",
387
+ );
388
+ const notBun = typeof (globalThis as { Bun?: unknown }).Bun === "undefined"; // Bun's fetch takes a `tls` option; elsewhere it is ignored and the read fails
389
+ for (const [i, d] of (config?.devices ?? []).entries())
390
+ if (
391
+ (d.connector === "hue" || d.connector === "enphase") &&
392
+ !options.tls &&
393
+ notBun
394
+ )
395
+ problems.push(
396
+ `devices[${i}] (${d.id}): ${d.connector} needs tls (NODE_TLS from @khalido/home/node) outside Bun`,
397
+ );
398
+ if (castAt >= 0 && !options.castConnect)
399
+ problems.push(
400
+ `devices[${castAt}] (${config!.devices![castAt]!.id}): Cast needs castConnect (tlsConnect from @khalido/home/cast/tls)`,
401
+ );
402
+ if (castAt >= 0 && !options.finders?.cast)
403
+ problems.push(
404
+ `devices[${castAt}] (${config!.devices![castAt]!.id}): Cast needs finders.cast (its mDNS discovery)`,
405
+ );
406
+ if (!config || problems.length) throw new HomeConfigError(problems);
407
+ const file = (path: string) => options.readFile!(path);
408
+ const env = (name: string) => options.env[name]!;
409
+ const net = options.fetch ?? fetch;
410
+ const clock = options.clock ?? realClock;
411
+ const tls = options.tls ?? { hue: bunPinnedFetch, enphase: bunGatewayFetch };
412
+ const book = addressBook(options.cache, { configAt: options.configAt });
413
+ /** Where a polling connector starts: a fresh cached address, else the config's, and its write-back for each good read. */
414
+ const start = (
415
+ connector: Connector,
416
+ id: string | undefined,
417
+ configured: string,
418
+ ) => {
419
+ const cached = book.fresh(connector, id);
420
+ return {
421
+ host: cached?.host ?? configured,
422
+ hostCached: cached !== undefined,
423
+ seen: (at: string) => book.seen(connector, id, { host: at }),
424
+ };
425
+ };
426
+ // Every Cast entry is one integration, at the first Cast entry's place; so is every printer.
427
+ const printersAt = (config.devices ?? []).findIndex(
428
+ (d) => d.connector === "printers",
429
+ );
430
+ const integrations: HomeIntegration[] = (config.devices ?? []).flatMap(
431
+ (d, i) =>
432
+ d.connector === "cast"
433
+ ? i === castAt
434
+ ? [castAll()]
435
+ : []
436
+ : d.connector === "printers"
437
+ ? i === printersAt
438
+ ? [printersAll()]
439
+ : []
440
+ : [one(d, `devices[${i}] (${d.id})`)],
441
+ );
442
+ function one(
443
+ d: Exclude<Device, { connector: "cast" | "printers" }>,
444
+ at: string,
445
+ ): HomeIntegration {
446
+ switch (d.connector) {
447
+ case "hue": {
448
+ const ca = file(d.ca);
449
+ return hue({
450
+ ...start("hue", d.bridgeId, d.host),
451
+ bridgeId: d.bridgeId,
452
+ key: env(d.key),
453
+ ca,
454
+ fetch: tls.hue(d.bridgeId, ca),
455
+ find: options.finders?.hue,
456
+ clock,
457
+ });
458
+ }
459
+ case "daikin":
460
+ return daikin({
461
+ ...start("daikin", d.mac, d.host),
462
+ mac: d.mac,
463
+ find: options.finders?.daikin,
464
+ fetch: net,
465
+ clock,
466
+ });
467
+ case "enphase": {
468
+ const text = file(d.token).trim();
469
+ const token = text.startsWith("{")
470
+ ? tokenFromJson(text, `${at}.token`)
471
+ : text;
472
+ if (!token)
473
+ throw new HomeConfigError([`${at}.token: no token in ${d.token}`]);
474
+ return enphase({
475
+ ...start("enphase", d.serial, d.host),
476
+ token,
477
+ serial: d.serial,
478
+ find: options.finders?.enphase,
479
+ fetch: tls.enphase(),
480
+ timeZone: config!.location?.timezone,
481
+ clock,
482
+ });
483
+ }
484
+ case "amber":
485
+ return amber({ key: env(d.key), siteId: d.siteId, fetch: net, clock });
486
+ case "up":
487
+ return up({
488
+ token: env(d.key),
489
+ account: d.account,
490
+ household: d.household,
491
+ fetch: net,
492
+ timeZone: config!.location?.timezone,
493
+ clock,
494
+ });
495
+ case "spotify":
496
+ return spotify({
497
+ url: d.url,
498
+ maxVolume: d.maxVolume,
499
+ repairStalls: d.repairStalls,
500
+ clock,
501
+ });
502
+ }
503
+ }
504
+ function castAll(): ReturnType<typeof cast> {
505
+ const expect = config!.devices!.flatMap((x) => {
506
+ if (x.connector !== "cast") return [];
507
+ const c = book.fresh("cast", x.castId);
508
+ return [
509
+ {
510
+ castId: x.castId,
511
+ name: x.name,
512
+ ...(c?.port === undefined ? {} : { host: c.host, port: c.port }),
513
+ },
514
+ ];
515
+ });
516
+ // Two passes, as every discovery runs, but done once every entry in the config has answered.
517
+ const all = (found: Found[]) =>
518
+ expect.every((e) => found.some((f) => sameId(f.id, e.castId)));
519
+ const resolve = async () =>
520
+ resolvedFrom(
521
+ await discover(options.finders!.cast!, { clock, done: all }),
522
+ );
523
+ return cast({
524
+ resolve,
525
+ connect: options.castConnect!,
526
+ expect,
527
+ serve: options.castServe,
528
+ clock,
529
+ seen: (id, a) => book.seen("cast", id, a),
530
+ });
531
+ }
532
+ function printersAll(): ReturnType<typeof printers> {
533
+ const entries = config!.devices!.flatMap((x) => {
534
+ if (x.connector !== "printers") return [];
535
+ const c = book.fresh("printers", x.uuid);
536
+ return [
537
+ {
538
+ uuid: x.uuid,
539
+ name: x.name,
540
+ host: c?.host ?? x.host,
541
+ port: c?.port ?? x.port,
542
+ room: x.room,
543
+ offlineDays: x.offlineDays,
544
+ },
545
+ ];
546
+ });
547
+ return printers({
548
+ printers: entries,
549
+ fetch: net,
550
+ find: options.finders?.printers,
551
+ clock,
552
+ seen: (uuid, a) => book.seen("printers", uuid, a),
553
+ });
554
+ }
555
+ if (config.location) {
556
+ const { latitude, longitude, bomForecast } = config.location;
557
+ const make: Record<(typeof WEATHER_SOURCES)[number], () => WeatherSource> =
558
+ {
559
+ bom: () =>
560
+ bom({ fetch: options.ftpFetch, forecastLocation: bomForecast }),
561
+ "open-meteo": () => openMeteo({ fetch: net }),
562
+ google: () =>
563
+ google({ key: env(config.weather!.googleKey!), fetch: net }),
564
+ };
565
+ const sources = config.weather?.sources?.map((s) => make[s]());
566
+ integrations.push(
567
+ weather({
568
+ latitude,
569
+ longitude,
570
+ sources,
571
+ pollMinutes: config.weather?.pollMinutes,
572
+ timeZone: config.location.timezone,
573
+ clock,
574
+ }),
575
+ );
576
+ }
577
+ return integrations;
578
+ }
@@ -0,0 +1,77 @@
1
+ import type { z } from "zod";
2
+
3
+ // What every connector's client shares: one timeout, one error, one fetch that a run's abort can cancel.
4
+
5
+ /** No device or service call waits longer than this. */
6
+ export const TIMEOUT_MS = 10_000;
7
+
8
+ /**
9
+ * An error as a detail line: its message ("fetch failed", not "TypeError: fetch failed"), and its cause's when it has one.
10
+ * Node's fetch puts the reason there: "fetch failed: connect ECONNREFUSED 192.0.2.10:443".
11
+ */
12
+ export function errorText(error: unknown): string {
13
+ if (!(error instanceof Error)) return String(error);
14
+ const cause = error.cause instanceof Error ? error.cause.message : undefined;
15
+ return cause && cause !== error.message
16
+ ? `${error.message}: ${cause}`
17
+ : error.message;
18
+ }
19
+
20
+ /** A failed device or service call. `status` is the HTTP status (or a device's own code); `code` a connector's own reason. */
21
+ export class ConnectorError extends Error {
22
+ readonly status?: number;
23
+ readonly code?: string;
24
+ constructor(
25
+ message: string,
26
+ options: { status?: number; code?: string; cause?: unknown } = {},
27
+ ) {
28
+ super(
29
+ message,
30
+ options.cause === undefined ? undefined : { cause: options.cause },
31
+ );
32
+ this.name = new.target.name; // a subclass (HueError, AmberError) names itself in logs; a detail shows only the message
33
+ this.status = options.status;
34
+ this.code = options.code;
35
+ }
36
+ }
37
+
38
+ /** `fetch` that gives up after `timeoutMs`, or as soon as `signal` (a run's) aborts. Returns the response whatever its status. */
39
+ export function fetchTimeout(
40
+ fetcher: typeof fetch,
41
+ url: string | URL,
42
+ init: Omit<RequestInit, "signal"> = {},
43
+ options: { timeoutMs?: number; signal?: AbortSignal } = {},
44
+ ): Promise<Response> {
45
+ const timeout = AbortSignal.timeout(options.timeoutMs ?? TIMEOUT_MS);
46
+ return fetcher(url, {
47
+ ...init,
48
+ signal: options.signal
49
+ ? AbortSignal.any([timeout, options.signal])
50
+ : timeout,
51
+ });
52
+ }
53
+
54
+ /**
55
+ * GET (or `init.method`) JSON through `fetchTimeout`. A non-ok status throws `error` (ConnectorError by default) with the
56
+ * status, as "HTTP 500 on GET /path" (no query: it can carry a key). With a `schema` the body is parsed by it.
57
+ */
58
+ export async function getJson<T = unknown>(
59
+ fetcher: typeof fetch,
60
+ url: string | URL,
61
+ options: {
62
+ init?: Omit<RequestInit, "signal">;
63
+ timeoutMs?: number;
64
+ signal?: AbortSignal;
65
+ schema?: z.ZodType<T>;
66
+ error?: typeof ConnectorError;
67
+ } = {},
68
+ ): Promise<T> {
69
+ const res = await fetchTimeout(fetcher, url, options.init, options);
70
+ if (!res.ok)
71
+ throw new (options.error ?? ConnectorError)(
72
+ `HTTP ${res.status} on ${options.init?.method ?? "GET"} ${new URL(url).pathname}`,
73
+ { status: res.status },
74
+ );
75
+ const body: unknown = await res.json();
76
+ return options.schema ? options.schema.parse(body) : (body as T);
77
+ }