@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,396 @@
|
|
|
1
|
+
import { type Clock, localTime, realClock } from "../../clock.ts";
|
|
2
|
+
import type { Integration, Status } from "../../home.ts";
|
|
3
|
+
import { field, type Manifest } from "../../manifest.ts";
|
|
4
|
+
import { poller } from "../../poll.ts";
|
|
5
|
+
import {
|
|
6
|
+
type BlockDef,
|
|
7
|
+
blockText,
|
|
8
|
+
checkBlocks,
|
|
9
|
+
type DayBlocks,
|
|
10
|
+
dayBlocks,
|
|
11
|
+
} from "./blocks.ts";
|
|
12
|
+
import { openMeteo } from "./open-meteo.ts";
|
|
13
|
+
import { type RainSoon, rainSoon } from "./rain.ts";
|
|
14
|
+
import {
|
|
15
|
+
DEFAULT_POLL_MINUTES,
|
|
16
|
+
type ForecastDay,
|
|
17
|
+
RAIN_HORIZON_MINUTES,
|
|
18
|
+
type SliceName,
|
|
19
|
+
type Slices,
|
|
20
|
+
sourcePollMinutes,
|
|
21
|
+
type SourceReading,
|
|
22
|
+
mergeWeather,
|
|
23
|
+
type WeatherSource,
|
|
24
|
+
} from "./source.ts";
|
|
25
|
+
import { type SunNow, sunNow } from "./sun.ts";
|
|
26
|
+
|
|
27
|
+
export {
|
|
28
|
+
type Condition,
|
|
29
|
+
type ForecastDay,
|
|
30
|
+
type ForecastHour,
|
|
31
|
+
mergeWeather,
|
|
32
|
+
nextPoll,
|
|
33
|
+
type Place,
|
|
34
|
+
type RainSegment,
|
|
35
|
+
type ReadRequest,
|
|
36
|
+
type SkyHour,
|
|
37
|
+
type SliceMeta,
|
|
38
|
+
type Slices,
|
|
39
|
+
type SourceReading,
|
|
40
|
+
type WeatherForecast,
|
|
41
|
+
type WeatherHourly,
|
|
42
|
+
type WeatherNow,
|
|
43
|
+
type WeatherRain,
|
|
44
|
+
type WeatherSky,
|
|
45
|
+
type WeatherSource,
|
|
46
|
+
} from "./source.ts";
|
|
47
|
+
export { type SunNext, type SunNow, sunNow } from "./sun.ts";
|
|
48
|
+
export { type RainSoon, rainSoon } from "./rain.ts";
|
|
49
|
+
export {
|
|
50
|
+
type BlockDef,
|
|
51
|
+
BLOCKS,
|
|
52
|
+
blockText,
|
|
53
|
+
type DayBlock,
|
|
54
|
+
type DayBlocks,
|
|
55
|
+
dayBlocks,
|
|
56
|
+
} from "./blocks.ts";
|
|
57
|
+
export { bom, type BomOptions } from "./bom.ts";
|
|
58
|
+
export { openMeteo, type OpenMeteoOptions } from "./open-meteo.ts";
|
|
59
|
+
export { google, type GoogleOptions } from "./google.ts";
|
|
60
|
+
|
|
61
|
+
/** The sun moves about a degree every four minutes; recompute it this often between source reads. */
|
|
62
|
+
const SUN_MS = 5 * 60_000;
|
|
63
|
+
/** However soon a source asks to be called again, never sooner than this. */
|
|
64
|
+
const MIN_POLL_MS = 60_000;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Where the home is, and the sources in preference order: each slice comes from the first source with a fresh one.
|
|
68
|
+
* The default is `[openMeteo()]`, which works anywhere with no key. In Australia put `bom({ fetch: ftpFetch(), forecastLocation })`
|
|
69
|
+
* first: it needs the FTP transport, so it is opt-in. `pollMinutes` (default 15) is how often observations and sky are read;
|
|
70
|
+
* forecasts follow their issue times. `timeZone` (IANA, the runtime's when left out) is where the home's day and its times are.
|
|
71
|
+
* `blocks` are the parts of the day `state.blocks` is cut into (`BLOCKS` by default).
|
|
72
|
+
*/
|
|
73
|
+
export type WeatherConfig = {
|
|
74
|
+
latitude: number;
|
|
75
|
+
longitude: number;
|
|
76
|
+
sources?: WeatherSource[];
|
|
77
|
+
pollMinutes?: number;
|
|
78
|
+
timeZone?: string;
|
|
79
|
+
blocks?: readonly BlockDef[];
|
|
80
|
+
clock?: Clock;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** The weather in the snapshot: one slice each from the preferred fresh source, the sun, and how each source is doing. */
|
|
84
|
+
export type WeatherState = Slices & {
|
|
85
|
+
sun: SunNow;
|
|
86
|
+
rainSoon?: RainSoon; // recomputed at every report and every 5 minutes; a consumer with its own clock calls `rainSoon(state.rain, now)`
|
|
87
|
+
blocks?: DayBlocks; // the same: `dayBlocks(state.hourly, now, state.timeZone, blocks)`
|
|
88
|
+
timeZone?: string; // the config's, which the blocks and the text use
|
|
89
|
+
sources: Record<
|
|
90
|
+
string,
|
|
91
|
+
{ read: boolean; problem?: string; stopped?: string; anomaly?: string }
|
|
92
|
+
>;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const SLICE_NAMES = [
|
|
96
|
+
"now",
|
|
97
|
+
"forecast",
|
|
98
|
+
"hourly",
|
|
99
|
+
"sky",
|
|
100
|
+
"rain",
|
|
101
|
+
] as const satisfies readonly SliceName[];
|
|
102
|
+
|
|
103
|
+
/** Outside weather as a home integration. Each source polls on its own cadence; the sun is computed, never fetched. */
|
|
104
|
+
export function weather(
|
|
105
|
+
config: WeatherConfig,
|
|
106
|
+
): Integration<WeatherState, "weather"> {
|
|
107
|
+
const sources = config.sources ?? [openMeteo()];
|
|
108
|
+
const clock = config.clock ?? realClock;
|
|
109
|
+
const pollMinutes = config.pollMinutes ?? DEFAULT_POLL_MINUTES;
|
|
110
|
+
if (!(pollMinutes >= 1))
|
|
111
|
+
throw new Error(
|
|
112
|
+
`weather: pollMinutes must be at least 1, got ${config.pollMinutes}`,
|
|
113
|
+
);
|
|
114
|
+
if (config.blocks) checkBlocks(config.blocks); // a bad block fails here, not at every report
|
|
115
|
+
const place = { latitude: config.latitude, longitude: config.longitude };
|
|
116
|
+
const readings = new Map<string, SourceReading>();
|
|
117
|
+
const fresh = new Map<string, boolean>(); // whether a source's latest read brought anything new, rather than its last good slices again
|
|
118
|
+
let readAt: number | undefined; // the last read that brought something new: the part's `lastGoodAt`, so a re-report is not a read
|
|
119
|
+
const polls = new Map(sources.map((s) => [s.name, poller(clock)]));
|
|
120
|
+
let answer: (() => void) | undefined; // reports now: for a refresh no source will answer
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
name: "weather",
|
|
124
|
+
/**
|
|
125
|
+
* Read every source now, whatever its schedule, forecasts included (`force`). A source with a floor (a billed one,
|
|
126
|
+
* Google) is not forced, and is skipped when its last read is younger than its floor.
|
|
127
|
+
*/
|
|
128
|
+
refresh() {
|
|
129
|
+
for (const poll of polls.values()) poll.nudge();
|
|
130
|
+
if (sources.every((s) => readings.get(s.name)?.stop)) answer?.(); // every source has stopped: none will read, so say so now
|
|
131
|
+
},
|
|
132
|
+
async run(signal, update) {
|
|
133
|
+
const emit = () => {
|
|
134
|
+
const at = clock.now();
|
|
135
|
+
const slices = mergeWeather(
|
|
136
|
+
sources.map((source) => ({
|
|
137
|
+
source,
|
|
138
|
+
reading: readings.get(source.name),
|
|
139
|
+
})),
|
|
140
|
+
at,
|
|
141
|
+
pollMinutes,
|
|
142
|
+
);
|
|
143
|
+
const report = Object.fromEntries(
|
|
144
|
+
sources.map((s) => {
|
|
145
|
+
const r = readings.get(s.name);
|
|
146
|
+
return [
|
|
147
|
+
s.name,
|
|
148
|
+
{
|
|
149
|
+
read: r !== undefined,
|
|
150
|
+
problem: r?.problem,
|
|
151
|
+
stopped: r?.stop,
|
|
152
|
+
anomaly: r?.anomaly,
|
|
153
|
+
},
|
|
154
|
+
];
|
|
155
|
+
}),
|
|
156
|
+
);
|
|
157
|
+
// Connected while some source's latest read brought something new; its older slices alone are a failed read, kept as state.
|
|
158
|
+
const status: Status = sources.some((s) => fresh.get(s.name))
|
|
159
|
+
? "connected"
|
|
160
|
+
: sources.every((s) => readings.get(s.name)?.stop)
|
|
161
|
+
? "unavailable"
|
|
162
|
+
: "disconnected";
|
|
163
|
+
const notes = (k: "problem" | "stopped" | "anomaly") =>
|
|
164
|
+
Object.values(report)
|
|
165
|
+
.map((r) => r[k])
|
|
166
|
+
.filter(Boolean)
|
|
167
|
+
.join("; ") || undefined;
|
|
168
|
+
const detail =
|
|
169
|
+
[notes("stopped"), notes("problem")].filter(Boolean).join("; ") ||
|
|
170
|
+
undefined;
|
|
171
|
+
const lastGoodAt =
|
|
172
|
+
status === "connected" && readAt !== undefined
|
|
173
|
+
? new Date(readAt).toISOString()
|
|
174
|
+
: undefined;
|
|
175
|
+
update({
|
|
176
|
+
status,
|
|
177
|
+
detail,
|
|
178
|
+
anomaly: notes("anomaly"),
|
|
179
|
+
lastGoodAt,
|
|
180
|
+
state: {
|
|
181
|
+
...slices,
|
|
182
|
+
sun: sunNow(place.latitude, place.longitude, at),
|
|
183
|
+
rainSoon: rainSoon(slices.rain, at),
|
|
184
|
+
blocks: dayBlocks(
|
|
185
|
+
slices.hourly,
|
|
186
|
+
at,
|
|
187
|
+
config.timeZone,
|
|
188
|
+
config.blocks,
|
|
189
|
+
),
|
|
190
|
+
timeZone: config.timeZone,
|
|
191
|
+
sources: report,
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
};
|
|
195
|
+
const poll = async (source: WeatherSource) => {
|
|
196
|
+
const floorMs = (source.minPollMinutes ?? 0) * 60_000;
|
|
197
|
+
let force = false;
|
|
198
|
+
while (!signal.aborted) {
|
|
199
|
+
const at = clock.now();
|
|
200
|
+
const reading = await source.read({
|
|
201
|
+
place,
|
|
202
|
+
at,
|
|
203
|
+
pollMinutes: sourcePollMinutes(source, pollMinutes),
|
|
204
|
+
force,
|
|
205
|
+
signal,
|
|
206
|
+
});
|
|
207
|
+
if (signal.aborted) return; // an aborted read is not a problem to report
|
|
208
|
+
readings.set(source.name, reading);
|
|
209
|
+
const isFresh = SLICE_NAMES.some((k) =>
|
|
210
|
+
reading[k]?.fetchedAt
|
|
211
|
+
? Date.parse(reading[k]!.fetchedAt) >= at
|
|
212
|
+
: false,
|
|
213
|
+
);
|
|
214
|
+
fresh.set(source.name, isFresh);
|
|
215
|
+
if (isFresh) readAt = Math.max(readAt ?? at, at);
|
|
216
|
+
emit();
|
|
217
|
+
if (reading.stop) return;
|
|
218
|
+
for (;;) {
|
|
219
|
+
const woken = await polls
|
|
220
|
+
.get(source.name)!
|
|
221
|
+
.wait(
|
|
222
|
+
Math.max(reading.nextAt - clock.now(), MIN_POLL_MS),
|
|
223
|
+
signal,
|
|
224
|
+
);
|
|
225
|
+
if (woken && clock.now() - at < floorMs) {
|
|
226
|
+
emit(); // a refresh never beats a billed source's floor, but it gets an answer: what there is now
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
force = woken && !floorMs;
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
answer = emit;
|
|
235
|
+
const sun = async () => {
|
|
236
|
+
while (!signal.aborted) {
|
|
237
|
+
await clock.sleep(SUN_MS, signal);
|
|
238
|
+
if (!signal.aborted) emit();
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
await Promise.all([...sources.map(poll), sun()]);
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** "20:20" in the home's time zone (the runtime's when unset), or "?". */
|
|
247
|
+
const hhmmIn = (iso: string | undefined, timeZone?: string) =>
|
|
248
|
+
iso ? localTime(Date.parse(iso), timeZone) : "?";
|
|
249
|
+
const day = (d?: ForecastDay, rainLeft?: number) =>
|
|
250
|
+
d
|
|
251
|
+
? [
|
|
252
|
+
d.minC === undefined && d.maxC === undefined
|
|
253
|
+
? ""
|
|
254
|
+
: `${d.minC ?? "?"}–${d.maxC ?? "?"} °C`,
|
|
255
|
+
d.text?.replace(/\.$/, "").toLowerCase() ?? "",
|
|
256
|
+
d.rainChance === undefined
|
|
257
|
+
? ""
|
|
258
|
+
: rainLeft !== undefined && rainLeft < d.rainChance
|
|
259
|
+
? `${rainLeft}% chance of rain the rest of the day`
|
|
260
|
+
: `${d.rainChance}% chance of rain`,
|
|
261
|
+
]
|
|
262
|
+
.filter(Boolean)
|
|
263
|
+
.join(" ")
|
|
264
|
+
: "?";
|
|
265
|
+
|
|
266
|
+
/** The highest hourly chance left today, all 24 hours of it, when the hourly forecast is the same source and day as the forecast: sources are never mixed. */
|
|
267
|
+
const rainLeftToday = (
|
|
268
|
+
w: Pick<Slices, "forecast" | "hourly"> & { timeZone?: string },
|
|
269
|
+
at: number,
|
|
270
|
+
) => {
|
|
271
|
+
if (!w.hourly || w.hourly.source !== w.forecast?.source) return undefined;
|
|
272
|
+
const rest = dayBlocks(w.hourly, at, w.timeZone, [
|
|
273
|
+
{ name: "rest", from: 0, to: 24 },
|
|
274
|
+
])?.today[0];
|
|
275
|
+
return rest?.date === w.forecast.today?.date ? rest?.rainChance : undefined;
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
/** "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. */
|
|
279
|
+
export function rainText(
|
|
280
|
+
rain: Slices["rain"],
|
|
281
|
+
r: RainSoon | undefined,
|
|
282
|
+
): string {
|
|
283
|
+
if (!r || !rain) return "";
|
|
284
|
+
const covered =
|
|
285
|
+
r.coveredMin >= RAIN_HORIZON_MINUTES
|
|
286
|
+
? `${RAIN_HORIZON_MINUTES / 60} h`
|
|
287
|
+
: `${r.coveredMin} min`;
|
|
288
|
+
if (!r.wet) return `no rain for ${covered} (${rain.source})`;
|
|
289
|
+
const end =
|
|
290
|
+
r.endsInMin !== undefined
|
|
291
|
+
? `ends in ${r.endsInMin} min`
|
|
292
|
+
: r.startsInMin === 0
|
|
293
|
+
? `still at ${covered}`
|
|
294
|
+
: "";
|
|
295
|
+
const parts = [
|
|
296
|
+
r.startsInMin === 0 ? "raining" : `rain in ${r.startsInMin} min`,
|
|
297
|
+
end,
|
|
298
|
+
field(r.chance, (v) => `${v}%`),
|
|
299
|
+
field(r.mm, (v) => `${v} mm`),
|
|
300
|
+
r.intensity && r.intensity !== "NO_INTENSITY"
|
|
301
|
+
? r.intensity.toLowerCase()
|
|
302
|
+
: "",
|
|
303
|
+
];
|
|
304
|
+
return `${parts.filter(Boolean).join(", ")} (${rain.source})`;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** The weather in one line, every part named by its source and time, never mixed. */
|
|
308
|
+
export function weatherText(
|
|
309
|
+
w: Slices & { rainSoon?: RainSoon; timeZone?: string },
|
|
310
|
+
sun: SunNow,
|
|
311
|
+
): string {
|
|
312
|
+
const n = w.now;
|
|
313
|
+
const hhmm = (iso?: string) => hhmmIn(iso, w.timeZone);
|
|
314
|
+
const readings = n
|
|
315
|
+
? [
|
|
316
|
+
field(n.tempC, (v) => `${v} °C`),
|
|
317
|
+
field(n.apparentC, (v) => `feels ${v}`),
|
|
318
|
+
field(n.humidity, (v) => `${v}%`),
|
|
319
|
+
field(n.rainMm, (v) => `${v} mm since 9am to ${hhmm(n.rainTo)}`),
|
|
320
|
+
]
|
|
321
|
+
.filter(Boolean)
|
|
322
|
+
.join(", ")
|
|
323
|
+
: "";
|
|
324
|
+
const now = n
|
|
325
|
+
? `${n.station ? `${n.station.name} (${n.station.distanceKm.toFixed(1)} km)` : n.source} ${hhmm(n.at)}${n.modelled ? " modelled" : ""}: ${readings}`
|
|
326
|
+
: "";
|
|
327
|
+
const f = w.forecast;
|
|
328
|
+
const forecast = f
|
|
329
|
+
? `today ${day(f.today, rainLeftToday(w, Date.parse(sun.at)))}, tomorrow ${day(f.tomorrow)} (${f.source}${f.modelled ? " modelled" : ""})`
|
|
330
|
+
: "";
|
|
331
|
+
const k = w.sky;
|
|
332
|
+
const skyParts = k
|
|
333
|
+
? [
|
|
334
|
+
field(k.cloudPct, (v) => `cloud ${v}%`),
|
|
335
|
+
field(k.uvIndex, (v) => `UV ${v}`),
|
|
336
|
+
field(k.radiationWm2, (v) => `${v} W/m²`),
|
|
337
|
+
]
|
|
338
|
+
.filter(Boolean)
|
|
339
|
+
.join(", ")
|
|
340
|
+
: "";
|
|
341
|
+
const sky = k ? `${skyParts} (${k.source})` : "";
|
|
342
|
+
const sunText = `sun ${Math.round(sun.altitudeDeg)}°, ${sun.rising ? "rising" : "setting"}, ${sun.daylight ? `sets ${hhmm(sun.next.sunset)}` : `rises ${hhmm(sun.next.sunrise)}`}`;
|
|
343
|
+
return [now, forecast, sky, rainText(w.rain, w.rainSoon), sunText]
|
|
344
|
+
.filter(Boolean)
|
|
345
|
+
.join(" · ");
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** "today: afternoon …; evening …" and the same for tomorrow, when there is an hourly forecast. */
|
|
349
|
+
const blockLines = (state: Pick<WeatherState, "blocks" | "timeZone">) =>
|
|
350
|
+
state.blocks
|
|
351
|
+
? (["today", "tomorrow"] as const)
|
|
352
|
+
.filter((d) => state.blocks![d].length)
|
|
353
|
+
.map(
|
|
354
|
+
(d) =>
|
|
355
|
+
`${d}: ${state.blocks![d].map((b) => blockText(b, state.timeZone)).join("; ")} (${state.blocks!.source})`,
|
|
356
|
+
)
|
|
357
|
+
: [];
|
|
358
|
+
|
|
359
|
+
export const manifest: Manifest<WeatherState> = {
|
|
360
|
+
name: "weather",
|
|
361
|
+
title: "Weather",
|
|
362
|
+
iotClass: "cloud_polling",
|
|
363
|
+
pollSeconds: DEFAULT_POLL_MINUTES * 60,
|
|
364
|
+
access:
|
|
365
|
+
"BOM anonymous FTP, Open-Meteo, Google with GOOGLE_WEATHER_KEY; sun computed",
|
|
366
|
+
env: [
|
|
367
|
+
{
|
|
368
|
+
name: "HOME_LATITUDE",
|
|
369
|
+
example: "-33.8688",
|
|
370
|
+
what: "the home's position for weather and the sun (this one is Sydney CBD)",
|
|
371
|
+
},
|
|
372
|
+
{ name: "HOME_LONGITUDE", example: "151.2093", what: "" },
|
|
373
|
+
{
|
|
374
|
+
name: "HOME_TIMEZONE",
|
|
375
|
+
example: "Australia/Sydney",
|
|
376
|
+
optional: true,
|
|
377
|
+
what: "the home's IANA time zone for weather times and day blocks; this machine's when unset",
|
|
378
|
+
},
|
|
379
|
+
{
|
|
380
|
+
name: "HOME_BOM_FORECAST",
|
|
381
|
+
example: "Sydney",
|
|
382
|
+
optional: true,
|
|
383
|
+
what: "BOM précis location by name or aac; unset, the forecast comes from Open-Meteo",
|
|
384
|
+
},
|
|
385
|
+
{
|
|
386
|
+
name: "GOOGLE_WEATHER_KEY",
|
|
387
|
+
example: "",
|
|
388
|
+
optional: true,
|
|
389
|
+
what: "Maps Platform key with the Weather API enabled",
|
|
390
|
+
},
|
|
391
|
+
],
|
|
392
|
+
lines: ({ state }) =>
|
|
393
|
+
state ? [weatherText(state, state.sun), ...blockLines(state)] : [],
|
|
394
|
+
record: ({ state }) =>
|
|
395
|
+
state ? { ...state, line: weatherText(state, state.sun) } : {},
|
|
396
|
+
};
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { getJson } from "../../connector.ts";
|
|
3
|
+
import {
|
|
4
|
+
type Condition,
|
|
5
|
+
type ForecastDay,
|
|
6
|
+
type ForecastHour,
|
|
7
|
+
HOURLY_MINUTES,
|
|
8
|
+
iso,
|
|
9
|
+
localDate,
|
|
10
|
+
nextPoll,
|
|
11
|
+
num,
|
|
12
|
+
type SourceReading,
|
|
13
|
+
USER_AGENT,
|
|
14
|
+
type WeatherSource,
|
|
15
|
+
} from "./source.ts";
|
|
16
|
+
|
|
17
|
+
// Open-Meteo's forecast API (https://open-meteo.com/en/docs): keyless, JSON, data CC BY 4.0. No `models`, so the default
|
|
18
|
+
// best_match answers; its BOM model (bom_access_global) has returned only nulls since BOM paused open data in mid 2025.
|
|
19
|
+
|
|
20
|
+
const CURRENT =
|
|
21
|
+
"temperature_2m,apparent_temperature,relative_humidity_2m,wind_speed_10m,wind_direction_10m,wind_gusts_10m,cloud_cover,uv_index,shortwave_radiation,weather_code";
|
|
22
|
+
const HOURLY =
|
|
23
|
+
"temperature_2m,apparent_temperature,precipitation_probability,precipitation,weather_code,wind_speed_10m,wind_gusts_10m,cloud_cover,uv_index,shortwave_radiation";
|
|
24
|
+
/** How many hours ahead `sky.hours` holds. */
|
|
25
|
+
const SKY_HOURS = 6;
|
|
26
|
+
const DAILY =
|
|
27
|
+
"temperature_2m_max,temperature_2m_min,precipitation_probability_max,precipitation_sum,weather_code";
|
|
28
|
+
|
|
29
|
+
const series = z.array(z.number().nullable()).optional();
|
|
30
|
+
const OpenMeteoResponse = z.looseObject({
|
|
31
|
+
utc_offset_seconds: z.number().optional(),
|
|
32
|
+
current: z.record(z.string(), z.unknown()).optional(),
|
|
33
|
+
hourly: z
|
|
34
|
+
.looseObject({
|
|
35
|
+
time: z.array(z.string()),
|
|
36
|
+
temperature_2m: series,
|
|
37
|
+
apparent_temperature: series,
|
|
38
|
+
precipitation_probability: series,
|
|
39
|
+
precipitation: series,
|
|
40
|
+
weather_code: series,
|
|
41
|
+
wind_speed_10m: series,
|
|
42
|
+
wind_gusts_10m: series,
|
|
43
|
+
cloud_cover: series,
|
|
44
|
+
uv_index: series,
|
|
45
|
+
shortwave_radiation: series,
|
|
46
|
+
})
|
|
47
|
+
.optional(),
|
|
48
|
+
daily: z
|
|
49
|
+
.looseObject({
|
|
50
|
+
time: z.array(z.string()),
|
|
51
|
+
temperature_2m_max: series,
|
|
52
|
+
temperature_2m_min: series,
|
|
53
|
+
precipitation_probability_max: series,
|
|
54
|
+
precipitation_sum: series,
|
|
55
|
+
weather_code: series,
|
|
56
|
+
})
|
|
57
|
+
.optional(),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/** WMO weather codes as Open-Meteo documents them (the table at the end of its docs page). */
|
|
61
|
+
const WMO: Record<number, string> = {
|
|
62
|
+
0: "Clear sky",
|
|
63
|
+
1: "Mainly clear",
|
|
64
|
+
2: "Partly cloudy",
|
|
65
|
+
3: "Overcast",
|
|
66
|
+
45: "Fog",
|
|
67
|
+
48: "Depositing rime fog",
|
|
68
|
+
51: "Light drizzle",
|
|
69
|
+
53: "Moderate drizzle",
|
|
70
|
+
55: "Dense drizzle",
|
|
71
|
+
56: "Light freezing drizzle",
|
|
72
|
+
57: "Dense freezing drizzle",
|
|
73
|
+
61: "Slight rain",
|
|
74
|
+
63: "Moderate rain",
|
|
75
|
+
65: "Heavy rain",
|
|
76
|
+
66: "Light freezing rain",
|
|
77
|
+
67: "Heavy freezing rain",
|
|
78
|
+
71: "Slight snow",
|
|
79
|
+
73: "Moderate snow",
|
|
80
|
+
75: "Heavy snow",
|
|
81
|
+
77: "Snow grains",
|
|
82
|
+
80: "Slight rain showers",
|
|
83
|
+
81: "Moderate rain showers",
|
|
84
|
+
82: "Violent rain showers",
|
|
85
|
+
85: "Slight snow showers",
|
|
86
|
+
86: "Heavy snow showers",
|
|
87
|
+
95: "Thunderstorm",
|
|
88
|
+
96: "Thunderstorm with slight hail",
|
|
89
|
+
99: "Thunderstorm with heavy hail",
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** WMO codes in a word: 0–1 clear, 2 partly cloudy, 3 cloudy, then by family. */
|
|
93
|
+
export function wmoCondition(code: number): Condition | undefined {
|
|
94
|
+
if (!(code in WMO)) return undefined;
|
|
95
|
+
if (code <= 1) return "clear";
|
|
96
|
+
if (code === 2) return "partly cloudy";
|
|
97
|
+
if (code === 3) return "cloudy";
|
|
98
|
+
if (code < 50) return "fog";
|
|
99
|
+
if (code < 60) return "drizzle";
|
|
100
|
+
if (code < 70) return "rain";
|
|
101
|
+
if (code < 80) return "snow";
|
|
102
|
+
if (code < 85) return "showers";
|
|
103
|
+
if (code < 95) return "snow"; // snow showers
|
|
104
|
+
return "storm";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Open-Meteo's local times ("2026-10-13T20:15") to UTC ISO, using the response's offset; undefined when it does not parse. Open-Meteo applies that one offset to every time it returns, across a daylight-saving change too (checked live, 2026-10-16), so it is right for all of them. */
|
|
108
|
+
const utc = (local: string, offset: number) => {
|
|
109
|
+
const t = Date.parse(`${local}Z`);
|
|
110
|
+
return Number.isNaN(t)
|
|
111
|
+
? undefined
|
|
112
|
+
: iso(new Date(t - offset * 1000).toISOString());
|
|
113
|
+
};
|
|
114
|
+
const value = (s: (number | null)[] | undefined, i: number) =>
|
|
115
|
+
s?.[i] ?? undefined;
|
|
116
|
+
|
|
117
|
+
/** Map one forecast response to slices. Everything here is a model, so every slice says `modelled: true`. `forecastAt` is when the daily part was read. */
|
|
118
|
+
export function toSlices(
|
|
119
|
+
body: unknown,
|
|
120
|
+
fetchedAt: number,
|
|
121
|
+
forecastAt = fetchedAt,
|
|
122
|
+
): Omit<SourceReading, "nextAt"> {
|
|
123
|
+
const r = OpenMeteoResponse.parse(body);
|
|
124
|
+
const offset = r.utc_offset_seconds ?? 0;
|
|
125
|
+
const odd: string[] = [];
|
|
126
|
+
const meta = (at: string) => ({
|
|
127
|
+
source: "open-meteo",
|
|
128
|
+
modelled: true,
|
|
129
|
+
at,
|
|
130
|
+
fetchedAt: new Date(fetchedAt).toISOString(),
|
|
131
|
+
});
|
|
132
|
+
const c = r.current;
|
|
133
|
+
const cur = (k: string) => num(c?.[k], `current.${k}`, odd);
|
|
134
|
+
const code = cur("weather_code");
|
|
135
|
+
const nowAt = typeof c?.time === "string" ? utc(c.time, offset) : undefined;
|
|
136
|
+
if (c && !nowAt) odd.push(`current.time not understood: ${String(c.time)}`);
|
|
137
|
+
const now = nowAt
|
|
138
|
+
? {
|
|
139
|
+
...meta(nowAt),
|
|
140
|
+
tempC: cur("temperature_2m"),
|
|
141
|
+
apparentC: cur("apparent_temperature"),
|
|
142
|
+
humidity: cur("relative_humidity_2m"),
|
|
143
|
+
windKmh: cur("wind_speed_10m"),
|
|
144
|
+
windDeg: cur("wind_direction_10m"),
|
|
145
|
+
gustKmh: cur("wind_gusts_10m"),
|
|
146
|
+
}
|
|
147
|
+
: undefined;
|
|
148
|
+
const h = r.hourly;
|
|
149
|
+
const unknownCodes = new Set<number>();
|
|
150
|
+
const hours = (h?.time ?? []).flatMap((t, i) => {
|
|
151
|
+
const at = utc(t, offset);
|
|
152
|
+
if (!at) odd.push(`hourly time not understood: ${t}`);
|
|
153
|
+
if (!at) return [];
|
|
154
|
+
const wmo = value(h?.weather_code, i);
|
|
155
|
+
if (wmo !== undefined && !(wmo in WMO)) unknownCodes.add(wmo);
|
|
156
|
+
const hour: ForecastHour & { radiationWm2?: number } = {
|
|
157
|
+
at,
|
|
158
|
+
tempC: value(h?.temperature_2m, i),
|
|
159
|
+
apparentC: value(h?.apparent_temperature, i),
|
|
160
|
+
rainChance: value(h?.precipitation_probability, i),
|
|
161
|
+
rainMm: value(h?.precipitation, i),
|
|
162
|
+
windKmh: value(h?.wind_speed_10m, i),
|
|
163
|
+
gustKmh: value(h?.wind_gusts_10m, i),
|
|
164
|
+
cloudPct: value(h?.cloud_cover, i),
|
|
165
|
+
uvIndex: value(h?.uv_index, i),
|
|
166
|
+
radiationWm2: value(h?.shortwave_radiation, i),
|
|
167
|
+
code: wmo === undefined ? undefined : String(wmo),
|
|
168
|
+
condition: wmo === undefined ? undefined : wmoCondition(wmo),
|
|
169
|
+
};
|
|
170
|
+
return [hour];
|
|
171
|
+
});
|
|
172
|
+
for (const code of unknownCodes)
|
|
173
|
+
odd.push(`unknown WMO code ${code} in the hourly forecast`);
|
|
174
|
+
// The hourly part is read once an hour; the sky's hours run from the hour it was read in.
|
|
175
|
+
const skyHours = hours
|
|
176
|
+
.filter((x) => Date.parse(x.at) + 3_600_000 > forecastAt)
|
|
177
|
+
.slice(0, SKY_HOURS)
|
|
178
|
+
.map((x) => ({
|
|
179
|
+
at: x.at,
|
|
180
|
+
cloudPct: x.cloudPct,
|
|
181
|
+
uvIndex: x.uvIndex,
|
|
182
|
+
radiationWm2: x.radiationWm2,
|
|
183
|
+
}));
|
|
184
|
+
const sky = now
|
|
185
|
+
? {
|
|
186
|
+
...meta(now.at),
|
|
187
|
+
cloudPct: cur("cloud_cover"),
|
|
188
|
+
uvIndex: cur("uv_index"),
|
|
189
|
+
radiationWm2: cur("shortwave_radiation"),
|
|
190
|
+
hours: skyHours,
|
|
191
|
+
}
|
|
192
|
+
: undefined;
|
|
193
|
+
const d = r.daily;
|
|
194
|
+
const day = (date: string): ForecastDay | undefined => {
|
|
195
|
+
const i = d?.time.indexOf(date) ?? -1;
|
|
196
|
+
if (!d || i < 0) return undefined;
|
|
197
|
+
const wmo = value(d.weather_code, i);
|
|
198
|
+
return {
|
|
199
|
+
date,
|
|
200
|
+
minC: value(d.temperature_2m_min, i),
|
|
201
|
+
maxC: value(d.temperature_2m_max, i),
|
|
202
|
+
rainChance: value(d.precipitation_probability_max, i),
|
|
203
|
+
rainMm: value(d.precipitation_sum, i),
|
|
204
|
+
text: wmo === undefined ? undefined : WMO[wmo],
|
|
205
|
+
code: wmo === undefined ? undefined : String(wmo),
|
|
206
|
+
};
|
|
207
|
+
};
|
|
208
|
+
const today = localDate(fetchedAt, offset);
|
|
209
|
+
const tomorrow = localDate(fetchedAt + 86_400_000, offset);
|
|
210
|
+
const forecastIso = new Date(forecastAt).toISOString();
|
|
211
|
+
const forecast = d
|
|
212
|
+
? {
|
|
213
|
+
...meta(forecastIso),
|
|
214
|
+
fetchedAt: forecastIso,
|
|
215
|
+
today: day(today),
|
|
216
|
+
tomorrow: day(tomorrow),
|
|
217
|
+
}
|
|
218
|
+
: undefined;
|
|
219
|
+
const hourly = hours.length
|
|
220
|
+
? {
|
|
221
|
+
...meta(forecastIso),
|
|
222
|
+
fetchedAt: forecastIso,
|
|
223
|
+
hours: hours.map(({ radiationWm2: _, ...x }) => x),
|
|
224
|
+
}
|
|
225
|
+
: undefined;
|
|
226
|
+
for (const [i, wmo] of (d?.weather_code ?? []).entries())
|
|
227
|
+
if (wmo !== null && !(wmo in WMO))
|
|
228
|
+
odd.push(`unknown WMO code ${wmo} on ${d?.time[i]}`);
|
|
229
|
+
if (code !== undefined && !(code in WMO))
|
|
230
|
+
odd.push(`unknown WMO code ${code} now`);
|
|
231
|
+
return {
|
|
232
|
+
now,
|
|
233
|
+
forecast,
|
|
234
|
+
hourly,
|
|
235
|
+
sky,
|
|
236
|
+
anomaly: odd.length ? odd.join("; ") : undefined,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export type OpenMeteoOptions = { fetch?: typeof fetch; base?: string };
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Open-Meteo as a weather source: modelled `now`, `forecast`, `hourly` (today and tomorrow) and `sky` (the only source with radiation). `current` is
|
|
244
|
+
* 15-minutely, so it is read every poll; the daily and hourly parts come from models that update hourly at best, so
|
|
245
|
+
* they are read hourly (one call with everything), and the polls between ask for `current` alone.
|
|
246
|
+
*/
|
|
247
|
+
export function openMeteo(options: OpenMeteoOptions = {}): WeatherSource {
|
|
248
|
+
const fetcher = options.fetch ?? fetch;
|
|
249
|
+
const base = options.base ?? "https://api.open-meteo.com/v1/forecast";
|
|
250
|
+
let last: Omit<SourceReading, "nextAt"> = {};
|
|
251
|
+
let full: { body: Record<string, unknown>; at: number } | undefined;
|
|
252
|
+
let fullDue = 0;
|
|
253
|
+
return {
|
|
254
|
+
name: "open-meteo",
|
|
255
|
+
owns: {
|
|
256
|
+
now: 30 * 60_000,
|
|
257
|
+
forecast: 6 * 3_600_000,
|
|
258
|
+
hourly: 6 * 3_600_000,
|
|
259
|
+
sky: 30 * 60_000,
|
|
260
|
+
}, // `current.time` starts its 15-minute interval
|
|
261
|
+
async read({
|
|
262
|
+
place,
|
|
263
|
+
at,
|
|
264
|
+
pollMinutes,
|
|
265
|
+
force,
|
|
266
|
+
signal,
|
|
267
|
+
}): Promise<SourceReading> {
|
|
268
|
+
const everything = force || !full || at >= fullDue;
|
|
269
|
+
const q = new URLSearchParams({
|
|
270
|
+
latitude: String(place.latitude),
|
|
271
|
+
longitude: String(place.longitude),
|
|
272
|
+
timezone: "auto",
|
|
273
|
+
current: CURRENT,
|
|
274
|
+
});
|
|
275
|
+
if (everything)
|
|
276
|
+
for (const [k, v] of Object.entries({
|
|
277
|
+
hourly: HOURLY,
|
|
278
|
+
daily: DAILY,
|
|
279
|
+
forecast_days: "2",
|
|
280
|
+
}))
|
|
281
|
+
q.set(k, v);
|
|
282
|
+
try {
|
|
283
|
+
const body = (await getJson(fetcher, `${base}?${q}`, {
|
|
284
|
+
init: { headers: { "user-agent": USER_AGENT } },
|
|
285
|
+
signal,
|
|
286
|
+
})) as Record<string, unknown>;
|
|
287
|
+
if (everything)
|
|
288
|
+
((full = { body, at }), (fullDue = nextPoll(at, HOURLY_MINUTES)));
|
|
289
|
+
last = toSlices(
|
|
290
|
+
{
|
|
291
|
+
...full!.body,
|
|
292
|
+
current: body.current,
|
|
293
|
+
utc_offset_seconds: body.utc_offset_seconds,
|
|
294
|
+
},
|
|
295
|
+
at,
|
|
296
|
+
full!.at,
|
|
297
|
+
);
|
|
298
|
+
return { ...last, nextAt: nextPoll(at, pollMinutes) };
|
|
299
|
+
} catch (error) {
|
|
300
|
+
return {
|
|
301
|
+
...last,
|
|
302
|
+
anomaly: undefined,
|
|
303
|
+
problem: `open-meteo: ${(error as Error).message}`,
|
|
304
|
+
nextAt: nextPoll(at, pollMinutes),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
},
|
|
308
|
+
};
|
|
309
|
+
}
|