@khalido/home 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +25 -0
- package/CHANGELOG.md +25 -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 +426 -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 +11 -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 +745 -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 +267 -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 +195 -0
- package/dist/discover.js +446 -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 +45 -0
- package/dist/finders.js +173 -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 +39 -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 +26 -0
- package/skills/draw-home/SKILL.md +82 -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 +175 -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 +43 -0
- package/src/connectors/cast/codec.ts +126 -0
- package/src/connectors/cast/index.ts +551 -0
- package/src/connectors/cast/schemas.ts +101 -0
- package/src/connectors/cast/serve.ts +72 -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 +80 -0
- package/src/connectors/spotify/changes.ts +127 -0
- package/src/connectors/spotify/client.ts +387 -0
- package/src/connectors/spotify/index.ts +929 -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 +380 -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 +634 -0
- package/src/energy.ts +221 -0
- package/src/finders-entry.ts +2 -0
- package/src/finders.ts +247 -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,15 @@
|
|
|
1
|
+
import { type Condition, type SourceReading, type WeatherSource } from "./source.ts";
|
|
2
|
+
/** WMO codes in a word: 0–1 clear, 2 partly cloudy, 3 cloudy, then by family. */
|
|
3
|
+
export declare function wmoCondition(code: number): Condition | undefined;
|
|
4
|
+
/** 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. */
|
|
5
|
+
export declare function toSlices(body: unknown, fetchedAt: number, forecastAt?: number): Omit<SourceReading, "nextAt">;
|
|
6
|
+
export type OpenMeteoOptions = {
|
|
7
|
+
fetch?: typeof fetch;
|
|
8
|
+
base?: string;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Open-Meteo as a weather source: modelled `now`, `forecast`, `hourly` (today and tomorrow) and `sky` (the only source with radiation). `current` is
|
|
12
|
+
* 15-minutely, so it is read every poll; the daily and hourly parts come from models that update hourly at best, so
|
|
13
|
+
* they are read hourly (one call with everything), and the polls between ask for `current` alone.
|
|
14
|
+
*/
|
|
15
|
+
export declare function openMeteo(options?: OpenMeteoOptions): WeatherSource;
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { getJson } from "../../connector.js";
|
|
3
|
+
import { HOURLY_MINUTES, iso, localDate, nextPoll, num, USER_AGENT, } from "./source.js";
|
|
4
|
+
// Open-Meteo's forecast API (https://open-meteo.com/en/docs): keyless, JSON, data CC BY 4.0. No `models`, so the default
|
|
5
|
+
// best_match answers; its BOM model (bom_access_global) has returned only nulls since BOM paused open data in mid 2025.
|
|
6
|
+
const CURRENT = "temperature_2m,apparent_temperature,relative_humidity_2m,wind_speed_10m,wind_direction_10m,wind_gusts_10m,cloud_cover,uv_index,shortwave_radiation,weather_code";
|
|
7
|
+
const HOURLY = "temperature_2m,apparent_temperature,precipitation_probability,precipitation,weather_code,wind_speed_10m,wind_gusts_10m,cloud_cover,uv_index,shortwave_radiation";
|
|
8
|
+
/** How many hours ahead `sky.hours` holds. */
|
|
9
|
+
const SKY_HOURS = 6;
|
|
10
|
+
const DAILY = "temperature_2m_max,temperature_2m_min,precipitation_probability_max,precipitation_sum,weather_code";
|
|
11
|
+
const series = z.array(z.number().nullable()).optional();
|
|
12
|
+
const OpenMeteoResponse = z.looseObject({
|
|
13
|
+
utc_offset_seconds: z.number().optional(),
|
|
14
|
+
current: z.record(z.string(), z.unknown()).optional(),
|
|
15
|
+
hourly: z
|
|
16
|
+
.looseObject({
|
|
17
|
+
time: z.array(z.string()),
|
|
18
|
+
temperature_2m: series,
|
|
19
|
+
apparent_temperature: series,
|
|
20
|
+
precipitation_probability: series,
|
|
21
|
+
precipitation: series,
|
|
22
|
+
weather_code: series,
|
|
23
|
+
wind_speed_10m: series,
|
|
24
|
+
wind_gusts_10m: series,
|
|
25
|
+
cloud_cover: series,
|
|
26
|
+
uv_index: series,
|
|
27
|
+
shortwave_radiation: series,
|
|
28
|
+
})
|
|
29
|
+
.optional(),
|
|
30
|
+
daily: z
|
|
31
|
+
.looseObject({
|
|
32
|
+
time: z.array(z.string()),
|
|
33
|
+
temperature_2m_max: series,
|
|
34
|
+
temperature_2m_min: series,
|
|
35
|
+
precipitation_probability_max: series,
|
|
36
|
+
precipitation_sum: series,
|
|
37
|
+
weather_code: series,
|
|
38
|
+
})
|
|
39
|
+
.optional(),
|
|
40
|
+
});
|
|
41
|
+
/** WMO weather codes as Open-Meteo documents them (the table at the end of its docs page). */
|
|
42
|
+
const WMO = {
|
|
43
|
+
0: "Clear sky",
|
|
44
|
+
1: "Mainly clear",
|
|
45
|
+
2: "Partly cloudy",
|
|
46
|
+
3: "Overcast",
|
|
47
|
+
45: "Fog",
|
|
48
|
+
48: "Depositing rime fog",
|
|
49
|
+
51: "Light drizzle",
|
|
50
|
+
53: "Moderate drizzle",
|
|
51
|
+
55: "Dense drizzle",
|
|
52
|
+
56: "Light freezing drizzle",
|
|
53
|
+
57: "Dense freezing drizzle",
|
|
54
|
+
61: "Slight rain",
|
|
55
|
+
63: "Moderate rain",
|
|
56
|
+
65: "Heavy rain",
|
|
57
|
+
66: "Light freezing rain",
|
|
58
|
+
67: "Heavy freezing rain",
|
|
59
|
+
71: "Slight snow",
|
|
60
|
+
73: "Moderate snow",
|
|
61
|
+
75: "Heavy snow",
|
|
62
|
+
77: "Snow grains",
|
|
63
|
+
80: "Slight rain showers",
|
|
64
|
+
81: "Moderate rain showers",
|
|
65
|
+
82: "Violent rain showers",
|
|
66
|
+
85: "Slight snow showers",
|
|
67
|
+
86: "Heavy snow showers",
|
|
68
|
+
95: "Thunderstorm",
|
|
69
|
+
96: "Thunderstorm with slight hail",
|
|
70
|
+
99: "Thunderstorm with heavy hail",
|
|
71
|
+
};
|
|
72
|
+
/** WMO codes in a word: 0–1 clear, 2 partly cloudy, 3 cloudy, then by family. */
|
|
73
|
+
export function wmoCondition(code) {
|
|
74
|
+
if (!(code in WMO))
|
|
75
|
+
return undefined;
|
|
76
|
+
if (code <= 1)
|
|
77
|
+
return "clear";
|
|
78
|
+
if (code === 2)
|
|
79
|
+
return "partly cloudy";
|
|
80
|
+
if (code === 3)
|
|
81
|
+
return "cloudy";
|
|
82
|
+
if (code < 50)
|
|
83
|
+
return "fog";
|
|
84
|
+
if (code < 60)
|
|
85
|
+
return "drizzle";
|
|
86
|
+
if (code < 70)
|
|
87
|
+
return "rain";
|
|
88
|
+
if (code < 80)
|
|
89
|
+
return "snow";
|
|
90
|
+
if (code < 85)
|
|
91
|
+
return "showers";
|
|
92
|
+
if (code < 95)
|
|
93
|
+
return "snow"; // snow showers
|
|
94
|
+
return "storm";
|
|
95
|
+
}
|
|
96
|
+
/** 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. */
|
|
97
|
+
const utc = (local, offset) => {
|
|
98
|
+
const t = Date.parse(`${local}Z`);
|
|
99
|
+
return Number.isNaN(t)
|
|
100
|
+
? undefined
|
|
101
|
+
: iso(new Date(t - offset * 1000).toISOString());
|
|
102
|
+
};
|
|
103
|
+
const value = (s, i) => s?.[i] ?? undefined;
|
|
104
|
+
/** 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. */
|
|
105
|
+
export function toSlices(body, fetchedAt, forecastAt = fetchedAt) {
|
|
106
|
+
const r = OpenMeteoResponse.parse(body);
|
|
107
|
+
const offset = r.utc_offset_seconds ?? 0;
|
|
108
|
+
const odd = [];
|
|
109
|
+
const meta = (at) => ({
|
|
110
|
+
source: "open-meteo",
|
|
111
|
+
modelled: true,
|
|
112
|
+
at,
|
|
113
|
+
fetchedAt: new Date(fetchedAt).toISOString(),
|
|
114
|
+
});
|
|
115
|
+
const c = r.current;
|
|
116
|
+
const cur = (k) => num(c?.[k], `current.${k}`, odd);
|
|
117
|
+
const code = cur("weather_code");
|
|
118
|
+
const nowAt = typeof c?.time === "string" ? utc(c.time, offset) : undefined;
|
|
119
|
+
if (c && !nowAt)
|
|
120
|
+
odd.push(`current.time not understood: ${String(c.time)}`);
|
|
121
|
+
const now = nowAt
|
|
122
|
+
? {
|
|
123
|
+
...meta(nowAt),
|
|
124
|
+
tempC: cur("temperature_2m"),
|
|
125
|
+
apparentC: cur("apparent_temperature"),
|
|
126
|
+
humidity: cur("relative_humidity_2m"),
|
|
127
|
+
windKmh: cur("wind_speed_10m"),
|
|
128
|
+
windDeg: cur("wind_direction_10m"),
|
|
129
|
+
gustKmh: cur("wind_gusts_10m"),
|
|
130
|
+
}
|
|
131
|
+
: undefined;
|
|
132
|
+
const h = r.hourly;
|
|
133
|
+
const unknownCodes = new Set();
|
|
134
|
+
const hours = (h?.time ?? []).flatMap((t, i) => {
|
|
135
|
+
const at = utc(t, offset);
|
|
136
|
+
if (!at)
|
|
137
|
+
odd.push(`hourly time not understood: ${t}`);
|
|
138
|
+
if (!at)
|
|
139
|
+
return [];
|
|
140
|
+
const wmo = value(h?.weather_code, i);
|
|
141
|
+
if (wmo !== undefined && !(wmo in WMO))
|
|
142
|
+
unknownCodes.add(wmo);
|
|
143
|
+
const hour = {
|
|
144
|
+
at,
|
|
145
|
+
tempC: value(h?.temperature_2m, i),
|
|
146
|
+
apparentC: value(h?.apparent_temperature, i),
|
|
147
|
+
rainChance: value(h?.precipitation_probability, i),
|
|
148
|
+
rainMm: value(h?.precipitation, i),
|
|
149
|
+
windKmh: value(h?.wind_speed_10m, i),
|
|
150
|
+
gustKmh: value(h?.wind_gusts_10m, i),
|
|
151
|
+
cloudPct: value(h?.cloud_cover, i),
|
|
152
|
+
uvIndex: value(h?.uv_index, i),
|
|
153
|
+
radiationWm2: value(h?.shortwave_radiation, i),
|
|
154
|
+
code: wmo === undefined ? undefined : String(wmo),
|
|
155
|
+
condition: wmo === undefined ? undefined : wmoCondition(wmo),
|
|
156
|
+
};
|
|
157
|
+
return [hour];
|
|
158
|
+
});
|
|
159
|
+
for (const code of unknownCodes)
|
|
160
|
+
odd.push(`unknown WMO code ${code} in the hourly forecast`);
|
|
161
|
+
// The hourly part is read once an hour; the sky's hours run from the hour it was read in.
|
|
162
|
+
const skyHours = hours
|
|
163
|
+
.filter((x) => Date.parse(x.at) + 3_600_000 > forecastAt)
|
|
164
|
+
.slice(0, SKY_HOURS)
|
|
165
|
+
.map((x) => ({
|
|
166
|
+
at: x.at,
|
|
167
|
+
cloudPct: x.cloudPct,
|
|
168
|
+
uvIndex: x.uvIndex,
|
|
169
|
+
radiationWm2: x.radiationWm2,
|
|
170
|
+
}));
|
|
171
|
+
const sky = now
|
|
172
|
+
? {
|
|
173
|
+
...meta(now.at),
|
|
174
|
+
cloudPct: cur("cloud_cover"),
|
|
175
|
+
uvIndex: cur("uv_index"),
|
|
176
|
+
radiationWm2: cur("shortwave_radiation"),
|
|
177
|
+
hours: skyHours,
|
|
178
|
+
}
|
|
179
|
+
: undefined;
|
|
180
|
+
const d = r.daily;
|
|
181
|
+
const day = (date) => {
|
|
182
|
+
const i = d?.time.indexOf(date) ?? -1;
|
|
183
|
+
if (!d || i < 0)
|
|
184
|
+
return undefined;
|
|
185
|
+
const wmo = value(d.weather_code, i);
|
|
186
|
+
return {
|
|
187
|
+
date,
|
|
188
|
+
minC: value(d.temperature_2m_min, i),
|
|
189
|
+
maxC: value(d.temperature_2m_max, i),
|
|
190
|
+
rainChance: value(d.precipitation_probability_max, i),
|
|
191
|
+
rainMm: value(d.precipitation_sum, i),
|
|
192
|
+
text: wmo === undefined ? undefined : WMO[wmo],
|
|
193
|
+
code: wmo === undefined ? undefined : String(wmo),
|
|
194
|
+
};
|
|
195
|
+
};
|
|
196
|
+
const today = localDate(fetchedAt, offset);
|
|
197
|
+
const tomorrow = localDate(fetchedAt + 86_400_000, offset);
|
|
198
|
+
const forecastIso = new Date(forecastAt).toISOString();
|
|
199
|
+
const forecast = d
|
|
200
|
+
? {
|
|
201
|
+
...meta(forecastIso),
|
|
202
|
+
fetchedAt: forecastIso,
|
|
203
|
+
today: day(today),
|
|
204
|
+
tomorrow: day(tomorrow),
|
|
205
|
+
}
|
|
206
|
+
: undefined;
|
|
207
|
+
const hourly = hours.length
|
|
208
|
+
? {
|
|
209
|
+
...meta(forecastIso),
|
|
210
|
+
fetchedAt: forecastIso,
|
|
211
|
+
hours: hours.map(({ radiationWm2: _, ...x }) => x),
|
|
212
|
+
}
|
|
213
|
+
: undefined;
|
|
214
|
+
for (const [i, wmo] of (d?.weather_code ?? []).entries())
|
|
215
|
+
if (wmo !== null && !(wmo in WMO))
|
|
216
|
+
odd.push(`unknown WMO code ${wmo} on ${d?.time[i]}`);
|
|
217
|
+
if (code !== undefined && !(code in WMO))
|
|
218
|
+
odd.push(`unknown WMO code ${code} now`);
|
|
219
|
+
return {
|
|
220
|
+
now,
|
|
221
|
+
forecast,
|
|
222
|
+
hourly,
|
|
223
|
+
sky,
|
|
224
|
+
anomaly: odd.length ? odd.join("; ") : undefined,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Open-Meteo as a weather source: modelled `now`, `forecast`, `hourly` (today and tomorrow) and `sky` (the only source with radiation). `current` is
|
|
229
|
+
* 15-minutely, so it is read every poll; the daily and hourly parts come from models that update hourly at best, so
|
|
230
|
+
* they are read hourly (one call with everything), and the polls between ask for `current` alone.
|
|
231
|
+
*/
|
|
232
|
+
export function openMeteo(options = {}) {
|
|
233
|
+
const fetcher = options.fetch ?? fetch;
|
|
234
|
+
const base = options.base ?? "https://api.open-meteo.com/v1/forecast";
|
|
235
|
+
let last = {};
|
|
236
|
+
let full;
|
|
237
|
+
let fullDue = 0;
|
|
238
|
+
return {
|
|
239
|
+
name: "open-meteo",
|
|
240
|
+
owns: {
|
|
241
|
+
now: 30 * 60_000,
|
|
242
|
+
forecast: 6 * 3_600_000,
|
|
243
|
+
hourly: 6 * 3_600_000,
|
|
244
|
+
sky: 30 * 60_000,
|
|
245
|
+
}, // `current.time` starts its 15-minute interval
|
|
246
|
+
async read({ place, at, pollMinutes, force, signal, }) {
|
|
247
|
+
const everything = force || !full || at >= fullDue;
|
|
248
|
+
const q = new URLSearchParams({
|
|
249
|
+
latitude: String(place.latitude),
|
|
250
|
+
longitude: String(place.longitude),
|
|
251
|
+
timezone: "auto",
|
|
252
|
+
current: CURRENT,
|
|
253
|
+
});
|
|
254
|
+
if (everything)
|
|
255
|
+
for (const [k, v] of Object.entries({
|
|
256
|
+
hourly: HOURLY,
|
|
257
|
+
daily: DAILY,
|
|
258
|
+
forecast_days: "2",
|
|
259
|
+
}))
|
|
260
|
+
q.set(k, v);
|
|
261
|
+
try {
|
|
262
|
+
const body = (await getJson(fetcher, `${base}?${q}`, {
|
|
263
|
+
init: { headers: { "user-agent": USER_AGENT } },
|
|
264
|
+
signal,
|
|
265
|
+
}));
|
|
266
|
+
if (everything)
|
|
267
|
+
((full = { body, at }), (fullDue = nextPoll(at, HOURLY_MINUTES)));
|
|
268
|
+
last = toSlices({
|
|
269
|
+
...full.body,
|
|
270
|
+
current: body.current,
|
|
271
|
+
utc_offset_seconds: body.utc_offset_seconds,
|
|
272
|
+
}, at, full.at);
|
|
273
|
+
return { ...last, nextAt: nextPoll(at, pollMinutes) };
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
return {
|
|
277
|
+
...last,
|
|
278
|
+
anomaly: undefined,
|
|
279
|
+
problem: `open-meteo: ${error.message}`,
|
|
280
|
+
nextAt: nextPoll(at, pollMinutes),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type WeatherRain } from "./source.ts";
|
|
2
|
+
/** What the rain forecast says about the next stretch. The one owner: every consumer calls `rainSoon`. */
|
|
3
|
+
export type RainSoon = {
|
|
4
|
+
wet: boolean;
|
|
5
|
+
startsInMin?: number;
|
|
6
|
+
endsInMin?: number;
|
|
7
|
+
chance?: number;
|
|
8
|
+
mm?: number;
|
|
9
|
+
intensity?: string;
|
|
10
|
+
type?: string;
|
|
11
|
+
coveredMin: number;
|
|
12
|
+
};
|
|
13
|
+
/** Google's intensities, lightest first. An unknown one is noted by the source, never ranked. */
|
|
14
|
+
export declare const RAIN_INTENSITIES: string[];
|
|
15
|
+
/**
|
|
16
|
+
* Rain in the next `windowMin` minutes from `at` (epoch ms). A segment counts while its end is still ahead, so one that began a few minutes ago is used.
|
|
17
|
+
* A segment with no `type` is unknown, not dry: it adds nothing to `coveredMin`. Returns nothing when the forecast covers none of the window.
|
|
18
|
+
*/
|
|
19
|
+
export declare function rainSoon(rain: WeatherRain | undefined, at: number, windowMin?: number): RainSoon | undefined;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { RAIN_HORIZON_MINUTES } from "./source.js";
|
|
2
|
+
const WET = (type) => type !== undefined && type !== "NONE";
|
|
3
|
+
/** Google's intensities, lightest first. An unknown one is noted by the source, never ranked. */
|
|
4
|
+
export const RAIN_INTENSITIES = ["NO_INTENSITY", "LIGHT", "MODERATE", "HEAVY"];
|
|
5
|
+
const minutes = (ms) => Math.round(ms / 60_000);
|
|
6
|
+
/**
|
|
7
|
+
* Rain in the next `windowMin` minutes from `at` (epoch ms). A segment counts while its end is still ahead, so one that began a few minutes ago is used.
|
|
8
|
+
* A segment with no `type` is unknown, not dry: it adds nothing to `coveredMin`. Returns nothing when the forecast covers none of the window.
|
|
9
|
+
*/
|
|
10
|
+
export function rainSoon(rain, at, windowMin = RAIN_HORIZON_MINUTES) {
|
|
11
|
+
if (!rain)
|
|
12
|
+
return undefined;
|
|
13
|
+
const end = at + windowMin * 60_000;
|
|
14
|
+
const segs = rain.segments
|
|
15
|
+
.map((s) => ({ ...s, f: Date.parse(s.from), t: Date.parse(s.to) }))
|
|
16
|
+
.filter((s) => s.t > at && s.f < end && s.type !== undefined)
|
|
17
|
+
.sort((a, b) => a.f - b.f);
|
|
18
|
+
const covered = segs.reduce((sum, s) => sum + Math.min(s.t, end) - Math.max(s.f, at), 0);
|
|
19
|
+
if (covered <= 0)
|
|
20
|
+
return undefined;
|
|
21
|
+
const coveredMin = minutes(covered);
|
|
22
|
+
const first = segs.findIndex((s) => WET(s.type));
|
|
23
|
+
if (first < 0)
|
|
24
|
+
return { wet: false, coveredMin };
|
|
25
|
+
let last = first;
|
|
26
|
+
while (last + 1 < segs.length &&
|
|
27
|
+
WET(segs[last + 1].type) &&
|
|
28
|
+
segs[last + 1].f <= segs[last].t)
|
|
29
|
+
last++;
|
|
30
|
+
const run = segs.slice(first, last + 1);
|
|
31
|
+
const chances = run.map((s) => s.chance).filter((c) => c !== undefined);
|
|
32
|
+
const mms = run.map((s) => s.mm);
|
|
33
|
+
const heaviest = run
|
|
34
|
+
.map((s) => s.intensity)
|
|
35
|
+
.filter((i) => i !== undefined)
|
|
36
|
+
.sort((a, b) => RAIN_INTENSITIES.indexOf(b) - RAIN_INTENSITIES.indexOf(a))[0];
|
|
37
|
+
const runEnd = run[run.length - 1].t;
|
|
38
|
+
const after = segs[last + 1]; // a known dry segment right after the run proves it ended
|
|
39
|
+
return {
|
|
40
|
+
wet: true,
|
|
41
|
+
startsInMin: Math.max(0, minutes(run[0].f - at)),
|
|
42
|
+
endsInMin: after && after.f <= runEnd ? minutes(runEnd - at) : undefined,
|
|
43
|
+
chance: chances.length ? Math.max(...chances) : undefined,
|
|
44
|
+
mm: mms.every((m) => m !== undefined)
|
|
45
|
+
? mms.reduce((sum, m) => sum + m, 0)
|
|
46
|
+
: undefined,
|
|
47
|
+
intensity: heaviest,
|
|
48
|
+
type: run[0].type,
|
|
49
|
+
coveredMin,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/** Sent on every request, so each service can see who is calling rather than a runtime's default string. */
|
|
2
|
+
export declare const USER_AGENT = "home (+https://github.com/khalido/home.ts)";
|
|
3
|
+
export type Place = {
|
|
4
|
+
latitude: number;
|
|
5
|
+
longitude: number;
|
|
6
|
+
};
|
|
7
|
+
/** The one polling number for observations and sky: every 15 minutes, KO's call. Forecasts follow their issue times. */
|
|
8
|
+
export declare const DEFAULT_POLL_MINUTES = 15;
|
|
9
|
+
/** The next read slot after `at`: every `pollMinutes` from 5 minutes past the hour when `pollMinutes` divides 60 (else from the epoch, plus 5 minutes). */
|
|
10
|
+
export declare function nextPoll(at: number, pollMinutes?: number): number;
|
|
11
|
+
/** What a source is asked to read: where, at what instant (epoch ms), the polling interval to schedule by, and `force` to read everything now. */
|
|
12
|
+
export type ReadRequest = {
|
|
13
|
+
place: Place;
|
|
14
|
+
at: number;
|
|
15
|
+
pollMinutes: number;
|
|
16
|
+
force?: boolean;
|
|
17
|
+
signal?: AbortSignal;
|
|
18
|
+
};
|
|
19
|
+
/** Every slice says who made it, whether it is a model rather than an instrument, the time it is for, and when it was fetched. */
|
|
20
|
+
export type SliceMeta = {
|
|
21
|
+
source: string;
|
|
22
|
+
modelled: boolean;
|
|
23
|
+
at: string;
|
|
24
|
+
fetchedAt: string;
|
|
25
|
+
};
|
|
26
|
+
/** Outside now. Speeds in km/h, direction where the wind comes from. */
|
|
27
|
+
export type WeatherNow = SliceMeta & {
|
|
28
|
+
tempC?: number;
|
|
29
|
+
apparentC?: number;
|
|
30
|
+
humidity?: number;
|
|
31
|
+
windKmh?: number;
|
|
32
|
+
windDir?: string;
|
|
33
|
+
windDeg?: number;
|
|
34
|
+
gustKmh?: number;
|
|
35
|
+
rainMm?: number;
|
|
36
|
+
rainFrom?: string;
|
|
37
|
+
rainTo?: string;
|
|
38
|
+
station?: {
|
|
39
|
+
name: string;
|
|
40
|
+
distanceKm: number;
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
/** One forecast day. Any field can be missing: after BOM's afternoon issue, today has no min, max or rain range. */
|
|
44
|
+
export type ForecastDay = {
|
|
45
|
+
date: string;
|
|
46
|
+
minC?: number;
|
|
47
|
+
maxC?: number;
|
|
48
|
+
rainChance?: number;
|
|
49
|
+
rainMm?: number;
|
|
50
|
+
rainRangeMm?: [number, number];
|
|
51
|
+
text?: string;
|
|
52
|
+
code?: string;
|
|
53
|
+
};
|
|
54
|
+
export type WeatherForecast = SliceMeta & {
|
|
55
|
+
issuedAt?: string;
|
|
56
|
+
today?: ForecastDay;
|
|
57
|
+
tomorrow?: ForecastDay;
|
|
58
|
+
};
|
|
59
|
+
/** Cloud, UV and sunlight on the ground. `radiationWm2` is shortwave (global horizontal) irradiance. */
|
|
60
|
+
export type SkyHour = {
|
|
61
|
+
at: string;
|
|
62
|
+
cloudPct?: number;
|
|
63
|
+
uvIndex?: number;
|
|
64
|
+
radiationWm2?: number;
|
|
65
|
+
};
|
|
66
|
+
export type WeatherSky = SliceMeta & Omit<SkyHour, "at"> & {
|
|
67
|
+
hours: SkyHour[];
|
|
68
|
+
};
|
|
69
|
+
/** One stretch of the minute-by-minute rain forecast. `from` and `to` are UTC ISO; `type` is the source's own (`NONE`, `RAIN`, ...). */
|
|
70
|
+
export type RainSegment = {
|
|
71
|
+
from: string;
|
|
72
|
+
to: string;
|
|
73
|
+
type?: string;
|
|
74
|
+
chance?: number;
|
|
75
|
+
mm?: number;
|
|
76
|
+
intensity?: string;
|
|
77
|
+
};
|
|
78
|
+
/** Rain minute by minute, up to `RAIN_HORIZON_MINUTES` ahead. `at` is when the forecast starts. Ask `rainSoon` what it means. */
|
|
79
|
+
export type WeatherRain = SliceMeta & {
|
|
80
|
+
segments: RainSegment[];
|
|
81
|
+
};
|
|
82
|
+
/** How far ahead the rain forecast is kept and read: two hours, KO's call. */
|
|
83
|
+
export declare const RAIN_HORIZON_MINUTES = 120;
|
|
84
|
+
/** Weather in words, mildest first, so an hour from any source reads the same and a stretch of hours takes its worst. */
|
|
85
|
+
export declare const CONDITIONS: readonly ["clear", "partly cloudy", "cloudy", "fog", "drizzle", "showers", "rain", "snow", "storm"];
|
|
86
|
+
export type Condition = (typeof CONDITIONS)[number];
|
|
87
|
+
/** One forecast hour, starting at `at`. `rainChance` in percent, `rainMm` over the hour, `code` the source's own, `condition` it in a word. */
|
|
88
|
+
export type ForecastHour = {
|
|
89
|
+
at: string;
|
|
90
|
+
tempC?: number;
|
|
91
|
+
apparentC?: number;
|
|
92
|
+
rainChance?: number;
|
|
93
|
+
rainMm?: number;
|
|
94
|
+
windKmh?: number;
|
|
95
|
+
gustKmh?: number;
|
|
96
|
+
cloudPct?: number;
|
|
97
|
+
uvIndex?: number;
|
|
98
|
+
code?: string;
|
|
99
|
+
condition?: Condition;
|
|
100
|
+
};
|
|
101
|
+
/** The hourly forecast for today and tomorrow, from the home's midnight. `at` is when it was read. Ask `dayBlocks` for mornings and evenings. */
|
|
102
|
+
export type WeatherHourly = SliceMeta & {
|
|
103
|
+
hours: ForecastHour[];
|
|
104
|
+
};
|
|
105
|
+
export type SliceName = "now" | "forecast" | "hourly" | "sky" | "rain";
|
|
106
|
+
export type Slices = {
|
|
107
|
+
now?: WeatherNow;
|
|
108
|
+
forecast?: WeatherForecast;
|
|
109
|
+
hourly?: WeatherHourly;
|
|
110
|
+
sky?: WeatherSky;
|
|
111
|
+
rain?: WeatherRain;
|
|
112
|
+
};
|
|
113
|
+
/** One read of a source: the slices it has (last good ones included), when to call again, and anything to report. */
|
|
114
|
+
export type SourceReading = Slices & {
|
|
115
|
+
nextAt: number;
|
|
116
|
+
problem?: string;
|
|
117
|
+
anomaly?: string;
|
|
118
|
+
stop?: string;
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* A weather provider. `owns` names the slices it can fill and how old each may get (by its `at`, beyond one polling
|
|
122
|
+
* interval) before the next source's is used. `read` fetches what is due and returns every slice it holds; it throws only on a bug.
|
|
123
|
+
*/
|
|
124
|
+
export type WeatherSource = {
|
|
125
|
+
name: string;
|
|
126
|
+
owns: Partial<Record<SliceName, number>>;
|
|
127
|
+
minPollMinutes?: number;
|
|
128
|
+
read(request: ReadRequest): Promise<SourceReading>;
|
|
129
|
+
};
|
|
130
|
+
/** The interval a source actually reads at: the shared `pollMinutes`, or its own floor if that is longer. */
|
|
131
|
+
export declare const sourcePollMinutes: (source: WeatherSource, pollMinutes: number) => number;
|
|
132
|
+
/** Hourly: how often forecast and hourly-sky reads happen where nothing documented changes faster. */
|
|
133
|
+
export declare const HOURLY_MINUTES = 60;
|
|
134
|
+
/**
|
|
135
|
+
* One owner for which source fills each slice, so the integration and `home status` show the same thing.
|
|
136
|
+
* `readings` are in preference order; a slice older than its source's `owns` age plus one polling interval falls through.
|
|
137
|
+
*/
|
|
138
|
+
export declare function mergeWeather(readings: {
|
|
139
|
+
source: WeatherSource;
|
|
140
|
+
reading?: SourceReading;
|
|
141
|
+
}[], at: number, pollMinutes?: number): Slices;
|
|
142
|
+
/** GET a URL as text (BOM's XML), with the timeout and user agent every source sends. A non-ok status throws `ConnectorError` with it. */
|
|
143
|
+
export declare function getText(fetcher: typeof fetch, url: string, signal?: AbortSignal): Promise<string>;
|
|
144
|
+
/** A number from a string the source sent, or undefined plus a note in `odd`. Never rounds or clamps. */
|
|
145
|
+
export declare function num(value: unknown, what: string, odd: string[]): number | undefined;
|
|
146
|
+
/** A source's timestamp as UTC ISO, or undefined when it does not parse: one odd time never sinks a whole read. */
|
|
147
|
+
export declare const iso: (s: string | undefined) => string | undefined;
|
|
148
|
+
/** The local calendar date (YYYY-MM-DD) of an instant at a UTC offset in seconds. */
|
|
149
|
+
export declare const localDate: (at: number, offsetSeconds: number) => string;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { ConnectorError, fetchTimeout } from "../../connector.js";
|
|
2
|
+
// The one place a weather provider plugs in: a `WeatherSource` returns slices, and `mergeWeather` picks one per slice.
|
|
3
|
+
/** Sent on every request, so each service can see who is calling rather than a runtime's default string. */
|
|
4
|
+
export const USER_AGENT = "home (+https://github.com/khalido/home.ts)";
|
|
5
|
+
/** The one polling number for observations and sky: every 15 minutes, KO's call. Forecasts follow their issue times. */
|
|
6
|
+
export const DEFAULT_POLL_MINUTES = 15;
|
|
7
|
+
/** Reads land this far past each slot, so BOM's 10-minutely file (sent about 3 minutes past) is there: :05, :20, :35, :50. */
|
|
8
|
+
const SLOT_OFFSET_MS = 5 * 60_000;
|
|
9
|
+
/** The next read slot after `at`: every `pollMinutes` from 5 minutes past the hour when `pollMinutes` divides 60 (else from the epoch, plus 5 minutes). */
|
|
10
|
+
export function nextPoll(at, pollMinutes = DEFAULT_POLL_MINUTES) {
|
|
11
|
+
const step = pollMinutes * 60_000;
|
|
12
|
+
return (Math.floor((at - SLOT_OFFSET_MS) / step) * step + step + SLOT_OFFSET_MS);
|
|
13
|
+
}
|
|
14
|
+
/** How far ahead the rain forecast is kept and read: two hours, KO's call. */
|
|
15
|
+
export const RAIN_HORIZON_MINUTES = 120;
|
|
16
|
+
/** Weather in words, mildest first, so an hour from any source reads the same and a stretch of hours takes its worst. */
|
|
17
|
+
export const CONDITIONS = [
|
|
18
|
+
"clear",
|
|
19
|
+
"partly cloudy",
|
|
20
|
+
"cloudy",
|
|
21
|
+
"fog",
|
|
22
|
+
"drizzle",
|
|
23
|
+
"showers",
|
|
24
|
+
"rain",
|
|
25
|
+
"snow",
|
|
26
|
+
"storm",
|
|
27
|
+
];
|
|
28
|
+
/** The interval a source actually reads at: the shared `pollMinutes`, or its own floor if that is longer. */
|
|
29
|
+
export const sourcePollMinutes = (source, pollMinutes) => Math.max(pollMinutes, source.minPollMinutes ?? 0);
|
|
30
|
+
/** Hourly: how often forecast and hourly-sky reads happen where nothing documented changes faster. */
|
|
31
|
+
export const HOURLY_MINUTES = 60;
|
|
32
|
+
/**
|
|
33
|
+
* One owner for which source fills each slice, so the integration and `home status` show the same thing.
|
|
34
|
+
* `readings` are in preference order; a slice older than its source's `owns` age plus one polling interval falls through.
|
|
35
|
+
*/
|
|
36
|
+
export function mergeWeather(readings, at, pollMinutes = DEFAULT_POLL_MINUTES) {
|
|
37
|
+
const pick = (name) => {
|
|
38
|
+
for (const { source, reading } of readings) {
|
|
39
|
+
const slice = reading?.[name];
|
|
40
|
+
const maxAge = source.owns[name];
|
|
41
|
+
if (slice &&
|
|
42
|
+
maxAge !== undefined &&
|
|
43
|
+
at - Date.parse(slice.at) <=
|
|
44
|
+
maxAge + sourcePollMinutes(source, pollMinutes) * 60_000)
|
|
45
|
+
return slice;
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
48
|
+
};
|
|
49
|
+
return {
|
|
50
|
+
now: pick("now"),
|
|
51
|
+
forecast: pick("forecast"),
|
|
52
|
+
hourly: pick("hourly"),
|
|
53
|
+
sky: pick("sky"),
|
|
54
|
+
rain: pick("rain"),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** GET a URL as text (BOM's XML), with the timeout and user agent every source sends. A non-ok status throws `ConnectorError` with it. */
|
|
58
|
+
export async function getText(fetcher, url, signal) {
|
|
59
|
+
const res = await fetchTimeout(fetcher, url, { headers: { "user-agent": USER_AGENT } }, { signal });
|
|
60
|
+
if (!res.ok)
|
|
61
|
+
throw new ConnectorError(`HTTP ${res.status} on GET ${new URL(url).pathname}`, { status: res.status });
|
|
62
|
+
return res.text();
|
|
63
|
+
}
|
|
64
|
+
/** A number from a string the source sent, or undefined plus a note in `odd`. Never rounds or clamps. */
|
|
65
|
+
export function num(value, what, odd) {
|
|
66
|
+
if (value === undefined || value === null || value === "")
|
|
67
|
+
return undefined;
|
|
68
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
69
|
+
if (Number.isFinite(n))
|
|
70
|
+
return n;
|
|
71
|
+
odd.push(`${what} is not a number: ${String(value)}`);
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
/** A source's timestamp as UTC ISO, or undefined when it does not parse: one odd time never sinks a whole read. */
|
|
75
|
+
export const iso = (s) => s && !Number.isNaN(Date.parse(s)) ? new Date(s).toISOString() : undefined;
|
|
76
|
+
/** The local calendar date (YYYY-MM-DD) of an instant at a UTC offset in seconds. */
|
|
77
|
+
export const localDate = (at, offsetSeconds) => new Date(at + offsetSeconds * 1000).toISOString().slice(0, 10);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** The next of each event after `at`: today's while it is still ahead, else tomorrow's; missing when that day has none (polar). */
|
|
2
|
+
export type SunNext = {
|
|
3
|
+
dawn?: string;
|
|
4
|
+
sunrise?: string;
|
|
5
|
+
sunset?: string;
|
|
6
|
+
dusk?: string;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* The sun at a place and instant, computed locally (suncalc 2: degrees, azimuth clockwise from north). No network.
|
|
10
|
+
* Times are ISO instants for the local solar day containing `at` (the one whose solar noon is nearest); any can be
|
|
11
|
+
* missing at high latitudes when the sun never reaches that altitude.
|
|
12
|
+
*
|
|
13
|
+
* `sunNow(latitude, longitude, now)` is the owner. The weather snapshot holds one recomputed every 5 minutes, so its
|
|
14
|
+
* position, `rising`, `daylight` and `next` can be that old; a consumer that shows them calls `sunNow` with its own clock,
|
|
15
|
+
* as `home status` does.
|
|
16
|
+
*/
|
|
17
|
+
export type SunNow = {
|
|
18
|
+
at: string;
|
|
19
|
+
nightEnd?: string;
|
|
20
|
+
dawn?: string;
|
|
21
|
+
sunrise?: string;
|
|
22
|
+
goldenHourEnd?: string;
|
|
23
|
+
solarNoon: string;
|
|
24
|
+
goldenHour?: string;
|
|
25
|
+
sunset?: string;
|
|
26
|
+
dusk?: string;
|
|
27
|
+
night?: string;
|
|
28
|
+
azimuthDeg: number;
|
|
29
|
+
altitudeDeg: number;
|
|
30
|
+
rising: boolean;
|
|
31
|
+
daylight: boolean;
|
|
32
|
+
next: SunNext;
|
|
33
|
+
alwaysUp?: boolean;
|
|
34
|
+
alwaysDown?: boolean;
|
|
35
|
+
};
|
|
36
|
+
/** One owner for the sun's numbers. `at` is epoch ms. */
|
|
37
|
+
export declare function sunNow(latitude: number, longitude: number, at: number): SunNow;
|
|
38
|
+
/** How far a source's sunrise or sunset may be from the computed one before it is worth saying. */
|
|
39
|
+
export declare const SUN_TOLERANCE_MS: number;
|
|
40
|
+
/**
|
|
41
|
+
* A source's sunrise and sunset checked against the computed sun, which stays the owner: a note per event more than
|
|
42
|
+
* two minutes out, else undefined. Each is compared with the nearest computed event of the solar days either side of it,
|
|
43
|
+
* so near solar midnight at high latitude, where the solar day containing it can be the neighbouring one, it still pairs.
|
|
44
|
+
*/
|
|
45
|
+
export declare function sunDisagrees(source: string, latitude: number, longitude: number, reported: {
|
|
46
|
+
sunrise?: string;
|
|
47
|
+
sunset?: string;
|
|
48
|
+
}): string | undefined;
|