@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,364 @@
1
+ import { z } from "zod";
2
+ import { getJson } from "../../connector.js";
3
+ import { RAIN_INTENSITIES } from "./rain.js";
4
+ import { sunDisagrees } from "./sun.js";
5
+ import { HOURLY_MINUTES, iso, nextPoll, num, RAIN_HORIZON_MINUTES, USER_AGENT, } from "./source.js";
6
+ // Google's Weather API (Maps Platform, https://developers.google.com/maps/documentation/weather): needs a key with
7
+ // billing. Built from the docs' example responses, checked live against Sydney on 2026-10-13. Every slice is a model.
8
+ export const BASE = "https://weather.googleapis.com/v1";
9
+ const Temp = z
10
+ .looseObject({ degrees: z.number().optional(), unit: z.string().optional() })
11
+ .optional();
12
+ const Speed = z
13
+ .looseObject({ value: z.number().optional(), unit: z.string().optional() })
14
+ .optional();
15
+ const Condition = z
16
+ .looseObject({
17
+ description: z.looseObject({ text: z.string().optional() }).optional(),
18
+ type: z.string().optional(),
19
+ })
20
+ .optional();
21
+ const Precipitation = z
22
+ .looseObject({
23
+ probability: z.looseObject({ percent: z.number().optional() }).optional(),
24
+ qpf: z
25
+ .looseObject({
26
+ quantity: z.number().optional(),
27
+ unit: z.string().optional(),
28
+ })
29
+ .optional(),
30
+ })
31
+ .optional();
32
+ const Interval = z
33
+ .looseObject({
34
+ startTime: z.string().optional(),
35
+ endTime: z.string().optional(),
36
+ })
37
+ .optional();
38
+ const Current = z.looseObject({
39
+ currentTime: z.string().optional(),
40
+ weatherCondition: Condition,
41
+ temperature: Temp,
42
+ feelsLikeTemperature: Temp,
43
+ relativeHumidity: z.number().optional(),
44
+ uvIndex: z.number().optional(),
45
+ cloudCover: z.number().optional(),
46
+ wind: z
47
+ .looseObject({
48
+ direction: z
49
+ .looseObject({
50
+ degrees: z.number().optional(),
51
+ cardinal: z.string().optional(),
52
+ })
53
+ .optional(),
54
+ speed: Speed,
55
+ gust: Speed,
56
+ })
57
+ .optional(),
58
+ });
59
+ const Half = z
60
+ .looseObject({ weatherCondition: Condition, precipitation: Precipitation })
61
+ .optional();
62
+ const Days = z.looseObject({
63
+ forecastDays: z
64
+ .array(z.looseObject({
65
+ interval: Interval,
66
+ displayDate: z
67
+ .looseObject({ year: z.number(), month: z.number(), day: z.number() })
68
+ .optional(),
69
+ daytimeForecast: Half,
70
+ nighttimeForecast: Half,
71
+ maxTemperature: Temp,
72
+ minTemperature: Temp,
73
+ sunEvents: z
74
+ .looseObject({
75
+ sunriseTime: z.string().optional(),
76
+ sunsetTime: z.string().optional(),
77
+ })
78
+ .optional(),
79
+ }))
80
+ .optional(),
81
+ });
82
+ const Hours = z.looseObject({
83
+ forecastHours: z
84
+ .array(z.looseObject({
85
+ interval: Interval,
86
+ uvIndex: z.number().optional(),
87
+ cloudCover: z.number().optional(),
88
+ }))
89
+ .optional(),
90
+ });
91
+ const Minutes = z.looseObject({
92
+ overallPredictionTimeframe: Interval,
93
+ segments: z
94
+ .array(z.looseObject({
95
+ timeFrame: Interval,
96
+ type: z.string().optional(),
97
+ probability: z.number().optional(),
98
+ qpf: z
99
+ .looseObject({
100
+ quantity: z.number().optional(),
101
+ unit: z.string().optional(),
102
+ })
103
+ .optional(),
104
+ intensity: z.string().optional(),
105
+ }))
106
+ .optional(),
107
+ });
108
+ /** Celsius, or undefined plus a note: the requests ask for METRIC, so another unit is a surprise worth seeing. */
109
+ const celsius = (t, what, odd) => {
110
+ if (t?.unit && t.unit !== "CELSIUS")
111
+ odd.push(`${what} in ${t.unit}`);
112
+ return t?.unit === "CELSIUS" || t?.unit === undefined
113
+ ? num(t?.degrees, what, odd)
114
+ : undefined;
115
+ };
116
+ const kmh = (s, what, odd) => {
117
+ if (s?.unit && s.unit !== "KILOMETERS_PER_HOUR")
118
+ odd.push(`${what} in ${s.unit}`);
119
+ return s?.unit === "KILOMETERS_PER_HOUR" || s?.unit === undefined
120
+ ? num(s?.value, what, odd)
121
+ : undefined;
122
+ };
123
+ const contains = (i, at) => Date.parse(i?.startTime ?? "") <= at && at < Date.parse(i?.endTime ?? "");
124
+ /**
125
+ * Map the three responses to slices. A day's rain chance is the higher of its day and night halves; its amount is their sum.
126
+ * With `place`, each day's `sunEvents` are checked against the computed sun (the owner); one more than 2 minutes out is an anomaly.
127
+ */
128
+ export function toSlices(current, days, hours, fetchedAt, forecastAt = fetchedAt, place) {
129
+ const odd = [];
130
+ const c = Current.parse(current);
131
+ const meta = (at) => ({
132
+ source: "google",
133
+ modelled: true,
134
+ at,
135
+ fetchedAt: new Date(fetchedAt).toISOString(),
136
+ });
137
+ const at = iso(c.currentTime);
138
+ if (c.currentTime !== undefined && !at)
139
+ odd.push(`currentTime not understood: ${c.currentTime}`);
140
+ const now = at
141
+ ? {
142
+ ...meta(at),
143
+ tempC: celsius(c.temperature, "temperature", odd),
144
+ apparentC: celsius(c.feelsLikeTemperature, "feels like", odd),
145
+ humidity: c.relativeHumidity,
146
+ windKmh: kmh(c.wind?.speed, "wind", odd),
147
+ windDir: c.wind?.direction?.cardinal,
148
+ windDeg: c.wind?.direction?.degrees,
149
+ gustKmh: kmh(c.wind?.gust, "gust", odd),
150
+ }
151
+ : undefined;
152
+ const hourly = (Hours.parse(hours).forecastHours ?? []).flatMap((h) => {
153
+ const at = iso(h.interval?.startTime);
154
+ if (!at)
155
+ odd.push(`forecast hour time not understood: ${h.interval?.startTime}`);
156
+ return at ? [{ at, cloudPct: h.cloudCover, uvIndex: h.uvIndex }] : [];
157
+ });
158
+ const sky = at
159
+ ? { ...meta(at), cloudPct: c.cloudCover, uvIndex: c.uvIndex, hours: hourly }
160
+ : undefined;
161
+ const list = Days.parse(days).forecastDays ?? [];
162
+ if (place)
163
+ for (const d of list) {
164
+ const off = sunDisagrees("google", place.latitude, place.longitude, {
165
+ sunrise: d.sunEvents?.sunriseTime,
166
+ sunset: d.sunEvents?.sunsetTime,
167
+ });
168
+ if (off)
169
+ odd.push(off);
170
+ }
171
+ const i = list.findIndex((d) => contains(d.interval, fetchedAt));
172
+ const day = (d) => {
173
+ if (!d?.displayDate)
174
+ return undefined;
175
+ const { year, month, day: dd } = d.displayDate;
176
+ const chances = [d.daytimeForecast, d.nighttimeForecast]
177
+ .map((h) => h?.precipitation?.probability?.percent)
178
+ .filter((p) => p !== undefined);
179
+ const amounts = [d.daytimeForecast, d.nighttimeForecast].map((h) => h?.precipitation?.qpf);
180
+ for (const q of amounts)
181
+ if (q?.unit && q.unit !== "MILLIMETERS")
182
+ odd.push(`rain in ${q.unit}`);
183
+ const mm = amounts.every((q) => q?.quantity !== undefined &&
184
+ (q.unit ?? "MILLIMETERS") === "MILLIMETERS")
185
+ ? amounts.reduce((s, q) => s + q.quantity, 0)
186
+ : undefined;
187
+ return {
188
+ date: `${year}-${String(month).padStart(2, "0")}-${String(dd).padStart(2, "0")}`,
189
+ minC: celsius(d.minTemperature, "minimum", odd),
190
+ maxC: celsius(d.maxTemperature, "maximum", odd),
191
+ rainChance: chances.length ? Math.max(...chances) : undefined,
192
+ rainMm: mm,
193
+ text: d.daytimeForecast?.weatherCondition?.description?.text,
194
+ code: d.daytimeForecast?.weatherCondition?.type,
195
+ };
196
+ };
197
+ const forecastIso = new Date(forecastAt).toISOString();
198
+ const forecast = list.length
199
+ ? {
200
+ ...meta(forecastIso),
201
+ fetchedAt: forecastIso,
202
+ today: i < 0 ? undefined : day(list[i]),
203
+ tomorrow: i < 0 ? undefined : day(list[i + 1]),
204
+ }
205
+ : undefined;
206
+ return {
207
+ now,
208
+ forecast,
209
+ sky,
210
+ anomaly: odd.length ? odd.join("; ") : undefined,
211
+ };
212
+ }
213
+ /**
214
+ * The minute forecast as a `rain` slice: the segments that start within `RAIN_HORIZON_MINUTES` of `fetchedAt`. A segment with an odd time is
215
+ * skipped and noted; a rain amount in a unit other than millimetres is left out and noted, never converted.
216
+ */
217
+ export function toRain(minutes, fetchedAt) {
218
+ const odd = [];
219
+ const parsed = Minutes.safeParse(minutes);
220
+ if (!parsed.success)
221
+ return {
222
+ anomaly: "minute forecast not understood: the response is not the documented shape",
223
+ };
224
+ const m = parsed.data;
225
+ const at = iso(m.overallPredictionTimeframe?.startTime);
226
+ if (!at)
227
+ odd.push(`minute forecast has no start time: ${m.overallPredictionTimeframe?.startTime}`);
228
+ const limit = fetchedAt + RAIN_HORIZON_MINUTES * 60_000;
229
+ const segments = (m.segments ?? []).flatMap((s) => {
230
+ const from = iso(s.timeFrame?.startTime);
231
+ const to = iso(s.timeFrame?.endTime);
232
+ if (!from || !to) {
233
+ odd.push(`minute forecast segment time not understood: ${s.timeFrame?.startTime}`);
234
+ return [];
235
+ }
236
+ if (Date.parse(from) >= limit)
237
+ return [];
238
+ if (s.qpf?.unit && s.qpf.unit !== "MILLIMETERS")
239
+ odd.push(`rain in ${s.qpf.unit}`);
240
+ if (s.intensity && !RAIN_INTENSITIES.includes(s.intensity))
241
+ odd.push(`rain intensity not understood: ${s.intensity}`);
242
+ const mm = s.qpf?.unit === undefined || s.qpf.unit === "MILLIMETERS"
243
+ ? num(s.qpf?.quantity, "rain amount", odd)
244
+ : undefined;
245
+ return [
246
+ {
247
+ from,
248
+ to,
249
+ type: s.type,
250
+ chance: s.probability,
251
+ mm,
252
+ intensity: s.intensity,
253
+ },
254
+ ];
255
+ });
256
+ const anomaly = odd.length ? odd.join("; ") : undefined;
257
+ if (!at || !segments.length)
258
+ return { anomaly };
259
+ return {
260
+ rain: {
261
+ source: "google",
262
+ modelled: true,
263
+ at,
264
+ fetchedAt: new Date(fetchedAt).toISOString(),
265
+ segments,
266
+ },
267
+ anomaly,
268
+ };
269
+ }
270
+ /**
271
+ * The shortest interval Google is read at: the minute forecast moves in 15-minute segments. Billed per call, 10,000 free a month across the key:
272
+ * rain every 15 minutes, current conditions every 30 (`CURRENT_MINUTES`), the day and hour forecasts hourly: 192 calls a day, about 5,900 a month.
273
+ */
274
+ export const MIN_POLL_MINUTES = 15;
275
+ export const CURRENT_MINUTES = 30;
276
+ /**
277
+ * Google Weather as a source: modelled `now`, `forecast` (10 days available, 2 read), `sky` without radiation, and `rain` from the
278
+ * minute forecast (Experimental at Google, so its failure is a `problem` on `rain` and never stops the other slices).
279
+ */
280
+ export function google(options) {
281
+ const fetcher = options.fetch ?? fetch;
282
+ const base = options.base ?? BASE;
283
+ let slices = {};
284
+ let rain;
285
+ let forecasts;
286
+ let forecastsDue = 0;
287
+ let slowDue = 0;
288
+ return {
289
+ name: "google",
290
+ owns: {
291
+ now: 30 * 60_000,
292
+ forecast: 6 * 3_600_000,
293
+ sky: 30 * 60_000,
294
+ rain: 15 * 60_000,
295
+ },
296
+ minPollMinutes: MIN_POLL_MINUTES,
297
+ async read({ place, at, pollMinutes, force, signal, }) {
298
+ const q = (extra) => `key=${encodeURIComponent(options.key)}&location.latitude=${place.latitude}&location.longitude=${place.longitude}${extra}`;
299
+ const json = (path) => getJson(fetcher, `${base}/${path}`, {
300
+ init: { headers: { "user-agent": USER_AGENT } },
301
+ signal,
302
+ });
303
+ // Started first and never throws: a failed minute forecast must not sink the rest.
304
+ const minutes = json(`forecast/minutes:lookup?${q("")}`).then((value) => ({ value }), (error) => ({ error: error }));
305
+ let problem;
306
+ let failed = false;
307
+ try {
308
+ if (force || at >= slowDue) {
309
+ const both = force || !forecasts || at >= forecastsDue;
310
+ const [current, days, hours] = await Promise.all([
311
+ json(`currentConditions:lookup?${q("")}`),
312
+ both
313
+ ? json(`forecast/days:lookup?${q("&days=2")}`)
314
+ : forecasts.days,
315
+ both
316
+ ? json(`forecast/hours:lookup?${q("&hours=6")}`)
317
+ : forecasts.hours,
318
+ ]);
319
+ if (both)
320
+ ((forecasts = { days, hours, at }),
321
+ (forecastsDue = nextPoll(at, HOURLY_MINUTES)));
322
+ slices = toSlices(current, days, hours, at, forecasts.at, place);
323
+ slowDue = nextPoll(at, Math.max(pollMinutes, CURRENT_MINUTES));
324
+ }
325
+ }
326
+ catch (error) {
327
+ const status = error.status;
328
+ if (status === 400 || status === 401 || status === 403)
329
+ return {
330
+ stop: `google: key rejected or request refused (HTTP ${status})`,
331
+ nextAt: Infinity,
332
+ };
333
+ problem = `google: ${error.message}`; // the old slices stay; the minute forecast below is still used
334
+ failed = true;
335
+ }
336
+ const m = await minutes;
337
+ let rainOdd;
338
+ if ("error" in m)
339
+ problem = [problem, `google minute forecast: ${m.error.message}`]
340
+ .filter(Boolean)
341
+ .join("; ");
342
+ else {
343
+ const r = toRain(m.value, at);
344
+ rainOdd = r.anomaly;
345
+ if (r.rain)
346
+ rain = r.rain;
347
+ else
348
+ problem = [problem, "google minute forecast: no segments"]
349
+ .filter(Boolean)
350
+ .join("; ");
351
+ }
352
+ const anomaly = [failed ? undefined : slices.anomaly, rainOdd]
353
+ .filter(Boolean)
354
+ .join("; ") || undefined;
355
+ return {
356
+ ...slices,
357
+ rain,
358
+ anomaly,
359
+ problem,
360
+ nextAt: nextPoll(at, pollMinutes),
361
+ };
362
+ },
363
+ };
364
+ }
@@ -0,0 +1,53 @@
1
+ import { type Clock } from "../../clock.ts";
2
+ import type { Integration } from "../../home.ts";
3
+ import { type Manifest } from "../../manifest.ts";
4
+ import { type BlockDef, type DayBlocks } from "./blocks.ts";
5
+ import { type RainSoon } from "./rain.ts";
6
+ import { type Slices, type WeatherSource } from "./source.ts";
7
+ import { type SunNow } from "./sun.ts";
8
+ export { type Condition, type ForecastDay, type ForecastHour, mergeWeather, nextPoll, type Place, type RainSegment, type ReadRequest, type SkyHour, type SliceMeta, type Slices, type SourceReading, type WeatherForecast, type WeatherHourly, type WeatherNow, type WeatherRain, type WeatherSky, type WeatherSource, } from "./source.ts";
9
+ export { type SunNext, type SunNow, sunNow } from "./sun.ts";
10
+ export { type RainSoon, rainSoon } from "./rain.ts";
11
+ export { type BlockDef, BLOCKS, blockText, type DayBlock, type DayBlocks, dayBlocks, } from "./blocks.ts";
12
+ export { bom, type BomOptions } from "./bom.ts";
13
+ export { openMeteo, type OpenMeteoOptions } from "./open-meteo.ts";
14
+ export { google, type GoogleOptions } from "./google.ts";
15
+ /**
16
+ * Where the home is, and the sources in preference order: each slice comes from the first source with a fresh one.
17
+ * The default is `[openMeteo()]`, which works anywhere with no key. In Australia put `bom({ fetch: ftpFetch(), forecastLocation })`
18
+ * first: it needs the FTP transport, so it is opt-in. `pollMinutes` (default 15) is how often observations and sky are read;
19
+ * forecasts follow their issue times. `timeZone` (IANA, the runtime's when left out) is where the home's day and its times are.
20
+ * `blocks` are the parts of the day `state.blocks` is cut into (`BLOCKS` by default).
21
+ */
22
+ export type WeatherConfig = {
23
+ latitude: number;
24
+ longitude: number;
25
+ sources?: WeatherSource[];
26
+ pollMinutes?: number;
27
+ timeZone?: string;
28
+ blocks?: readonly BlockDef[];
29
+ clock?: Clock;
30
+ };
31
+ /** The weather in the snapshot: one slice each from the preferred fresh source, the sun, and how each source is doing. */
32
+ export type WeatherState = Slices & {
33
+ sun: SunNow;
34
+ rainSoon?: RainSoon;
35
+ blocks?: DayBlocks;
36
+ timeZone?: string;
37
+ sources: Record<string, {
38
+ read: boolean;
39
+ problem?: string;
40
+ stopped?: string;
41
+ anomaly?: string;
42
+ }>;
43
+ };
44
+ /** Outside weather as a home integration. Each source polls on its own cadence; the sun is computed, never fetched. */
45
+ export declare function weather(config: WeatherConfig): Integration<WeatherState, "weather">;
46
+ /** "rain in 30 min, ends in 55 min, 70%, 0.8 mm, moderate (google)", "raining, ends in 25 min", or "no rain for 2 h"; nothing when the forecast covers none of the window. */
47
+ export declare function rainText(rain: Slices["rain"], r: RainSoon | undefined): string;
48
+ /** The weather in one line, every part named by its source and time, never mixed. */
49
+ export declare function weatherText(w: Slices & {
50
+ rainSoon?: RainSoon;
51
+ timeZone?: string;
52
+ }, sun: SunNow): string;
53
+ export declare const manifest: Manifest<WeatherState>;
@@ -0,0 +1,267 @@
1
+ import { localTime, realClock } from "../../clock.js";
2
+ import { field } from "../../manifest.js";
3
+ import { poller } from "../../poll.js";
4
+ import { blockText, checkBlocks, dayBlocks, } from "./blocks.js";
5
+ import { openMeteo } from "./open-meteo.js";
6
+ import { rainSoon } from "./rain.js";
7
+ import { DEFAULT_POLL_MINUTES, RAIN_HORIZON_MINUTES, sourcePollMinutes, mergeWeather, } from "./source.js";
8
+ import { sunNow } from "./sun.js";
9
+ export { mergeWeather, nextPoll, } from "./source.js";
10
+ export { sunNow } from "./sun.js";
11
+ export { rainSoon } from "./rain.js";
12
+ export { BLOCKS, blockText, dayBlocks, } from "./blocks.js";
13
+ export { bom } from "./bom.js";
14
+ export { openMeteo } from "./open-meteo.js";
15
+ export { google } from "./google.js";
16
+ /** The sun moves about a degree every four minutes; recompute it this often between source reads. */
17
+ const SUN_MS = 5 * 60_000;
18
+ /** However soon a source asks to be called again, never sooner than this. */
19
+ const MIN_POLL_MS = 60_000;
20
+ const SLICE_NAMES = [
21
+ "now",
22
+ "forecast",
23
+ "hourly",
24
+ "sky",
25
+ "rain",
26
+ ];
27
+ /** Outside weather as a home integration. Each source polls on its own cadence; the sun is computed, never fetched. */
28
+ export function weather(config) {
29
+ const sources = config.sources ?? [openMeteo()];
30
+ const clock = config.clock ?? realClock;
31
+ const pollMinutes = config.pollMinutes ?? DEFAULT_POLL_MINUTES;
32
+ if (!(pollMinutes >= 1))
33
+ throw new Error(`weather: pollMinutes must be at least 1, got ${config.pollMinutes}`);
34
+ if (config.blocks)
35
+ checkBlocks(config.blocks); // a bad block fails here, not at every report
36
+ const place = { latitude: config.latitude, longitude: config.longitude };
37
+ const readings = new Map();
38
+ const fresh = new Map(); // whether a source's latest read brought anything new, rather than its last good slices again
39
+ let readAt; // the last read that brought something new: the part's `lastGoodAt`, so a re-report is not a read
40
+ const polls = new Map(sources.map((s) => [s.name, poller(clock)]));
41
+ let answer; // reports now: for a refresh no source will answer
42
+ return {
43
+ name: "weather",
44
+ /**
45
+ * Read every source now, whatever its schedule, forecasts included (`force`). A source with a floor (a billed one,
46
+ * Google) is not forced, and is skipped when its last read is younger than its floor.
47
+ */
48
+ refresh() {
49
+ for (const poll of polls.values())
50
+ poll.nudge();
51
+ if (sources.every((s) => readings.get(s.name)?.stop))
52
+ answer?.(); // every source has stopped: none will read, so say so now
53
+ },
54
+ async run(signal, update) {
55
+ const emit = () => {
56
+ const at = clock.now();
57
+ const slices = mergeWeather(sources.map((source) => ({
58
+ source,
59
+ reading: readings.get(source.name),
60
+ })), at, pollMinutes);
61
+ const report = Object.fromEntries(sources.map((s) => {
62
+ const r = readings.get(s.name);
63
+ return [
64
+ s.name,
65
+ {
66
+ read: r !== undefined,
67
+ problem: r?.problem,
68
+ stopped: r?.stop,
69
+ anomaly: r?.anomaly,
70
+ },
71
+ ];
72
+ }));
73
+ // Connected while some source's latest read brought something new; its older slices alone are a failed read, kept as state.
74
+ const status = sources.some((s) => fresh.get(s.name))
75
+ ? "connected"
76
+ : sources.every((s) => readings.get(s.name)?.stop)
77
+ ? "unavailable"
78
+ : "disconnected";
79
+ const notes = (k) => Object.values(report)
80
+ .map((r) => r[k])
81
+ .filter(Boolean)
82
+ .join("; ") || undefined;
83
+ const detail = [notes("stopped"), notes("problem")].filter(Boolean).join("; ") ||
84
+ undefined;
85
+ const lastGoodAt = status === "connected" && readAt !== undefined
86
+ ? new Date(readAt).toISOString()
87
+ : undefined;
88
+ update({
89
+ status,
90
+ detail,
91
+ anomaly: notes("anomaly"),
92
+ lastGoodAt,
93
+ state: {
94
+ ...slices,
95
+ sun: sunNow(place.latitude, place.longitude, at),
96
+ rainSoon: rainSoon(slices.rain, at),
97
+ blocks: dayBlocks(slices.hourly, at, config.timeZone, config.blocks),
98
+ timeZone: config.timeZone,
99
+ sources: report,
100
+ },
101
+ });
102
+ };
103
+ const poll = async (source) => {
104
+ const floorMs = (source.minPollMinutes ?? 0) * 60_000;
105
+ let force = false;
106
+ while (!signal.aborted) {
107
+ const at = clock.now();
108
+ const reading = await source.read({
109
+ place,
110
+ at,
111
+ pollMinutes: sourcePollMinutes(source, pollMinutes),
112
+ force,
113
+ signal,
114
+ });
115
+ if (signal.aborted)
116
+ return; // an aborted read is not a problem to report
117
+ readings.set(source.name, reading);
118
+ const isFresh = SLICE_NAMES.some((k) => reading[k]?.fetchedAt
119
+ ? Date.parse(reading[k].fetchedAt) >= at
120
+ : false);
121
+ fresh.set(source.name, isFresh);
122
+ if (isFresh)
123
+ readAt = Math.max(readAt ?? at, at);
124
+ emit();
125
+ if (reading.stop)
126
+ return;
127
+ for (;;) {
128
+ const woken = await polls
129
+ .get(source.name)
130
+ .wait(Math.max(reading.nextAt - clock.now(), MIN_POLL_MS), signal);
131
+ if (woken && clock.now() - at < floorMs) {
132
+ emit(); // a refresh never beats a billed source's floor, but it gets an answer: what there is now
133
+ continue;
134
+ }
135
+ force = woken && !floorMs;
136
+ break;
137
+ }
138
+ }
139
+ };
140
+ answer = emit;
141
+ const sun = async () => {
142
+ while (!signal.aborted) {
143
+ await clock.sleep(SUN_MS, signal);
144
+ if (!signal.aborted)
145
+ emit();
146
+ }
147
+ };
148
+ await Promise.all([...sources.map(poll), sun()]);
149
+ },
150
+ };
151
+ }
152
+ /** "20:20" in the home's time zone (the runtime's when unset), or "?". */
153
+ const hhmmIn = (iso, timeZone) => iso ? localTime(Date.parse(iso), timeZone) : "?";
154
+ const day = (d) => d
155
+ ? [
156
+ d.minC === undefined && d.maxC === undefined
157
+ ? ""
158
+ : `${d.minC ?? "?"}–${d.maxC ?? "?"} °C`,
159
+ d.text?.replace(/\.$/, "").toLowerCase() ?? "",
160
+ d.rainChance === undefined ? "" : `${d.rainChance}% chance of rain`,
161
+ ]
162
+ .filter(Boolean)
163
+ .join(" ")
164
+ : "?";
165
+ /** "rain in 30 min, ends in 55 min, 70%, 0.8 mm, moderate (google)", "raining, ends in 25 min", or "no rain for 2 h"; nothing when the forecast covers none of the window. */
166
+ export function rainText(rain, r) {
167
+ if (!r || !rain)
168
+ return "";
169
+ const covered = r.coveredMin >= RAIN_HORIZON_MINUTES
170
+ ? `${RAIN_HORIZON_MINUTES / 60} h`
171
+ : `${r.coveredMin} min`;
172
+ if (!r.wet)
173
+ return `no rain for ${covered} (${rain.source})`;
174
+ const end = r.endsInMin !== undefined
175
+ ? `ends in ${r.endsInMin} min`
176
+ : r.startsInMin === 0
177
+ ? `still at ${covered}`
178
+ : "";
179
+ const parts = [
180
+ r.startsInMin === 0 ? "raining" : `rain in ${r.startsInMin} min`,
181
+ end,
182
+ field(r.chance, (v) => `${v}%`),
183
+ field(r.mm, (v) => `${v} mm`),
184
+ r.intensity && r.intensity !== "NO_INTENSITY"
185
+ ? r.intensity.toLowerCase()
186
+ : "",
187
+ ];
188
+ return `${parts.filter(Boolean).join(", ")} (${rain.source})`;
189
+ }
190
+ /** The weather in one line, every part named by its source and time, never mixed. */
191
+ export function weatherText(w, sun) {
192
+ const n = w.now;
193
+ const hhmm = (iso) => hhmmIn(iso, w.timeZone);
194
+ const readings = n
195
+ ? [
196
+ field(n.tempC, (v) => `${v} °C`),
197
+ field(n.apparentC, (v) => `feels ${v}`),
198
+ field(n.humidity, (v) => `${v}%`),
199
+ field(n.rainMm, (v) => `${v} mm since 9am to ${hhmm(n.rainTo)}`),
200
+ ]
201
+ .filter(Boolean)
202
+ .join(", ")
203
+ : "";
204
+ const now = n
205
+ ? `${n.station ? `${n.station.name} (${n.station.distanceKm.toFixed(1)} km)` : n.source} ${hhmm(n.at)}${n.modelled ? " modelled" : ""}: ${readings}`
206
+ : "";
207
+ const f = w.forecast;
208
+ const forecast = f
209
+ ? `today ${day(f.today)}, tomorrow ${day(f.tomorrow)} (${f.source}${f.modelled ? " modelled" : ""})`
210
+ : "";
211
+ const k = w.sky;
212
+ const skyParts = k
213
+ ? [
214
+ field(k.cloudPct, (v) => `cloud ${v}%`),
215
+ field(k.uvIndex, (v) => `UV ${v}`),
216
+ field(k.radiationWm2, (v) => `${v} W/m²`),
217
+ ]
218
+ .filter(Boolean)
219
+ .join(", ")
220
+ : "";
221
+ const sky = k ? `${skyParts} (${k.source})` : "";
222
+ const sunText = `sun ${Math.round(sun.altitudeDeg)}°, ${sun.rising ? "rising" : "setting"}, ${sun.daylight ? `sets ${hhmm(sun.next.sunset)}` : `rises ${hhmm(sun.next.sunrise)}`}`;
223
+ return [now, forecast, sky, rainText(w.rain, w.rainSoon), sunText]
224
+ .filter(Boolean)
225
+ .join(" · ");
226
+ }
227
+ /** "today: afternoon …; evening …" and the same for tomorrow, when there is an hourly forecast. */
228
+ const blockLines = (state) => state.blocks
229
+ ? ["today", "tomorrow"]
230
+ .filter((d) => state.blocks[d].length)
231
+ .map((d) => `${d}: ${state.blocks[d].map((b) => blockText(b, state.timeZone)).join("; ")} (${state.blocks.source})`)
232
+ : [];
233
+ export const manifest = {
234
+ name: "weather",
235
+ title: "Weather",
236
+ iotClass: "cloud_polling",
237
+ pollSeconds: DEFAULT_POLL_MINUTES * 60,
238
+ access: "BOM anonymous FTP, Open-Meteo, Google with GOOGLE_WEATHER_KEY; sun computed",
239
+ env: [
240
+ {
241
+ name: "HOME_LATITUDE",
242
+ example: "-33.8688",
243
+ what: "the home's position for weather and the sun (this one is Sydney CBD)",
244
+ },
245
+ { name: "HOME_LONGITUDE", example: "151.2093", what: "" },
246
+ {
247
+ name: "HOME_TIMEZONE",
248
+ example: "Australia/Sydney",
249
+ optional: true,
250
+ what: "the home's IANA time zone for weather times and day blocks; this machine's when unset",
251
+ },
252
+ {
253
+ name: "HOME_BOM_FORECAST",
254
+ example: "Sydney",
255
+ optional: true,
256
+ what: "BOM précis location by name or aac; unset, the forecast comes from Open-Meteo",
257
+ },
258
+ {
259
+ name: "GOOGLE_WEATHER_KEY",
260
+ example: "",
261
+ optional: true,
262
+ what: "Maps Platform key with the Weather API enabled",
263
+ },
264
+ ],
265
+ lines: ({ state }) => state ? [weatherText(state, state.sun), ...blockLines(state)] : [],
266
+ record: ({ state }) => state ? { ...state, line: weatherText(state, state.sun) } : {},
267
+ };