@khalido/home 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/.env.example +25 -0
  2. package/CHANGELOG.md +25 -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 +426 -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 +11 -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 +745 -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 +267 -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 +195 -0
  92. package/dist/discover.js +446 -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 +45 -0
  98. package/dist/finders.js +173 -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 +39 -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 +26 -0
  137. package/skills/draw-home/SKILL.md +82 -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 +175 -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 +43 -0
  160. package/src/connectors/cast/codec.ts +126 -0
  161. package/src/connectors/cast/index.ts +551 -0
  162. package/src/connectors/cast/schemas.ts +101 -0
  163. package/src/connectors/cast/serve.ts +72 -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 +80 -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 +929 -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 +380 -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 +634 -0
  206. package/src/energy.ts +221 -0
  207. package/src/finders-entry.ts +2 -0
  208. package/src/finders.ts +247 -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
@@ -0,0 +1,274 @@
1
+ // `home probe`: read-only checks that need no key: Hue's public config, Daikin discovery and state, the IQ Gateway's
2
+ // /info, each Cast device's status, each printer's status, each weather source.
3
+ import { type CastSnapshot, castDevices } from "../connectors/cast/index.ts";
4
+ import {
5
+ type PrintersState,
6
+ printerList,
7
+ } from "../connectors/printers/index.ts";
8
+ import { gatewayHost } from "../connectors/enphase/client.ts";
9
+ import { bunPinnedFetch } from "../connectors/hue/index.ts";
10
+ import { type WeatherSource } from "../connectors/weather/index.ts";
11
+ import {
12
+ DEFAULT_POLL_MINUTES,
13
+ sourcePollMinutes,
14
+ } from "../connectors/weather/source.ts";
15
+ import { checkHome } from "../config.ts";
16
+ import { daikinFields, findDaikins } from "../finders.ts";
17
+ import { castNow } from "./cast.ts";
18
+ import { printersNow } from "./printers.ts";
19
+ import { settle } from "../home.ts";
20
+ import {
21
+ configPathOf,
22
+ env,
23
+ fail,
24
+ fromConfig,
25
+ type Options,
26
+ printJson,
27
+ readConfig,
28
+ } from "./context.ts";
29
+ import { bridge, ca } from "./hue.ts";
30
+ import { place, weatherSources } from "./status.ts";
31
+
32
+ /** The env names `probe` reads, filled from a config's addresses (secrets by the names it gives), so probe needs no second reading of it. */
33
+ function envFromConfig(path: string): Record<string, string> {
34
+ const { config, problems } = checkHome(readConfig(path), env);
35
+ if (!config) fail(`${path}: ${problems.join("; ")}`);
36
+ const out: Record<string, string | undefined> = {};
37
+ for (const d of config!.devices ?? []) {
38
+ if (d.connector === "hue")
39
+ Object.assign(out, {
40
+ HUE_BRIDGE_HOST: d.host,
41
+ HUE_BRIDGE_ID: d.bridgeId,
42
+ HUE_CA_FILE: fromConfig(path, d.ca),
43
+ HUE_APPLICATION_KEY: env[d.key],
44
+ });
45
+ if (d.connector === "daikin") out.DAIKIN_HOST = d.host;
46
+ if (d.connector === "enphase") out.IQ_GATEWAY_HOST = d.host;
47
+ if (d.connector === "amber")
48
+ Object.assign(out, { AMBER_KEY: env[d.key], AMBER_SITE_ID: d.siteId });
49
+ }
50
+ const at = config!.location;
51
+ if (at)
52
+ Object.assign(out, {
53
+ HOME_LATITUDE: String(at.latitude),
54
+ HOME_LONGITUDE: String(at.longitude),
55
+ HOME_BOM_FORECAST: at.bomForecast,
56
+ });
57
+ const g = config!.weather?.googleKey;
58
+ if (g) out.GOOGLE_WEATHER_KEY = env[g];
59
+ return Object.fromEntries(
60
+ Object.entries(out).filter(
61
+ (e): e is [string, string] => e[1] !== undefined,
62
+ ),
63
+ );
64
+ }
65
+
66
+ /** GET a URL and return its text, or the error, within 5 s. */
67
+ const tryGet = (url: string, init: RequestInit = {}) =>
68
+ fetch(url, { ...init, signal: AbortSignal.timeout(5000) }).then(
69
+ async (r) => ({ status: r.status, body: await r.text() }),
70
+ (e: Error) => ({ status: 0, body: e.message }),
71
+ );
72
+
73
+ const pick = (o: Record<string, unknown>, keys: string[]) =>
74
+ Object.fromEntries(keys.map((k) => [k, o[k]]));
75
+
76
+ /** What `basic_info` and the UDP answer may show: the adapter and firmware, never its name, MAC, Wi-Fi name or password. */
77
+ const DAIKIN_SHOWN = [
78
+ "ret",
79
+ "type",
80
+ "reg",
81
+ "ver",
82
+ "rev",
83
+ "adp_kind",
84
+ "pv",
85
+ "cpv",
86
+ "method",
87
+ "err",
88
+ "en_setzone",
89
+ ];
90
+
91
+ /** The BRP084 reply's response codes only: its `adp_i` node holds the adapter's MAC (pydaikin reads it there). */
92
+ const answered = (reply: {
93
+ responses?: { fr?: string; rsc?: number }[];
94
+ notJson?: string;
95
+ }) =>
96
+ reply.notJson
97
+ ? reply
98
+ : {
99
+ status: "answers",
100
+ responses: reply.responses?.map((r) => ({ from: r.fr, code: r.rsc })),
101
+ };
102
+
103
+ /** JSON, or the start of the body as text when it is not. */
104
+ const jsonOr = (body: string) => {
105
+ try {
106
+ return JSON.parse(body);
107
+ } catch {
108
+ return { notJson: body.slice(0, 80) };
109
+ }
110
+ };
111
+
112
+ export async function probe(o: Options): Promise<void> {
113
+ const configPath = configPathOf(o);
114
+ if (configPath) Object.assign(env, envFromConfig(configPath)); // the config's addresses, for this one command
115
+ const out: Record<string, unknown> = {};
116
+ try {
117
+ const b = await bridge();
118
+ const fetchPinned = env.HUE_CA_FILE
119
+ ? bunPinnedFetch(b.id, ca())
120
+ : undefined;
121
+ const r = fetchPinned
122
+ ? await fetchPinned(`https://${b.host}/api/0/config`).then((x) =>
123
+ x.json(),
124
+ )
125
+ : "needs HUE_CA_FILE";
126
+ out.hue =
127
+ typeof r === "string"
128
+ ? { status: r }
129
+ : {
130
+ status: "answers",
131
+ ...pick(r, ["name", "modelid", "apiversion", "swversion"]),
132
+ };
133
+ } catch (error) {
134
+ out.hue = { status: "unavailable", detail: (error as Error).message };
135
+ }
136
+ const daikins = env.DAIKIN_HOST
137
+ ? [{ ip: env.DAIKIN_HOST, fields: {} }]
138
+ : await findDaikins();
139
+ out.daikin = daikins.length
140
+ ? await Promise.all(
141
+ daikins.map(async (d) => {
142
+ const info = await tryGet(`http://${d.ip}/common/basic_info`);
143
+ const control = await tryGet(
144
+ `http://${d.ip}/aircon/get_control_info`,
145
+ );
146
+ const sensor = await tryGet(`http://${d.ip}/aircon/get_sensor_info`);
147
+ const f = (r: { status: number; body: string }) =>
148
+ r.status === 200
149
+ ? daikinFields(r.body)
150
+ : { status: r.status, error: r.body.slice(0, 80) };
151
+ const shown = (r: typeof info) =>
152
+ r.status === 200 ? pick(daikinFields(r.body), DAIKIN_SHOWN) : f(r);
153
+ const result: Record<string, unknown> = {
154
+ ip: d.ip,
155
+ udp: pick(d.fields, DAIKIN_SHOWN),
156
+ basic_info: shown(info),
157
+ control_info: f(control),
158
+ sensor_info: f(sensor),
159
+ };
160
+ if (info.status === 404) {
161
+ // Not the BRP069 API. Try the newer JSON one (BRP084): op 2 is a read of the adapter's info, as pydaikin does.
162
+ const body = JSON.stringify({
163
+ requests: [
164
+ { op: 2, to: "/dsiot/edge.adp_i" },
165
+ {
166
+ op: 2,
167
+ to: "/dsiot/edge/adr_0100.dgc_status?filter=pv,pt,md",
168
+ },
169
+ ],
170
+ });
171
+ const r = await tryGet(`http://${d.ip}/dsiot/multireq`, {
172
+ method: "POST",
173
+ headers: { "content-type": "application/json" },
174
+ body,
175
+ });
176
+ result.dsiot =
177
+ r.status === 200
178
+ ? answered(jsonOr(r.body))
179
+ : { status: r.status, error: r.body.slice(0, 80) };
180
+ }
181
+ return result;
182
+ }),
183
+ )
184
+ : {
185
+ status:
186
+ "no answer to the UDP broadcast; set DAIKIN_HOST to try one address",
187
+ };
188
+ const gateway = env.IQ_GATEWAY_HOST && gatewayHost(env.IQ_GATEWAY_HOST);
189
+ if (gateway) {
190
+ // Bun cannot verify the gateway's chain (README); this unauthenticated read sends nothing secret.
191
+ const r = await tryGet(`https://${gateway}/info`, {
192
+ tls: { rejectUnauthorized: false },
193
+ } as RequestInit);
194
+ const tag = (t: string) =>
195
+ r.body.match(new RegExp(`<${t}>([^<]*)</${t}>`))?.[1];
196
+ const serial = tag("sn");
197
+ out.enphase =
198
+ r.status === 200
199
+ ? {
200
+ status: "answers",
201
+ software: tag("software") ?? tag("version"),
202
+ part: tag("pn"),
203
+ serialMatchesEnv: env.IQ_GATEWAY_SERIAL_NUMBER
204
+ ? serial === env.IQ_GATEWAY_SERIAL_NUMBER
205
+ : "IQ_GATEWAY_SERIAL_NUMBER unset",
206
+ }
207
+ : {
208
+ status: "unavailable",
209
+ detail: `${r.status} ${r.body.slice(0, 80)}`,
210
+ };
211
+ } else out.enphase = { status: "needs IQ_GATEWAY_HOST" };
212
+ // Cast: connect to every device and group, read receiver (and group) status, close. Nothing is launched or changed.
213
+ const c = (await settle([castNow()], { ms: 10_000 })).cast!;
214
+ const devices = (c.state as CastSnapshot | undefined)?.devices ?? {};
215
+ out.cast = {
216
+ status: c.status,
217
+ detail: c.detail,
218
+ anomaly: c.anomaly,
219
+ devices: castDevices({ devices, asOf: "" }).map(
220
+ ({ host: _, asOf: __, ...d }) => d,
221
+ ),
222
+ };
223
+ // Printers: one Get-Printer-Attributes to each AirPrint printer that answers mDNS (or the address cache). Nothing is sent but the read.
224
+ const pr = (await settle([printersNow()], { ms: 15_000 })).printers!;
225
+ out.printers = {
226
+ status: pr.status,
227
+ detail: pr.detail,
228
+ anomaly: pr.anomaly,
229
+ printers: printerList(
230
+ (pr.state as PrintersState | undefined) ?? { printers: {}, asOf: "" },
231
+ ).map(({ host: _, asOf: __, ...p }) => p),
232
+ };
233
+ const needs = ["HOME_LATITUDE", "HOME_LONGITUDE"].filter((n) => !env[n]);
234
+ if (needs.length) out.weather = { status: `needs ${needs.join(", ")}` };
235
+ else {
236
+ const at = place();
237
+ const now = Date.now();
238
+ const probeOne = async (source: WeatherSource) => {
239
+ const t = performance.now();
240
+ const r = await source.read({
241
+ place: at,
242
+ at: now,
243
+ pollMinutes: sourcePollMinutes(source, DEFAULT_POLL_MINUTES),
244
+ });
245
+ const when = (s?: { at: string; modelled: boolean }) =>
246
+ s ? `${s.at}${s.modelled ? " (modelled)" : ""}` : undefined;
247
+ return {
248
+ status: r.stop
249
+ ? "unavailable"
250
+ : r.now || r.forecast || r.sky || r.rain
251
+ ? "answers"
252
+ : "no data",
253
+ ms: Math.round(performance.now() - t),
254
+ now: when(r.now),
255
+ station: r.now?.station,
256
+ forecastIssued: r.forecast?.issuedAt ?? when(r.forecast),
257
+ sky: when(r.sky),
258
+ rain: when(r.rain),
259
+ problem: r.stop ?? r.problem,
260
+ anomaly: r.anomaly,
261
+ };
262
+ };
263
+ const probed = await Promise.all(
264
+ weatherSources().map(async (s) => [s.name, await probeOne(s)] as const),
265
+ );
266
+ out.weather = {
267
+ ...Object.fromEntries(probed),
268
+ ...(env.GOOGLE_WEATHER_KEY
269
+ ? {}
270
+ : { google: { status: "no key: set GOOGLE_WEATHER_KEY" } }),
271
+ };
272
+ }
273
+ printJson(out);
274
+ }
@@ -0,0 +1,76 @@
1
+ // `home spotify pause|play [link]|next|volume <0-100>`: one write to the Soloist speaker. Opens the socket, waits for
2
+ // Soloist to answer (at most 5 s), sends the command and closes.
3
+ import { spotify } from "../connectors/spotify/index.ts";
4
+ import type { Part } from "../home.ts";
5
+ import {
6
+ type Applied,
7
+ type SpotifyState,
8
+ spotifyText,
9
+ } from "../connectors/spotify/index.ts";
10
+ import { env, fail, usage, type Verbs } from "./context.ts";
11
+
12
+ /** SOLOIST_MAX_VOLUME as a number 0–100, or undefined when unset. */
13
+ function maxVolume(): number | undefined {
14
+ const raw = env.SOLOIST_MAX_VOLUME;
15
+ if (!raw) return undefined;
16
+ const n = Number(raw);
17
+ return Number.isInteger(n) && n >= 0 && n <= 100
18
+ ? n
19
+ : fail(`SOLOIST_MAX_VOLUME: a whole number 0-100, not ${raw}`);
20
+ }
21
+
22
+ /** The speaker from SOLOIST_WS and SOLOIST_MAX_VOLUME, for `home status` and the verbs: both need SOLOIST_WS. */
23
+ export const spotifyFromEnv = () =>
24
+ spotify({
25
+ url:
26
+ env.SOLOIST_WS ||
27
+ fail(
28
+ "needs SOLOIST_WS, Soloist's --ws address as a URL (see .env.example)",
29
+ ),
30
+ maxVolume: maxVolume(),
31
+ });
32
+
33
+ export const SPOTIFY_USAGE =
34
+ "usage: home spotify pause | play [link] | next | volume <0-100>";
35
+
36
+ async function withSpotify<T>(
37
+ act: (s: ReturnType<typeof spotify>) => Promise<T>,
38
+ ): Promise<T> {
39
+ const s = spotifyFromEnv();
40
+ const stop = new AbortController();
41
+ let last: Part<SpotifyState> | undefined;
42
+ const running = s.run(stop.signal, (p) => (last = p));
43
+ try {
44
+ for (
45
+ const until = Date.now() + 5000;
46
+ Date.now() < until && (last?.status ?? "connecting") === "connecting";
47
+ )
48
+ await Bun.sleep(50);
49
+ if (last?.status !== "connected")
50
+ fail(last?.detail ?? "Soloist did not answer in 5 s", "network");
51
+ return await act(s);
52
+ } finally {
53
+ stop.abort();
54
+ await running;
55
+ }
56
+ }
57
+
58
+ /** What a write left, in a line: the speaker as Soloist then reported it, or that it has not reported the change yet. */
59
+ const said = ({ state, confirmed }: Applied) =>
60
+ console.log(
61
+ confirmed
62
+ ? spotifyText(state)
63
+ : `sent; Soloist has not reported it yet (${spotifyText(state)})`,
64
+ );
65
+
66
+ export const spotifyVerbs: Verbs = {
67
+ pause: () => withSpotify((s) => s.pause()).then(said),
68
+ next: () => withSpotify((s) => s.next()).then(said),
69
+ play: ([link]) => withSpotify((s) => s.play(link)).then(said),
70
+ async volume([value]) {
71
+ const n = Number(value);
72
+ if (value === undefined || value.trim() === "" || !(n >= 0 && n <= 100))
73
+ usage(SPOTIFY_USAGE);
74
+ said(await withSpotify((s) => s.setVolume(n)));
75
+ },
76
+ };
@@ -0,0 +1,228 @@
1
+ // `home status` and `home <connector>`: build each integration (from a config, else from the shell's env names), run
2
+ // them until each has answered, and print each through its connector's manifest.
3
+ import { existsSync, readFileSync, statSync } from "node:fs";
4
+ import { amber } from "../connectors/amber/index.ts";
5
+ import { tlsConnect } from "../connectors/cast/tls.ts";
6
+ import { daikin } from "../connectors/daikin/index.ts";
7
+ import {
8
+ bom,
9
+ google,
10
+ openMeteo,
11
+ weather,
12
+ type WeatherSource,
13
+ } from "../connectors/weather/index.ts";
14
+ import { ftpFetch } from "../connectors/weather/ftp.ts";
15
+ import { loadHome } from "../config.ts";
16
+ import { energyNow, gridText } from "../energy.ts";
17
+ import { FINDERS } from "../finders.ts";
18
+ import { type Integration, type Part, type Status, settle } from "../home.ts";
19
+ import { kw, type Manifest } from "../manifest.ts";
20
+ import { MANIFESTS, manifestOf } from "../manifests.ts";
21
+ import { castNow } from "./cast.ts";
22
+ import { printersNow } from "./printers.ts";
23
+ import { spotifyFromEnv } from "./spotify.ts";
24
+ import {
25
+ addresses,
26
+ CliError,
27
+ configPathOf,
28
+ env,
29
+ fail,
30
+ fromConfig,
31
+ homeTimeZone,
32
+ type Options,
33
+ printJson,
34
+ readConfig,
35
+ TOKEN_FILE,
36
+ } from "./context.ts";
37
+ import { enphaseFromEnv } from "./enphase.ts";
38
+ import { hueFromEnv } from "./hue.ts";
39
+ import { upFromEnv } from "./up.ts";
40
+
41
+ /** One connector's row: its part, or "not set up" with the env names (and steps) it still needs. */
42
+ type Row = Omit<Part, "status"> & {
43
+ status: Status | "not set up";
44
+ needs: string[];
45
+ };
46
+
47
+ /** HOME_LATITUDE and HOME_LONGITUDE as numbers; the manifest's `needs` has already checked they are set. */
48
+ export function place(): { latitude: number; longitude: number } {
49
+ const latitude = Number(env.HOME_LATITUDE);
50
+ const longitude = Number(env.HOME_LONGITUDE);
51
+ return Number.isFinite(latitude) && Number.isFinite(longitude)
52
+ ? { latitude, longitude }
53
+ : fail("HOME_LATITUDE and HOME_LONGITUDE must be numbers");
54
+ }
55
+
56
+ /** The weather sources KO's env sets up, in preference order: BOM (opt-in, with the FTP transport), Open-Meteo, then Google if keyed, as the last resort. */
57
+ export const weatherSources = (): WeatherSource[] => [
58
+ bom({ fetch: ftpFetch(), forecastLocation: env.HOME_BOM_FORECAST }),
59
+ openMeteo(),
60
+ ...(env.GOOGLE_WEATHER_KEY ? [google({ key: env.GOOGLE_WEATHER_KEY })] : []),
61
+ ];
62
+
63
+ /**
64
+ * Each connector from the shell's env names, once the manifest's non-optional names are set. `needs` adds a step that
65
+ * is not an env name (Enphase's token file). Addresses come from the env only; `home probe` and `home discover` search.
66
+ */
67
+ const FROM_ENV: Record<
68
+ string,
69
+ {
70
+ needs?: () => string[];
71
+ build: () => Integration<any> | Promise<Integration<any>>;
72
+ }
73
+ > = {
74
+ hue: { build: hueFromEnv },
75
+ daikin: { build: () => daikin({ host: env.DAIKIN_HOST!, fetch }) },
76
+ enphase: {
77
+ needs: () => (existsSync(TOKEN_FILE) ? [] : ["`home enphase login`"]),
78
+ build: enphaseFromEnv,
79
+ },
80
+ amber: {
81
+ build: () =>
82
+ amber({ key: env.AMBER_KEY!, siteId: env.AMBER_SITE_ID, fetch }),
83
+ },
84
+ up: { build: upFromEnv },
85
+ weather: {
86
+ build: () =>
87
+ weather({
88
+ ...place(),
89
+ sources: weatherSources(),
90
+ timeZone: homeTimeZone(),
91
+ }),
92
+ },
93
+ cast: { build: castNow },
94
+ printers: { build: printersNow },
95
+ spotify: { build: spotifyFromEnv },
96
+ };
97
+
98
+ /** The rows for these manifests from the shell: not set up, failed to build, or run until each has answered. */
99
+ async function statusFromEnv(
100
+ manifests: Manifest[],
101
+ ): Promise<Record<string, Row>> {
102
+ const rows: Record<string, Row> = {};
103
+ const built = await Promise.all(
104
+ // in parallel: Hue's build can wait on mDNS
105
+ manifests.map(async (m): Promise<Integration<any>[]> => {
106
+ const from = FROM_ENV[m.name]!;
107
+ const needs = [
108
+ ...m.env.filter((e) => !e.optional && !env[e.name]).map((e) => e.name),
109
+ ...(from.needs?.() ?? []),
110
+ ];
111
+ rows[m.name] = {
112
+ status: needs.length ? "not set up" : "connecting",
113
+ needs,
114
+ };
115
+ if (needs.length) return [];
116
+ try {
117
+ return [await from.build()];
118
+ } catch (error) {
119
+ const fix =
120
+ error instanceof CliError && error.fix
121
+ ? ` (run \`${error.fix}\`)`
122
+ : "";
123
+ rows[m.name] = {
124
+ status: "unavailable",
125
+ needs,
126
+ detail: `${(error as Error).message}${fix}`,
127
+ };
128
+ return [];
129
+ }
130
+ }),
131
+ );
132
+ for (const [name, part] of Object.entries(
133
+ (await settle(built.flat())) as Record<string, Part>,
134
+ ))
135
+ rows[name] = { ...part, needs: [] };
136
+ return rows;
137
+ }
138
+
139
+ /** The rows from a config: its integrations (or the one named) run until each has answered (weather: every source), at most 15 s. */
140
+ async function statusFromConfig(
141
+ path: string,
142
+ only?: string,
143
+ ): Promise<Record<string, Row>> {
144
+ const options = {
145
+ env,
146
+ readFile: (p: string) => readFileSync(fromConfig(path, p), "utf8"),
147
+ ftpFetch: ftpFetch(),
148
+ finders: FINDERS,
149
+ castConnect: tlsConnect,
150
+ cache: addresses.cache,
151
+ configAt: statSync(path).mtimeMs,
152
+ };
153
+ const all = loadHome(readConfig(path), options);
154
+ const integrations =
155
+ only === undefined ? all : all.filter((i) => i.name === only);
156
+ if (!integrations.length) fail(`no ${only} in ${path}`);
157
+ return Object.fromEntries(
158
+ Object.entries(await settle(integrations)).map(([name, p]) => [
159
+ name,
160
+ { ...p, needs: [] },
161
+ ]),
162
+ );
163
+ }
164
+
165
+ /** A row as `--json` prints it: status, needs and access, then the state as the connector's manifest shapes it. */
166
+ function rowRecord(name: string, r: Row): Record<string, unknown> {
167
+ const m = manifestOf(name);
168
+ return {
169
+ status: r.status,
170
+ needs: r.needs,
171
+ access: m?.access ?? "",
172
+ detail: r.detail,
173
+ anomaly: r.anomaly,
174
+ ...(m?.record && r.state ? m.record(r as Part) : (r.state as object)),
175
+ };
176
+ }
177
+
178
+ /** A row's text: "name status · summary · needs … · detail · odd: … (access)", then its children indented. */
179
+ function rowLines(name: string, r: Row): string[] {
180
+ const m = manifestOf(name);
181
+ const [summary = "", ...children] =
182
+ r.state === undefined ? [] : (m?.lines(r as Part) ?? []);
183
+ const parts = [
184
+ r.status,
185
+ summary,
186
+ r.needs.length ? `needs ${r.needs.join(", ")}` : "",
187
+ r.detail ?? "",
188
+ r.anomaly ? `odd: ${r.anomaly}` : "",
189
+ ];
190
+ return [
191
+ `${name.padEnd(9)}${parts.filter(Boolean).join(" · ")} (${m?.access ?? ""})`,
192
+ ...children.map((line) => ` ${line}`),
193
+ ];
194
+ }
195
+
196
+ /** `home status`: every connector, with the energy now on top. With `only` (`home <connector>`), that connector alone. */
197
+ export async function status(o: Options, only?: string): Promise<void> {
198
+ const path = configPathOf(o);
199
+ const manifests = only === undefined ? MANIFESTS : [manifestOf(only)!];
200
+ const rows = path
201
+ ? await statusFromConfig(path, only)
202
+ : await statusFromEnv(manifests);
203
+ if (only !== undefined) {
204
+ const row = rows[only]!;
205
+ if (row.status !== "connected") process.exitCode = 1; // `home <connector>` is a check a script can test; `home` alone lists every one and exits 0
206
+ return o.json
207
+ ? printJson(rowRecord(only, row))
208
+ : console.log(rowLines(only, row).join("\n"));
209
+ }
210
+ const connected = (name: string) =>
211
+ rows[name]?.status === "connected"
212
+ ? (rows[name]!.state as never)
213
+ : undefined;
214
+ const now = energyNow(connected("enphase"), connected("amber"));
215
+ if (o.json)
216
+ return printJson({
217
+ now,
218
+ ...Object.fromEntries(
219
+ Object.entries(rows).map(([name, r]) => [name, rowRecord(name, r)]),
220
+ ),
221
+ });
222
+ if (now.gridW !== undefined)
223
+ console.log(
224
+ `now solar ${kw(now.solarW)} · using ${kw(now.usingW)} · ${gridText(now)}`,
225
+ );
226
+ for (const [name, r] of Object.entries(rows))
227
+ console.log(rowLines(name, r).join("\n"));
228
+ }