@khalido/home 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +25 -0
- package/CHANGELOG.md +47 -0
- package/LICENSE +21 -0
- package/README.md +77 -2
- package/dist/clock.d.ts +17 -0
- package/dist/clock.js +43 -0
- package/dist/config.d.ts +273 -0
- package/dist/config.js +392 -0
- package/dist/connector.d.ts +34 -0
- package/dist/connector.js +47 -0
- package/dist/connectors/amber/client.d.ts +61 -0
- package/dist/connectors/amber/client.js +106 -0
- package/dist/connectors/amber/index.d.ts +30 -0
- package/dist/connectors/amber/index.js +94 -0
- package/dist/connectors/cast/codec.d.ts +38 -0
- package/dist/connectors/cast/codec.js +102 -0
- package/dist/connectors/cast/index.d.ts +60 -0
- package/dist/connectors/cast/index.js +428 -0
- package/dist/connectors/cast/schemas.d.ts +167 -0
- package/dist/connectors/cast/schemas.js +87 -0
- package/dist/connectors/cast/serve.d.ts +18 -0
- package/dist/connectors/cast/serve.js +57 -0
- package/dist/connectors/cast/state.d.ts +62 -0
- package/dist/connectors/cast/state.js +133 -0
- package/dist/connectors/cast/tls.d.ts +3 -0
- package/dist/connectors/cast/tls.js +67 -0
- package/dist/connectors/cast/writes.d.ts +80 -0
- package/dist/connectors/cast/writes.js +390 -0
- package/dist/connectors/daikin/client.d.ts +65 -0
- package/dist/connectors/daikin/client.js +221 -0
- package/dist/connectors/daikin/index.d.ts +41 -0
- package/dist/connectors/daikin/index.js +108 -0
- package/dist/connectors/enphase/client.d.ts +65 -0
- package/dist/connectors/enphase/client.js +173 -0
- package/dist/connectors/enphase/index.d.ts +43 -0
- package/dist/connectors/enphase/index.js +174 -0
- package/dist/connectors/enphase/schemas.d.ts +51 -0
- package/dist/connectors/enphase/schemas.js +34 -0
- package/dist/connectors/hue/client.d.ts +48 -0
- package/dist/connectors/hue/client.js +95 -0
- package/dist/connectors/hue/events.d.ts +41 -0
- package/dist/connectors/hue/events.js +156 -0
- package/dist/connectors/hue/index.d.ts +42 -0
- package/dist/connectors/hue/index.js +205 -0
- package/dist/connectors/hue/schemas.d.ts +65 -0
- package/dist/connectors/hue/schemas.js +48 -0
- package/dist/connectors/hue/state.d.ts +43 -0
- package/dist/connectors/hue/state.js +84 -0
- package/dist/connectors/printers/client.d.ts +28 -0
- package/dist/connectors/printers/client.js +74 -0
- package/dist/connectors/printers/codec.d.ts +76 -0
- package/dist/connectors/printers/codec.js +246 -0
- package/dist/connectors/printers/found.d.ts +15 -0
- package/dist/connectors/printers/found.js +35 -0
- package/dist/connectors/printers/index.d.ts +52 -0
- package/dist/connectors/printers/index.js +282 -0
- package/dist/connectors/printers/state.d.ts +60 -0
- package/dist/connectors/printers/state.js +99 -0
- package/dist/connectors/spotify/changes.d.ts +32 -0
- package/dist/connectors/spotify/changes.js +84 -0
- package/dist/connectors/spotify/client.d.ts +199 -0
- package/dist/connectors/spotify/client.js +270 -0
- package/dist/connectors/spotify/index.d.ts +92 -0
- package/dist/connectors/spotify/index.js +754 -0
- package/dist/connectors/up/client.d.ts +183 -0
- package/dist/connectors/up/client.js +308 -0
- package/dist/connectors/up/index.d.ts +91 -0
- package/dist/connectors/up/index.js +213 -0
- package/dist/connectors/up/spend.d.ts +78 -0
- package/dist/connectors/up/spend.js +213 -0
- package/dist/connectors/up/webhook.d.ts +13 -0
- package/dist/connectors/up/webhook.js +32 -0
- package/dist/connectors/weather/blocks.d.ts +51 -0
- package/dist/connectors/weather/blocks.js +106 -0
- package/dist/connectors/weather/bom.d.ts +37 -0
- package/dist/connectors/weather/bom.js +237 -0
- package/dist/connectors/weather/ftp.d.ts +5 -0
- package/dist/connectors/weather/ftp.js +46 -0
- package/dist/connectors/weather/google.d.ts +31 -0
- package/dist/connectors/weather/google.js +364 -0
- package/dist/connectors/weather/index.d.ts +53 -0
- package/dist/connectors/weather/index.js +280 -0
- package/dist/connectors/weather/open-meteo.d.ts +15 -0
- package/dist/connectors/weather/open-meteo.js +285 -0
- package/dist/connectors/weather/rain.d.ts +19 -0
- package/dist/connectors/weather/rain.js +51 -0
- package/dist/connectors/weather/source.d.ts +149 -0
- package/dist/connectors/weather/source.js +77 -0
- package/dist/connectors/weather/sun.d.ts +48 -0
- package/dist/connectors/weather/sun.js +65 -0
- package/dist/discover.d.ts +197 -0
- package/dist/discover.js +452 -0
- package/dist/energy.d.ts +61 -0
- package/dist/energy.js +162 -0
- package/dist/finders-entry.d.ts +1 -0
- package/dist/finders-entry.js +2 -0
- package/dist/finders.d.ts +48 -0
- package/dist/finders.js +176 -0
- package/dist/health.d.ts +28 -0
- package/dist/health.js +35 -0
- package/dist/home.d.ts +81 -0
- package/dist/home.js +114 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +11 -0
- package/dist/lane.d.ts +17 -0
- package/dist/lane.js +74 -0
- package/dist/locate.d.ts +27 -0
- package/dist/locate.js +59 -0
- package/dist/manifest.d.ts +31 -0
- package/dist/manifest.js +4 -0
- package/dist/manifests.d.ts +5 -0
- package/dist/manifests.js +23 -0
- package/dist/node-fetch.d.ts +9 -0
- package/dist/node-fetch.js +25 -0
- package/dist/poll.d.ts +10 -0
- package/dist/poll.js +33 -0
- package/docs/cast.md +133 -0
- package/docs/cli.md +41 -0
- package/docs/config.md +36 -0
- package/docs/decisions.md +150 -0
- package/docs/devices.md +128 -0
- package/docs/enphase-notes.md +24 -0
- package/docs/google.md +92 -0
- package/docs/ideas.md +69 -0
- package/docs/matter.md +55 -0
- package/docs/performance.md +12 -0
- package/docs/philosophy.md +23 -0
- package/docs/up.md +84 -0
- package/docs/using.md +65 -0
- package/docs/weather-probe.md +63 -0
- package/examples/home.jsonc +74 -0
- package/examples/home.svg +76 -0
- package/home.schema.json +474 -0
- package/llms.txt +32 -0
- package/package.json +143 -4
- package/skills/draw-home/README.md +25 -0
- package/skills/draw-home/SKILL.md +80 -0
- package/skills/use-home/SKILL.md +86 -0
- package/src/cli/cache.ts +29 -0
- package/src/cli/cast.ts +172 -0
- package/src/cli/config.ts +52 -0
- package/src/cli/context.ts +118 -0
- package/src/cli/discover.ts +182 -0
- package/src/cli/enphase.ts +62 -0
- package/src/cli/hue.ts +124 -0
- package/src/cli/locate.ts +47 -0
- package/src/cli/main.ts +182 -0
- package/src/cli/printers.ts +21 -0
- package/src/cli/probe.ts +274 -0
- package/src/cli/spotify.ts +76 -0
- package/src/cli/status.ts +228 -0
- package/src/cli/up.ts +350 -0
- package/src/clock.ts +62 -0
- package/src/config.ts +578 -0
- package/src/connector.ts +77 -0
- package/src/connectors/amber/README.md +40 -0
- package/src/connectors/amber/client.ts +157 -0
- package/src/connectors/amber/index.ts +118 -0
- package/src/connectors/cast/README.md +44 -0
- package/src/connectors/cast/codec.ts +126 -0
- package/src/connectors/cast/index.ts +552 -0
- package/src/connectors/cast/schemas.ts +101 -0
- package/src/connectors/cast/serve.ts +82 -0
- package/src/connectors/cast/state.ts +203 -0
- package/src/connectors/cast/tls.ts +100 -0
- package/src/connectors/cast/writes.ts +572 -0
- package/src/connectors/daikin/README.md +78 -0
- package/src/connectors/daikin/client.ts +295 -0
- package/src/connectors/daikin/index.ts +145 -0
- package/src/connectors/enphase/README.md +64 -0
- package/src/connectors/enphase/client.ts +244 -0
- package/src/connectors/enphase/index.ts +241 -0
- package/src/connectors/enphase/schemas.ts +45 -0
- package/src/connectors/hue/README.md +50 -0
- package/src/connectors/hue/client.ts +157 -0
- package/src/connectors/hue/events.ts +182 -0
- package/src/connectors/hue/index.ts +261 -0
- package/src/connectors/hue/schemas.ts +60 -0
- package/src/connectors/hue/state.ts +124 -0
- package/src/connectors/printers/README.md +46 -0
- package/src/connectors/printers/client.ts +114 -0
- package/src/connectors/printers/codec.ts +292 -0
- package/src/connectors/printers/found.ts +46 -0
- package/src/connectors/printers/index.ts +385 -0
- package/src/connectors/printers/state.ts +182 -0
- package/src/connectors/spotify/README.md +83 -0
- package/src/connectors/spotify/changes.ts +127 -0
- package/src/connectors/spotify/client.ts +387 -0
- package/src/connectors/spotify/index.ts +937 -0
- package/src/connectors/up/README.md +35 -0
- package/src/connectors/up/client.ts +518 -0
- package/src/connectors/up/index.ts +370 -0
- package/src/connectors/up/spend.ts +280 -0
- package/src/connectors/up/webhook.ts +62 -0
- package/src/connectors/weather/README.md +84 -0
- package/src/connectors/weather/blocks.ts +173 -0
- package/src/connectors/weather/bom.ts +344 -0
- package/src/connectors/weather/ftp.ts +48 -0
- package/src/connectors/weather/google.ts +425 -0
- package/src/connectors/weather/index.ts +396 -0
- package/src/connectors/weather/open-meteo.ts +309 -0
- package/src/connectors/weather/rain.ts +73 -0
- package/src/connectors/weather/source.ts +236 -0
- package/src/connectors/weather/sun.ts +114 -0
- package/src/discover.ts +638 -0
- package/src/energy.ts +221 -0
- package/src/finders-entry.ts +2 -0
- package/src/finders.ts +250 -0
- package/src/health.ts +74 -0
- package/src/home.ts +205 -0
- package/src/index.ts +62 -0
- package/src/lane.ts +110 -0
- package/src/locate.ts +85 -0
- package/src/manifest.ts +43 -0
- package/src/manifests.ts +27 -0
- package/src/node-fetch.ts +41 -0
- package/src/poll.ts +33 -0
|
@@ -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>;
|