@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,425 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { type ConnectorError, getJson } from "../../connector.ts";
|
|
3
|
+
import { RAIN_INTENSITIES } from "./rain.ts";
|
|
4
|
+
import { sunDisagrees } from "./sun.ts";
|
|
5
|
+
import {
|
|
6
|
+
type ForecastDay,
|
|
7
|
+
HOURLY_MINUTES,
|
|
8
|
+
iso,
|
|
9
|
+
nextPoll,
|
|
10
|
+
num,
|
|
11
|
+
type Place,
|
|
12
|
+
RAIN_HORIZON_MINUTES,
|
|
13
|
+
type SourceReading,
|
|
14
|
+
USER_AGENT,
|
|
15
|
+
type WeatherRain,
|
|
16
|
+
type WeatherSource,
|
|
17
|
+
} from "./source.ts";
|
|
18
|
+
|
|
19
|
+
// Google's Weather API (Maps Platform, https://developers.google.com/maps/documentation/weather): needs a key with
|
|
20
|
+
// billing. Built from the docs' example responses, checked live against Sydney on 2026-10-13. Every slice is a model.
|
|
21
|
+
export const BASE = "https://weather.googleapis.com/v1";
|
|
22
|
+
|
|
23
|
+
const Temp = z
|
|
24
|
+
.looseObject({ degrees: z.number().optional(), unit: z.string().optional() })
|
|
25
|
+
.optional();
|
|
26
|
+
const Speed = z
|
|
27
|
+
.looseObject({ value: z.number().optional(), unit: z.string().optional() })
|
|
28
|
+
.optional();
|
|
29
|
+
const Condition = z
|
|
30
|
+
.looseObject({
|
|
31
|
+
description: z.looseObject({ text: z.string().optional() }).optional(),
|
|
32
|
+
type: z.string().optional(),
|
|
33
|
+
})
|
|
34
|
+
.optional();
|
|
35
|
+
const Precipitation = z
|
|
36
|
+
.looseObject({
|
|
37
|
+
probability: z.looseObject({ percent: z.number().optional() }).optional(),
|
|
38
|
+
qpf: z
|
|
39
|
+
.looseObject({
|
|
40
|
+
quantity: z.number().optional(),
|
|
41
|
+
unit: z.string().optional(),
|
|
42
|
+
})
|
|
43
|
+
.optional(),
|
|
44
|
+
})
|
|
45
|
+
.optional();
|
|
46
|
+
const Interval = z
|
|
47
|
+
.looseObject({
|
|
48
|
+
startTime: z.string().optional(),
|
|
49
|
+
endTime: z.string().optional(),
|
|
50
|
+
})
|
|
51
|
+
.optional();
|
|
52
|
+
|
|
53
|
+
const Current = z.looseObject({
|
|
54
|
+
currentTime: z.string().optional(),
|
|
55
|
+
weatherCondition: Condition,
|
|
56
|
+
temperature: Temp,
|
|
57
|
+
feelsLikeTemperature: Temp,
|
|
58
|
+
relativeHumidity: z.number().optional(),
|
|
59
|
+
uvIndex: z.number().optional(),
|
|
60
|
+
cloudCover: z.number().optional(),
|
|
61
|
+
wind: z
|
|
62
|
+
.looseObject({
|
|
63
|
+
direction: z
|
|
64
|
+
.looseObject({
|
|
65
|
+
degrees: z.number().optional(),
|
|
66
|
+
cardinal: z.string().optional(),
|
|
67
|
+
})
|
|
68
|
+
.optional(),
|
|
69
|
+
speed: Speed,
|
|
70
|
+
gust: Speed,
|
|
71
|
+
})
|
|
72
|
+
.optional(),
|
|
73
|
+
});
|
|
74
|
+
const Half = z
|
|
75
|
+
.looseObject({ weatherCondition: Condition, precipitation: Precipitation })
|
|
76
|
+
.optional();
|
|
77
|
+
const Days = z.looseObject({
|
|
78
|
+
forecastDays: z
|
|
79
|
+
.array(
|
|
80
|
+
z.looseObject({
|
|
81
|
+
interval: Interval,
|
|
82
|
+
displayDate: z
|
|
83
|
+
.looseObject({ year: z.number(), month: z.number(), day: z.number() })
|
|
84
|
+
.optional(),
|
|
85
|
+
daytimeForecast: Half,
|
|
86
|
+
nighttimeForecast: Half,
|
|
87
|
+
maxTemperature: Temp,
|
|
88
|
+
minTemperature: Temp,
|
|
89
|
+
sunEvents: z
|
|
90
|
+
.looseObject({
|
|
91
|
+
sunriseTime: z.string().optional(),
|
|
92
|
+
sunsetTime: z.string().optional(),
|
|
93
|
+
})
|
|
94
|
+
.optional(),
|
|
95
|
+
}),
|
|
96
|
+
)
|
|
97
|
+
.optional(),
|
|
98
|
+
});
|
|
99
|
+
const Hours = z.looseObject({
|
|
100
|
+
forecastHours: z
|
|
101
|
+
.array(
|
|
102
|
+
z.looseObject({
|
|
103
|
+
interval: Interval,
|
|
104
|
+
uvIndex: z.number().optional(),
|
|
105
|
+
cloudCover: z.number().optional(),
|
|
106
|
+
}),
|
|
107
|
+
)
|
|
108
|
+
.optional(),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const Minutes = z.looseObject({
|
|
112
|
+
overallPredictionTimeframe: Interval,
|
|
113
|
+
segments: z
|
|
114
|
+
.array(
|
|
115
|
+
z.looseObject({
|
|
116
|
+
timeFrame: Interval,
|
|
117
|
+
type: z.string().optional(),
|
|
118
|
+
probability: z.number().optional(),
|
|
119
|
+
qpf: z
|
|
120
|
+
.looseObject({
|
|
121
|
+
quantity: z.number().optional(),
|
|
122
|
+
unit: z.string().optional(),
|
|
123
|
+
})
|
|
124
|
+
.optional(),
|
|
125
|
+
intensity: z.string().optional(),
|
|
126
|
+
}),
|
|
127
|
+
)
|
|
128
|
+
.optional(),
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
/** Celsius, or undefined plus a note: the requests ask for METRIC, so another unit is a surprise worth seeing. */
|
|
132
|
+
const celsius = (t: z.infer<typeof Temp>, what: string, odd: string[]) => {
|
|
133
|
+
if (t?.unit && t.unit !== "CELSIUS") odd.push(`${what} in ${t.unit}`);
|
|
134
|
+
return t?.unit === "CELSIUS" || t?.unit === undefined
|
|
135
|
+
? num(t?.degrees, what, odd)
|
|
136
|
+
: undefined;
|
|
137
|
+
};
|
|
138
|
+
const kmh = (s: z.infer<typeof Speed>, what: string, odd: string[]) => {
|
|
139
|
+
if (s?.unit && s.unit !== "KILOMETERS_PER_HOUR")
|
|
140
|
+
odd.push(`${what} in ${s.unit}`);
|
|
141
|
+
return s?.unit === "KILOMETERS_PER_HOUR" || s?.unit === undefined
|
|
142
|
+
? num(s?.value, what, odd)
|
|
143
|
+
: undefined;
|
|
144
|
+
};
|
|
145
|
+
const contains = (i: z.infer<typeof Interval>, at: number) =>
|
|
146
|
+
Date.parse(i?.startTime ?? "") <= at && at < Date.parse(i?.endTime ?? "");
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* 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.
|
|
150
|
+
* With `place`, each day's `sunEvents` are checked against the computed sun (the owner); one more than 2 minutes out is an anomaly.
|
|
151
|
+
*/
|
|
152
|
+
export function toSlices(
|
|
153
|
+
current: unknown,
|
|
154
|
+
days: unknown,
|
|
155
|
+
hours: unknown,
|
|
156
|
+
fetchedAt: number,
|
|
157
|
+
forecastAt = fetchedAt,
|
|
158
|
+
place?: Place,
|
|
159
|
+
): Omit<SourceReading, "nextAt"> {
|
|
160
|
+
const odd: string[] = [];
|
|
161
|
+
const c = Current.parse(current);
|
|
162
|
+
const meta = (at: string) => ({
|
|
163
|
+
source: "google",
|
|
164
|
+
modelled: true,
|
|
165
|
+
at,
|
|
166
|
+
fetchedAt: new Date(fetchedAt).toISOString(),
|
|
167
|
+
});
|
|
168
|
+
const at = iso(c.currentTime);
|
|
169
|
+
if (c.currentTime !== undefined && !at)
|
|
170
|
+
odd.push(`currentTime not understood: ${c.currentTime}`);
|
|
171
|
+
const now = at
|
|
172
|
+
? {
|
|
173
|
+
...meta(at),
|
|
174
|
+
tempC: celsius(c.temperature, "temperature", odd),
|
|
175
|
+
apparentC: celsius(c.feelsLikeTemperature, "feels like", odd),
|
|
176
|
+
humidity: c.relativeHumidity,
|
|
177
|
+
windKmh: kmh(c.wind?.speed, "wind", odd),
|
|
178
|
+
windDir: c.wind?.direction?.cardinal,
|
|
179
|
+
windDeg: c.wind?.direction?.degrees,
|
|
180
|
+
gustKmh: kmh(c.wind?.gust, "gust", odd),
|
|
181
|
+
}
|
|
182
|
+
: undefined;
|
|
183
|
+
const hourly = (Hours.parse(hours).forecastHours ?? []).flatMap((h) => {
|
|
184
|
+
const at = iso(h.interval?.startTime);
|
|
185
|
+
if (!at)
|
|
186
|
+
odd.push(`forecast hour time not understood: ${h.interval?.startTime}`);
|
|
187
|
+
return at ? [{ at, cloudPct: h.cloudCover, uvIndex: h.uvIndex }] : [];
|
|
188
|
+
});
|
|
189
|
+
const sky = at
|
|
190
|
+
? { ...meta(at), cloudPct: c.cloudCover, uvIndex: c.uvIndex, hours: hourly }
|
|
191
|
+
: undefined;
|
|
192
|
+
const list = Days.parse(days).forecastDays ?? [];
|
|
193
|
+
if (place)
|
|
194
|
+
for (const d of list) {
|
|
195
|
+
const off = sunDisagrees("google", place.latitude, place.longitude, {
|
|
196
|
+
sunrise: d.sunEvents?.sunriseTime,
|
|
197
|
+
sunset: d.sunEvents?.sunsetTime,
|
|
198
|
+
});
|
|
199
|
+
if (off) odd.push(off);
|
|
200
|
+
}
|
|
201
|
+
const i = list.findIndex((d) => contains(d.interval, fetchedAt));
|
|
202
|
+
const day = (
|
|
203
|
+
d: (typeof list)[number] | undefined,
|
|
204
|
+
): ForecastDay | undefined => {
|
|
205
|
+
if (!d?.displayDate) return undefined;
|
|
206
|
+
const { year, month, day: dd } = d.displayDate;
|
|
207
|
+
const chances = [d.daytimeForecast, d.nighttimeForecast]
|
|
208
|
+
.map((h) => h?.precipitation?.probability?.percent)
|
|
209
|
+
.filter((p) => p !== undefined);
|
|
210
|
+
const amounts = [d.daytimeForecast, d.nighttimeForecast].map(
|
|
211
|
+
(h) => h?.precipitation?.qpf,
|
|
212
|
+
);
|
|
213
|
+
for (const q of amounts)
|
|
214
|
+
if (q?.unit && q.unit !== "MILLIMETERS") odd.push(`rain in ${q.unit}`);
|
|
215
|
+
const mm = amounts.every(
|
|
216
|
+
(q) =>
|
|
217
|
+
q?.quantity !== undefined &&
|
|
218
|
+
(q.unit ?? "MILLIMETERS") === "MILLIMETERS",
|
|
219
|
+
)
|
|
220
|
+
? amounts.reduce((s, q) => s + q!.quantity!, 0)
|
|
221
|
+
: undefined;
|
|
222
|
+
return {
|
|
223
|
+
date: `${year}-${String(month).padStart(2, "0")}-${String(dd).padStart(2, "0")}`,
|
|
224
|
+
minC: celsius(d.minTemperature, "minimum", odd),
|
|
225
|
+
maxC: celsius(d.maxTemperature, "maximum", odd),
|
|
226
|
+
rainChance: chances.length ? Math.max(...chances) : undefined,
|
|
227
|
+
rainMm: mm,
|
|
228
|
+
text: d.daytimeForecast?.weatherCondition?.description?.text,
|
|
229
|
+
code: d.daytimeForecast?.weatherCondition?.type,
|
|
230
|
+
};
|
|
231
|
+
};
|
|
232
|
+
const forecastIso = new Date(forecastAt).toISOString();
|
|
233
|
+
const forecast = list.length
|
|
234
|
+
? {
|
|
235
|
+
...meta(forecastIso),
|
|
236
|
+
fetchedAt: forecastIso,
|
|
237
|
+
today: i < 0 ? undefined : day(list[i]),
|
|
238
|
+
tomorrow: i < 0 ? undefined : day(list[i + 1]),
|
|
239
|
+
}
|
|
240
|
+
: undefined;
|
|
241
|
+
return {
|
|
242
|
+
now,
|
|
243
|
+
forecast,
|
|
244
|
+
sky,
|
|
245
|
+
anomaly: odd.length ? odd.join("; ") : undefined,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The minute forecast as a `rain` slice: the segments that start within `RAIN_HORIZON_MINUTES` of `fetchedAt`. A segment with an odd time is
|
|
251
|
+
* skipped and noted; a rain amount in a unit other than millimetres is left out and noted, never converted.
|
|
252
|
+
*/
|
|
253
|
+
export function toRain(
|
|
254
|
+
minutes: unknown,
|
|
255
|
+
fetchedAt: number,
|
|
256
|
+
): { rain?: WeatherRain; anomaly?: string } {
|
|
257
|
+
const odd: string[] = [];
|
|
258
|
+
const parsed = Minutes.safeParse(minutes);
|
|
259
|
+
if (!parsed.success)
|
|
260
|
+
return {
|
|
261
|
+
anomaly:
|
|
262
|
+
"minute forecast not understood: the response is not the documented shape",
|
|
263
|
+
};
|
|
264
|
+
const m = parsed.data;
|
|
265
|
+
const at = iso(m.overallPredictionTimeframe?.startTime);
|
|
266
|
+
if (!at)
|
|
267
|
+
odd.push(
|
|
268
|
+
`minute forecast has no start time: ${m.overallPredictionTimeframe?.startTime}`,
|
|
269
|
+
);
|
|
270
|
+
const limit = fetchedAt + RAIN_HORIZON_MINUTES * 60_000;
|
|
271
|
+
const segments = (m.segments ?? []).flatMap((s) => {
|
|
272
|
+
const from = iso(s.timeFrame?.startTime);
|
|
273
|
+
const to = iso(s.timeFrame?.endTime);
|
|
274
|
+
if (!from || !to) {
|
|
275
|
+
odd.push(
|
|
276
|
+
`minute forecast segment time not understood: ${s.timeFrame?.startTime}`,
|
|
277
|
+
);
|
|
278
|
+
return [];
|
|
279
|
+
}
|
|
280
|
+
if (Date.parse(from) >= limit) return [];
|
|
281
|
+
if (s.qpf?.unit && s.qpf.unit !== "MILLIMETERS")
|
|
282
|
+
odd.push(`rain in ${s.qpf.unit}`);
|
|
283
|
+
if (s.intensity && !RAIN_INTENSITIES.includes(s.intensity))
|
|
284
|
+
odd.push(`rain intensity not understood: ${s.intensity}`);
|
|
285
|
+
const mm =
|
|
286
|
+
s.qpf?.unit === undefined || s.qpf.unit === "MILLIMETERS"
|
|
287
|
+
? num(s.qpf?.quantity, "rain amount", odd)
|
|
288
|
+
: undefined;
|
|
289
|
+
return [
|
|
290
|
+
{
|
|
291
|
+
from,
|
|
292
|
+
to,
|
|
293
|
+
type: s.type,
|
|
294
|
+
chance: s.probability,
|
|
295
|
+
mm,
|
|
296
|
+
intensity: s.intensity,
|
|
297
|
+
},
|
|
298
|
+
];
|
|
299
|
+
});
|
|
300
|
+
const anomaly = odd.length ? odd.join("; ") : undefined;
|
|
301
|
+
if (!at || !segments.length) return { anomaly };
|
|
302
|
+
return {
|
|
303
|
+
rain: {
|
|
304
|
+
source: "google",
|
|
305
|
+
modelled: true,
|
|
306
|
+
at,
|
|
307
|
+
fetchedAt: new Date(fetchedAt).toISOString(),
|
|
308
|
+
segments,
|
|
309
|
+
},
|
|
310
|
+
anomaly,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export type GoogleOptions = {
|
|
315
|
+
key: string;
|
|
316
|
+
fetch?: typeof fetch;
|
|
317
|
+
base?: string;
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* 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:
|
|
322
|
+
* 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.
|
|
323
|
+
*/
|
|
324
|
+
export const MIN_POLL_MINUTES = 15;
|
|
325
|
+
export const CURRENT_MINUTES = 30;
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Google Weather as a source: modelled `now`, `forecast` (10 days available, 2 read), `sky` without radiation, and `rain` from the
|
|
329
|
+
* minute forecast (Experimental at Google, so its failure is a `problem` on `rain` and never stops the other slices).
|
|
330
|
+
*/
|
|
331
|
+
export function google(options: GoogleOptions): WeatherSource {
|
|
332
|
+
const fetcher = options.fetch ?? fetch;
|
|
333
|
+
const base = options.base ?? BASE;
|
|
334
|
+
let slices: Omit<SourceReading, "nextAt"> = {};
|
|
335
|
+
let rain: WeatherRain | undefined;
|
|
336
|
+
let forecasts: { days: unknown; hours: unknown; at: number } | undefined;
|
|
337
|
+
let forecastsDue = 0;
|
|
338
|
+
let slowDue = 0;
|
|
339
|
+
return {
|
|
340
|
+
name: "google",
|
|
341
|
+
owns: {
|
|
342
|
+
now: 30 * 60_000,
|
|
343
|
+
forecast: 6 * 3_600_000,
|
|
344
|
+
sky: 30 * 60_000,
|
|
345
|
+
rain: 15 * 60_000,
|
|
346
|
+
},
|
|
347
|
+
minPollMinutes: MIN_POLL_MINUTES,
|
|
348
|
+
async read({
|
|
349
|
+
place,
|
|
350
|
+
at,
|
|
351
|
+
pollMinutes,
|
|
352
|
+
force,
|
|
353
|
+
signal,
|
|
354
|
+
}): Promise<SourceReading> {
|
|
355
|
+
const q = (extra: string) =>
|
|
356
|
+
`key=${encodeURIComponent(options.key)}&location.latitude=${place.latitude}&location.longitude=${place.longitude}${extra}`;
|
|
357
|
+
const json = (path: string) =>
|
|
358
|
+
getJson(fetcher, `${base}/${path}`, {
|
|
359
|
+
init: { headers: { "user-agent": USER_AGENT } },
|
|
360
|
+
signal,
|
|
361
|
+
});
|
|
362
|
+
// Started first and never throws: a failed minute forecast must not sink the rest.
|
|
363
|
+
const minutes = json(`forecast/minutes:lookup?${q("")}`).then(
|
|
364
|
+
(value) => ({ value }),
|
|
365
|
+
(error: unknown) => ({ error: error as Error }),
|
|
366
|
+
);
|
|
367
|
+
let problem: string | undefined;
|
|
368
|
+
let failed = false;
|
|
369
|
+
try {
|
|
370
|
+
if (force || at >= slowDue) {
|
|
371
|
+
const both = force || !forecasts || at >= forecastsDue;
|
|
372
|
+
const [current, days, hours] = await Promise.all([
|
|
373
|
+
json(`currentConditions:lookup?${q("")}`),
|
|
374
|
+
both
|
|
375
|
+
? json(`forecast/days:lookup?${q("&days=2")}`)
|
|
376
|
+
: forecasts!.days,
|
|
377
|
+
both
|
|
378
|
+
? json(`forecast/hours:lookup?${q("&hours=6")}`)
|
|
379
|
+
: forecasts!.hours,
|
|
380
|
+
]);
|
|
381
|
+
if (both)
|
|
382
|
+
((forecasts = { days, hours, at }),
|
|
383
|
+
(forecastsDue = nextPoll(at, HOURLY_MINUTES)));
|
|
384
|
+
slices = toSlices(current, days, hours, at, forecasts!.at, place);
|
|
385
|
+
slowDue = nextPoll(at, Math.max(pollMinutes, CURRENT_MINUTES));
|
|
386
|
+
}
|
|
387
|
+
} catch (error) {
|
|
388
|
+
const status = (error as ConnectorError).status;
|
|
389
|
+
if (status === 400 || status === 401 || status === 403)
|
|
390
|
+
return {
|
|
391
|
+
stop: `google: key rejected or request refused (HTTP ${status})`,
|
|
392
|
+
nextAt: Infinity,
|
|
393
|
+
};
|
|
394
|
+
problem = `google: ${(error as Error).message}`; // the old slices stay; the minute forecast below is still used
|
|
395
|
+
failed = true;
|
|
396
|
+
}
|
|
397
|
+
const m = await minutes;
|
|
398
|
+
let rainOdd: string | undefined;
|
|
399
|
+
if ("error" in m)
|
|
400
|
+
problem = [problem, `google minute forecast: ${m.error.message}`]
|
|
401
|
+
.filter(Boolean)
|
|
402
|
+
.join("; ");
|
|
403
|
+
else {
|
|
404
|
+
const r = toRain(m.value, at);
|
|
405
|
+
rainOdd = r.anomaly;
|
|
406
|
+
if (r.rain) rain = r.rain;
|
|
407
|
+
else
|
|
408
|
+
problem = [problem, "google minute forecast: no segments"]
|
|
409
|
+
.filter(Boolean)
|
|
410
|
+
.join("; ");
|
|
411
|
+
}
|
|
412
|
+
const anomaly =
|
|
413
|
+
[failed ? undefined : slices.anomaly, rainOdd]
|
|
414
|
+
.filter(Boolean)
|
|
415
|
+
.join("; ") || undefined;
|
|
416
|
+
return {
|
|
417
|
+
...slices,
|
|
418
|
+
rain,
|
|
419
|
+
anomaly,
|
|
420
|
+
problem,
|
|
421
|
+
nextAt: nextPoll(at, pollMinutes),
|
|
422
|
+
};
|
|
423
|
+
},
|
|
424
|
+
};
|
|
425
|
+
}
|