@khalido/home 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/.env.example +25 -0
  2. package/CHANGELOG.md +25 -0
  3. package/LICENSE +21 -0
  4. package/README.md +77 -2
  5. package/dist/clock.d.ts +17 -0
  6. package/dist/clock.js +43 -0
  7. package/dist/config.d.ts +273 -0
  8. package/dist/config.js +392 -0
  9. package/dist/connector.d.ts +34 -0
  10. package/dist/connector.js +47 -0
  11. package/dist/connectors/amber/client.d.ts +61 -0
  12. package/dist/connectors/amber/client.js +106 -0
  13. package/dist/connectors/amber/index.d.ts +30 -0
  14. package/dist/connectors/amber/index.js +94 -0
  15. package/dist/connectors/cast/codec.d.ts +38 -0
  16. package/dist/connectors/cast/codec.js +102 -0
  17. package/dist/connectors/cast/index.d.ts +60 -0
  18. package/dist/connectors/cast/index.js +426 -0
  19. package/dist/connectors/cast/schemas.d.ts +167 -0
  20. package/dist/connectors/cast/schemas.js +87 -0
  21. package/dist/connectors/cast/serve.d.ts +11 -0
  22. package/dist/connectors/cast/serve.js +57 -0
  23. package/dist/connectors/cast/state.d.ts +62 -0
  24. package/dist/connectors/cast/state.js +133 -0
  25. package/dist/connectors/cast/tls.d.ts +3 -0
  26. package/dist/connectors/cast/tls.js +67 -0
  27. package/dist/connectors/cast/writes.d.ts +80 -0
  28. package/dist/connectors/cast/writes.js +390 -0
  29. package/dist/connectors/daikin/client.d.ts +65 -0
  30. package/dist/connectors/daikin/client.js +221 -0
  31. package/dist/connectors/daikin/index.d.ts +41 -0
  32. package/dist/connectors/daikin/index.js +108 -0
  33. package/dist/connectors/enphase/client.d.ts +65 -0
  34. package/dist/connectors/enphase/client.js +173 -0
  35. package/dist/connectors/enphase/index.d.ts +43 -0
  36. package/dist/connectors/enphase/index.js +174 -0
  37. package/dist/connectors/enphase/schemas.d.ts +51 -0
  38. package/dist/connectors/enphase/schemas.js +34 -0
  39. package/dist/connectors/hue/client.d.ts +48 -0
  40. package/dist/connectors/hue/client.js +95 -0
  41. package/dist/connectors/hue/events.d.ts +41 -0
  42. package/dist/connectors/hue/events.js +156 -0
  43. package/dist/connectors/hue/index.d.ts +42 -0
  44. package/dist/connectors/hue/index.js +205 -0
  45. package/dist/connectors/hue/schemas.d.ts +65 -0
  46. package/dist/connectors/hue/schemas.js +48 -0
  47. package/dist/connectors/hue/state.d.ts +43 -0
  48. package/dist/connectors/hue/state.js +84 -0
  49. package/dist/connectors/printers/client.d.ts +28 -0
  50. package/dist/connectors/printers/client.js +74 -0
  51. package/dist/connectors/printers/codec.d.ts +76 -0
  52. package/dist/connectors/printers/codec.js +246 -0
  53. package/dist/connectors/printers/found.d.ts +15 -0
  54. package/dist/connectors/printers/found.js +35 -0
  55. package/dist/connectors/printers/index.d.ts +52 -0
  56. package/dist/connectors/printers/index.js +282 -0
  57. package/dist/connectors/printers/state.d.ts +60 -0
  58. package/dist/connectors/printers/state.js +99 -0
  59. package/dist/connectors/spotify/changes.d.ts +32 -0
  60. package/dist/connectors/spotify/changes.js +84 -0
  61. package/dist/connectors/spotify/client.d.ts +199 -0
  62. package/dist/connectors/spotify/client.js +270 -0
  63. package/dist/connectors/spotify/index.d.ts +92 -0
  64. package/dist/connectors/spotify/index.js +745 -0
  65. package/dist/connectors/up/client.d.ts +183 -0
  66. package/dist/connectors/up/client.js +308 -0
  67. package/dist/connectors/up/index.d.ts +91 -0
  68. package/dist/connectors/up/index.js +213 -0
  69. package/dist/connectors/up/spend.d.ts +78 -0
  70. package/dist/connectors/up/spend.js +213 -0
  71. package/dist/connectors/up/webhook.d.ts +13 -0
  72. package/dist/connectors/up/webhook.js +32 -0
  73. package/dist/connectors/weather/blocks.d.ts +51 -0
  74. package/dist/connectors/weather/blocks.js +106 -0
  75. package/dist/connectors/weather/bom.d.ts +37 -0
  76. package/dist/connectors/weather/bom.js +237 -0
  77. package/dist/connectors/weather/ftp.d.ts +5 -0
  78. package/dist/connectors/weather/ftp.js +46 -0
  79. package/dist/connectors/weather/google.d.ts +31 -0
  80. package/dist/connectors/weather/google.js +364 -0
  81. package/dist/connectors/weather/index.d.ts +53 -0
  82. package/dist/connectors/weather/index.js +267 -0
  83. package/dist/connectors/weather/open-meteo.d.ts +15 -0
  84. package/dist/connectors/weather/open-meteo.js +285 -0
  85. package/dist/connectors/weather/rain.d.ts +19 -0
  86. package/dist/connectors/weather/rain.js +51 -0
  87. package/dist/connectors/weather/source.d.ts +149 -0
  88. package/dist/connectors/weather/source.js +77 -0
  89. package/dist/connectors/weather/sun.d.ts +48 -0
  90. package/dist/connectors/weather/sun.js +65 -0
  91. package/dist/discover.d.ts +195 -0
  92. package/dist/discover.js +446 -0
  93. package/dist/energy.d.ts +61 -0
  94. package/dist/energy.js +162 -0
  95. package/dist/finders-entry.d.ts +1 -0
  96. package/dist/finders-entry.js +2 -0
  97. package/dist/finders.d.ts +45 -0
  98. package/dist/finders.js +173 -0
  99. package/dist/health.d.ts +28 -0
  100. package/dist/health.js +35 -0
  101. package/dist/home.d.ts +81 -0
  102. package/dist/home.js +114 -0
  103. package/dist/index.d.ts +11 -0
  104. package/dist/index.js +11 -0
  105. package/dist/lane.d.ts +17 -0
  106. package/dist/lane.js +74 -0
  107. package/dist/locate.d.ts +27 -0
  108. package/dist/locate.js +59 -0
  109. package/dist/manifest.d.ts +31 -0
  110. package/dist/manifest.js +4 -0
  111. package/dist/manifests.d.ts +5 -0
  112. package/dist/manifests.js +23 -0
  113. package/dist/node-fetch.d.ts +9 -0
  114. package/dist/node-fetch.js +25 -0
  115. package/dist/poll.d.ts +10 -0
  116. package/dist/poll.js +33 -0
  117. package/docs/cast.md +133 -0
  118. package/docs/cli.md +39 -0
  119. package/docs/config.md +36 -0
  120. package/docs/decisions.md +150 -0
  121. package/docs/devices.md +128 -0
  122. package/docs/enphase-notes.md +24 -0
  123. package/docs/google.md +92 -0
  124. package/docs/ideas.md +69 -0
  125. package/docs/matter.md +55 -0
  126. package/docs/performance.md +12 -0
  127. package/docs/philosophy.md +23 -0
  128. package/docs/up.md +84 -0
  129. package/docs/using.md +65 -0
  130. package/docs/weather-probe.md +63 -0
  131. package/examples/home.jsonc +74 -0
  132. package/examples/home.svg +76 -0
  133. package/home.schema.json +474 -0
  134. package/llms.txt +32 -0
  135. package/package.json +143 -4
  136. package/skills/draw-home/README.md +26 -0
  137. package/skills/draw-home/SKILL.md +82 -0
  138. package/skills/use-home/SKILL.md +86 -0
  139. package/src/cli/cache.ts +29 -0
  140. package/src/cli/cast.ts +172 -0
  141. package/src/cli/config.ts +52 -0
  142. package/src/cli/context.ts +118 -0
  143. package/src/cli/discover.ts +175 -0
  144. package/src/cli/enphase.ts +62 -0
  145. package/src/cli/hue.ts +124 -0
  146. package/src/cli/locate.ts +47 -0
  147. package/src/cli/main.ts +182 -0
  148. package/src/cli/printers.ts +21 -0
  149. package/src/cli/probe.ts +274 -0
  150. package/src/cli/spotify.ts +76 -0
  151. package/src/cli/status.ts +228 -0
  152. package/src/cli/up.ts +350 -0
  153. package/src/clock.ts +62 -0
  154. package/src/config.ts +578 -0
  155. package/src/connector.ts +77 -0
  156. package/src/connectors/amber/README.md +40 -0
  157. package/src/connectors/amber/client.ts +157 -0
  158. package/src/connectors/amber/index.ts +118 -0
  159. package/src/connectors/cast/README.md +43 -0
  160. package/src/connectors/cast/codec.ts +126 -0
  161. package/src/connectors/cast/index.ts +551 -0
  162. package/src/connectors/cast/schemas.ts +101 -0
  163. package/src/connectors/cast/serve.ts +72 -0
  164. package/src/connectors/cast/state.ts +203 -0
  165. package/src/connectors/cast/tls.ts +100 -0
  166. package/src/connectors/cast/writes.ts +572 -0
  167. package/src/connectors/daikin/README.md +78 -0
  168. package/src/connectors/daikin/client.ts +295 -0
  169. package/src/connectors/daikin/index.ts +145 -0
  170. package/src/connectors/enphase/README.md +64 -0
  171. package/src/connectors/enphase/client.ts +244 -0
  172. package/src/connectors/enphase/index.ts +241 -0
  173. package/src/connectors/enphase/schemas.ts +45 -0
  174. package/src/connectors/hue/README.md +50 -0
  175. package/src/connectors/hue/client.ts +157 -0
  176. package/src/connectors/hue/events.ts +182 -0
  177. package/src/connectors/hue/index.ts +261 -0
  178. package/src/connectors/hue/schemas.ts +60 -0
  179. package/src/connectors/hue/state.ts +124 -0
  180. package/src/connectors/printers/README.md +46 -0
  181. package/src/connectors/printers/client.ts +114 -0
  182. package/src/connectors/printers/codec.ts +292 -0
  183. package/src/connectors/printers/found.ts +46 -0
  184. package/src/connectors/printers/index.ts +385 -0
  185. package/src/connectors/printers/state.ts +182 -0
  186. package/src/connectors/spotify/README.md +80 -0
  187. package/src/connectors/spotify/changes.ts +127 -0
  188. package/src/connectors/spotify/client.ts +387 -0
  189. package/src/connectors/spotify/index.ts +929 -0
  190. package/src/connectors/up/README.md +35 -0
  191. package/src/connectors/up/client.ts +518 -0
  192. package/src/connectors/up/index.ts +370 -0
  193. package/src/connectors/up/spend.ts +280 -0
  194. package/src/connectors/up/webhook.ts +62 -0
  195. package/src/connectors/weather/README.md +84 -0
  196. package/src/connectors/weather/blocks.ts +173 -0
  197. package/src/connectors/weather/bom.ts +344 -0
  198. package/src/connectors/weather/ftp.ts +48 -0
  199. package/src/connectors/weather/google.ts +425 -0
  200. package/src/connectors/weather/index.ts +380 -0
  201. package/src/connectors/weather/open-meteo.ts +309 -0
  202. package/src/connectors/weather/rain.ts +73 -0
  203. package/src/connectors/weather/source.ts +236 -0
  204. package/src/connectors/weather/sun.ts +114 -0
  205. package/src/discover.ts +634 -0
  206. package/src/energy.ts +221 -0
  207. package/src/finders-entry.ts +2 -0
  208. package/src/finders.ts +247 -0
  209. package/src/health.ts +74 -0
  210. package/src/home.ts +205 -0
  211. package/src/index.ts +62 -0
  212. package/src/lane.ts +110 -0
  213. package/src/locate.ts +85 -0
  214. package/src/manifest.ts +43 -0
  215. package/src/manifests.ts +27 -0
  216. package/src/node-fetch.ts +41 -0
  217. package/src/poll.ts +33 -0
@@ -0,0 +1,106 @@
1
+ import { localDay, localHour, localTime } from "../../clock.js";
2
+ import { field } from "../../manifest.js";
3
+ import { CONDITIONS, } from "./source.js";
4
+ /** The parts of a day by default, by the local hour each starts in: KO's call. A consumer passes its own to `dayBlocks`. */
5
+ export const BLOCKS = [
6
+ { name: "morning", from: 6, to: 12 },
7
+ { name: "afternoon", from: 12, to: 18 },
8
+ { name: "evening", from: 18, to: 22 },
9
+ ];
10
+ const HOUR_MS = 3_600_000;
11
+ function summarise(block, date, hours) {
12
+ const values = (k) => hours.map((h) => h[k]).filter((v) => v !== undefined);
13
+ const max = (k) => values(k).length ? Math.max(...values(k)) : undefined;
14
+ const temps = values("tempC");
15
+ const mm = values("rainMm");
16
+ const worst = hours
17
+ .map((h) => h.condition)
18
+ .filter((c) => c !== undefined)
19
+ .sort((a, b) => CONDITIONS.indexOf(b) - CONDITIONS.indexOf(a))[0];
20
+ return {
21
+ name: block.name,
22
+ date,
23
+ from: new Date(hours[0].t).toISOString(),
24
+ to: new Date(hours[hours.length - 1].t + HOUR_MS).toISOString(),
25
+ hours: hours.length,
26
+ late: hours[0].hour > block.from || undefined,
27
+ minC: temps.length ? Math.min(...temps) : undefined,
28
+ maxC: temps.length ? Math.max(...temps) : undefined,
29
+ rainChance: max("rainChance"),
30
+ rainMm: mm.length === hours.length
31
+ ? Number(mm.reduce((s, v) => s + v, 0).toFixed(2))
32
+ : undefined, // toFixed drops float noise only
33
+ windKmh: max("windKmh"),
34
+ gustKmh: max("gustKmh"),
35
+ uvIndex: max("uvIndex"),
36
+ condition: worst,
37
+ };
38
+ }
39
+ /**
40
+ * The rest of today and tomorrow in `blocks` (`BLOCKS` by default), from the hourly forecast, in the home's time zone (the
41
+ * runtime's when left out). The one owner: the integration, `home weather` and a consumer with its own clock all call
42
+ * `dayBlocks(state.hourly, now, timeZone)`. An hour counts while it has not ended; a part with no hours left is left out.
43
+ * Throws on a block outside 0–24 or ending before it starts.
44
+ */
45
+ export function dayBlocks(hourly, at, timeZone, blocks = BLOCKS) {
46
+ checkBlocks(blocks);
47
+ if (!hourly)
48
+ return undefined;
49
+ const hours = hourly.hours
50
+ .map((h) => ({ ...h, t: Date.parse(h.at) }))
51
+ .filter((h) => h.t + HOUR_MS > at)
52
+ .map((h) => ({
53
+ ...h,
54
+ date: localDay(h.t, timeZone),
55
+ hour: localHour(h.t, timeZone),
56
+ }));
57
+ const cut = (date) => blocks.flatMap((b) => {
58
+ const inside = hours.filter((h) => h.date === date && h.hour >= b.from && h.hour < b.to);
59
+ return inside.length ? [summarise(b, date, inside)] : [];
60
+ });
61
+ const today = localDay(at, timeZone);
62
+ const [y, m, d] = today.split("-").map(Number);
63
+ const tomorrow = new Date(Date.UTC(y, m - 1, d + 1))
64
+ .toISOString()
65
+ .slice(0, 10); // by the calendar: a day is 23 or 25 hours twice a year
66
+ return { source: hourly.source, today: cut(today), tomorrow: cut(tomorrow) };
67
+ }
68
+ /** Throw on blocks a day cannot hold: whole hours within 0–24, each ending after it starts. */
69
+ export function checkBlocks(blocks) {
70
+ for (const b of blocks)
71
+ if (!(Number.isInteger(b.from) &&
72
+ Number.isInteger(b.to) &&
73
+ b.from >= 0 &&
74
+ b.to <= 24 &&
75
+ b.from < b.to))
76
+ throw new RangeError(`day block ${b.name}: hours ${b.from}–${b.to}, want whole hours, 0 ≤ from < to ≤ 24`);
77
+ }
78
+ /** The UV index from which a block's text names it: 3, where sun protection starts to be advised (WHO's "moderate"). A display rule. */
79
+ export const UV_SHOWN = 3;
80
+ /**
81
+ * "afternoon from 16:00 17–19 °C, showers, rain 40% 1.2 mm, wind 25 km/h gusting 40, UV 8". `from` shows when the part is
82
+ * under way or short; UV shows from `UV_SHOWN`.
83
+ */
84
+ export function blockText(b, timeZone) {
85
+ const name = b.late
86
+ ? `${b.name} from ${localTime(Date.parse(b.from), timeZone)}`
87
+ : b.name;
88
+ const temp = b.minC === undefined || b.maxC === undefined
89
+ ? ""
90
+ : b.minC === b.maxC
91
+ ? `${b.minC} °C`
92
+ : `${b.minC}–${b.maxC} °C`;
93
+ const rain = [
94
+ field(b.rainChance, (v) => `rain ${v}%`),
95
+ b.rainMm
96
+ ? `${b.rainChance === undefined ? "rain " : ""}${b.rainMm} mm`
97
+ : "",
98
+ ]
99
+ .filter(Boolean)
100
+ .join(" ");
101
+ const wind = field(b.windKmh, (v) => `wind ${v} km/h${b.gustKmh === undefined ? "" : ` gusting ${b.gustKmh}`}`);
102
+ const uv = b.uvIndex !== undefined && b.uvIndex >= UV_SHOWN ? `UV ${b.uvIndex}` : "";
103
+ return [`${name} ${temp}`.trim(), b.condition ?? "", rain, wind, uv]
104
+ .filter(Boolean)
105
+ .join(", ");
106
+ }
@@ -0,0 +1,37 @@
1
+ import { type Place, type WeatherForecast, type WeatherNow, type WeatherSource } from "./source.ts";
2
+ export declare const FTP_BASE = "ftp://ftp.bom.gov.au/anon/gen/fwo";
3
+ /** Great-circle distance in km. */
4
+ export declare function distanceKm(a: Place, b: Place): number;
5
+ /**
6
+ * The `now` slice from an observations product: the station given by WMO id, else the nearest one that reports air
7
+ * temperature. Rain since 9am keeps its own window, which ends a few minutes after the observation time.
8
+ */
9
+ export declare function toNow(xml: string, place: Place, fetchedAt: number, wmoId?: string): {
10
+ now?: WeatherNow;
11
+ anomaly?: string;
12
+ problem?: string;
13
+ };
14
+ /**
15
+ * The `forecast` slice for one précis location (its name, e.g. "Sydney", or its `aac`, e.g. "NSW_PT131"). Today is the
16
+ * period containing `fetchedAt`; after the afternoon issue it has no min, max or rain range, and they stay missing.
17
+ */
18
+ export declare function toForecast(xml: string, location: string, fetchedAt: number): {
19
+ forecast?: WeatherForecast;
20
+ nextIssue?: number;
21
+ anomaly?: string;
22
+ problem?: string;
23
+ };
24
+ export type BomOptions = {
25
+ /** A `fetch` that speaks ftp://, e.g. `ftpFetch()` from `@khalido/home/weather/ftp`. Without one the source stops at once. */
26
+ fetch?: typeof fetch;
27
+ /** The précis location by name or aac. Without one there is no BOM forecast and that slice falls to the next source. */
28
+ forecastLocation?: string;
29
+ /** A station's WMO id, to pin it instead of the nearest. */
30
+ station?: string;
31
+ /** Another state's product ids (README has the table checked on the server). Default NSW: IDN60920 and IDN11060. */
32
+ observations?: string;
33
+ forecasts?: string;
34
+ base?: string;
35
+ };
36
+ /** BOM as a weather source: observed `now` every polling interval, the forecaster's `forecast` at its issue times. */
37
+ export declare function bom(options?: BomOptions): WeatherSource;
@@ -0,0 +1,237 @@
1
+ import { XMLParser } from "fast-xml-parser";
2
+ import { getText, iso, nextPoll, num, } from "./source.js";
3
+ // The Bureau of Meteorology's anonymous FTP products (https://www.bom.gov.au/catalogue/anon-ftp.shtml): a state's
4
+ // observations (IDx60920, every station, 10-minutely) and its précis forecast (IDx11060). Free, not for commercial use,
5
+ // BOM's default copyright terms. FTP is the channel BOM names for automated access; `fetch` must speak ftp:// (ftp.ts).
6
+ export const FTP_BASE = "ftp://ftp.bom.gov.au/anon/gen/fwo";
7
+ /** Stations whose latest observation is this much older than the file's newest are skipped (daily manual stations). */
8
+ const STALE_STATION_MS = 60 * 60_000;
9
+ /** After the forecast's own next routine issue time, wait this long for the file to land. */
10
+ const ISSUE_GRACE_MS = 5 * 60_000;
11
+ const HOUR = 3_600_000;
12
+ const parser = new XMLParser({
13
+ ignoreAttributes: false,
14
+ attributeNamePrefix: "@",
15
+ parseTagValue: false, // numbers are read here, so "-" or "80%" is seen as written
16
+ parseAttributeValue: false,
17
+ isArray: (name) => [
18
+ "station",
19
+ "period",
20
+ "level",
21
+ "element",
22
+ "area",
23
+ "forecast-period",
24
+ "text",
25
+ ].includes(name),
26
+ });
27
+ const byType = (els, type) => els?.find((e) => e["@type"] === type);
28
+ /** Great-circle distance in km. */
29
+ export function distanceKm(a, b) {
30
+ const rad = Math.PI / 180;
31
+ const dLat = (b.latitude - a.latitude) * rad;
32
+ const dLon = (b.longitude - a.longitude) * rad;
33
+ const h = Math.sin(dLat / 2) ** 2 +
34
+ Math.cos(a.latitude * rad) *
35
+ Math.cos(b.latitude * rad) *
36
+ Math.sin(dLon / 2) ** 2;
37
+ return 2 * 6371 * Math.asin(Math.sqrt(h));
38
+ }
39
+ /**
40
+ * The `now` slice from an observations product: the station given by WMO id, else the nearest one that reports air
41
+ * temperature. Rain since 9am keeps its own window, which ends a few minutes after the observation time.
42
+ */
43
+ export function toNow(xml, place, fetchedAt, wmoId) {
44
+ const stations = parser.parse(xml).product?.observations?.station ?? [];
45
+ const odd = [];
46
+ const surface = (s) => s.period?.[0]?.level?.[0]?.element;
47
+ const time = (s) => Date.parse(s.period?.[0]?.["@time-utc"] ?? "");
48
+ const newest = Math.max(...stations.map(time).filter((t) => !Number.isNaN(t)));
49
+ const located = stations.flatMap((s) => {
50
+ const latitude = Number(s["@lat"]);
51
+ const longitude = Number(s["@lon"]);
52
+ return Number.isFinite(latitude) && Number.isFinite(longitude)
53
+ ? [{ s, km: distanceKm(place, { latitude, longitude }) }]
54
+ : [];
55
+ });
56
+ const pick = wmoId
57
+ ? located.find(({ s }) => s["@wmo-id"] === wmoId) // a pinned station is shown however old; its `at` says so
58
+ : located
59
+ .filter(({ s }) => byType(surface(s), "air_temperature") &&
60
+ newest - time(s) <= STALE_STATION_MS)
61
+ .sort((a, b) => a.km - b.km)[0];
62
+ if (!pick)
63
+ return {
64
+ problem: wmoId
65
+ ? `no station ${wmoId} in this product`
66
+ : "no station with an air temperature in this product",
67
+ };
68
+ const { s, km } = pick;
69
+ const els = surface(s);
70
+ const at = iso(s.period?.[0]?.["@time-utc"]);
71
+ if (!at)
72
+ return { problem: `station ${s["@wmo-id"]} has no observation time` };
73
+ const n = (type) => num(byType(els, type)?.["#text"], type, odd);
74
+ const rain = byType(els, "rainfall");
75
+ const now = {
76
+ source: "bom",
77
+ modelled: false,
78
+ at,
79
+ fetchedAt: new Date(fetchedAt).toISOString(),
80
+ tempC: n("air_temperature"),
81
+ apparentC: n("apparent_temp"),
82
+ humidity: n("rel-humidity"),
83
+ windKmh: n("wind_spd_kmh"),
84
+ windDir: byType(els, "wind_dir")?.["#text"],
85
+ windDeg: n("wind_dir_deg"),
86
+ gustKmh: n("gust_kmh"),
87
+ rainMm: n("rainfall"),
88
+ rainFrom: iso(rain?.["@start-time-utc"]),
89
+ rainTo: iso(rain?.["@end-time-utc"]),
90
+ station: {
91
+ name: s["@description"] ?? s["@wmo-id"] ?? "unnamed",
92
+ distanceKm: km,
93
+ },
94
+ };
95
+ return { now, anomaly: odd.length ? odd.join("; ") : undefined };
96
+ }
97
+ /** "0 to 1 mm" → [0, 1]; anything else is reported, not guessed. */
98
+ function range(text, odd) {
99
+ if (text === undefined)
100
+ return undefined;
101
+ const m = text.match(/^(\d+(?:\.\d+)?) to (\d+(?:\.\d+)?) mm$/);
102
+ if (!m)
103
+ odd.push(`rain range not understood: ${text}`);
104
+ return m ? [Number(m[1]), Number(m[2])] : undefined;
105
+ }
106
+ /**
107
+ * The `forecast` slice for one précis location (its name, e.g. "Sydney", or its `aac`, e.g. "NSW_PT131"). Today is the
108
+ * period containing `fetchedAt`; after the afternoon issue it has no min, max or rain range, and they stay missing.
109
+ */
110
+ export function toForecast(xml, location, fetchedAt) {
111
+ const p = parser.parse(xml).product;
112
+ const nextIssue = Date.parse(p?.amoc?.["next-routine-issue-time-utc"] ?? "");
113
+ const want = location.toLowerCase();
114
+ const areas = (p?.forecast?.area ?? []).filter((a) => a["@type"] === "location");
115
+ const area = areas.find((a) => a["@aac"]?.toLowerCase() === want ||
116
+ a["@description"]?.toLowerCase() === want);
117
+ const result = { nextIssue: Number.isNaN(nextIssue) ? undefined : nextIssue };
118
+ if (!area)
119
+ return {
120
+ ...result,
121
+ problem: `no forecast location "${location}"; have ${areas
122
+ .slice(0, 8)
123
+ .map((a) => a["@description"])
124
+ .join(", ")}…`,
125
+ };
126
+ const odd = [];
127
+ const periods = area["forecast-period"] ?? [];
128
+ const i = periods.findIndex((f) => Date.parse(f["@start-time-utc"] ?? "") <= fetchedAt &&
129
+ fetchedAt < Date.parse(f["@end-time-utc"] ?? ""));
130
+ const day = (f) => {
131
+ if (!f)
132
+ return undefined;
133
+ const el = (type) => byType(f.element, type)?.["#text"];
134
+ const chance = byType(f.text, "probability_of_precipitation")?.["#text"];
135
+ const percent = chance?.match(/^(\d+)%$/)?.[1];
136
+ if (chance !== undefined && percent === undefined)
137
+ odd.push(`rain chance not understood: ${chance}`);
138
+ return {
139
+ date: (f["@start-time-local"] ?? "").slice(0, 10),
140
+ minC: num(el("air_temperature_minimum"), "minimum", odd),
141
+ maxC: num(el("air_temperature_maximum"), "maximum", odd),
142
+ rainChance: percent === undefined ? undefined : Number(percent),
143
+ rainRangeMm: range(el("precipitation_range"), odd),
144
+ text: byType(f.text, "precis")?.["#text"],
145
+ code: el("forecast_icon_code"),
146
+ };
147
+ };
148
+ const issuedAt = iso(p?.amoc?.["issue-time-utc"]);
149
+ const forecast = {
150
+ source: "bom",
151
+ modelled: false,
152
+ at: issuedAt ?? new Date(fetchedAt).toISOString(),
153
+ fetchedAt: new Date(fetchedAt).toISOString(),
154
+ issuedAt,
155
+ today: i < 0 ? undefined : day(periods[i]),
156
+ tomorrow: i < 0 ? undefined : day(periods[i + 1]),
157
+ };
158
+ if (i < 0)
159
+ odd.push(`no forecast period contains ${new Date(fetchedAt).toISOString()}`);
160
+ return {
161
+ ...result,
162
+ forecast,
163
+ anomaly: odd.length ? odd.join("; ") : undefined,
164
+ };
165
+ }
166
+ /** BOM as a weather source: observed `now` every polling interval, the forecaster's `forecast` at its issue times. */
167
+ export function bom(options = {}) {
168
+ const fetcher = options.fetch ?? fetch;
169
+ const base = options.base ?? FTP_BASE;
170
+ const obsUrl = `${base}/${options.observations ?? "IDN60920"}.xml`;
171
+ const fcUrl = `${base}/${options.forecasts ?? "IDN11060"}.xml`;
172
+ let now;
173
+ let forecast;
174
+ let obsDue = 0;
175
+ let fcDue = 0;
176
+ const notes = {
177
+ obs: {},
178
+ fc: {},
179
+ };
180
+ return {
181
+ name: "bom",
182
+ owns: { now: 30 * 60_000, forecast: 24 * HOUR },
183
+ async read({ place, at, pollMinutes, force, signal }) {
184
+ // Plain fetch never reads ftp:// (Bun and Node fail differently), so this is terminal, like a rejected key.
185
+ if (!options.fetch && base.startsWith("ftp:"))
186
+ return {
187
+ stop: "bom: plain fetch cannot read ftp://; pass fetch: ftpFetch() from @khalido/home/weather/ftp",
188
+ nextAt: Infinity,
189
+ };
190
+ if (force || at >= obsDue) {
191
+ try {
192
+ const r = toNow(await getText(fetcher, obsUrl, signal), place, at, options.station);
193
+ now = r.now ?? now;
194
+ notes.obs = {
195
+ problem: r.problem && `bom observations: ${r.problem}`,
196
+ anomaly: r.anomaly,
197
+ };
198
+ }
199
+ catch (error) {
200
+ notes.obs = {
201
+ problem: `bom observations: ${error.message}`,
202
+ };
203
+ }
204
+ obsDue = nextPoll(at, pollMinutes);
205
+ }
206
+ if (!options.forecastLocation)
207
+ notes.fc = { problem: "bom forecast: no forecastLocation set" };
208
+ else if (force || at >= fcDue) {
209
+ try {
210
+ const r = toForecast(await getText(fetcher, fcUrl, signal), options.forecastLocation, at);
211
+ forecast = r.forecast ?? forecast;
212
+ notes.fc = {
213
+ problem: r.problem && `bom forecast: ${r.problem}`,
214
+ anomaly: r.anomaly,
215
+ };
216
+ // At the file's own next routine issue; hourly only when it names none. An amendment between issues waits (README).
217
+ fcDue =
218
+ r.nextIssue && r.nextIssue > at
219
+ ? r.nextIssue + ISSUE_GRACE_MS
220
+ : at + HOUR;
221
+ }
222
+ catch (error) {
223
+ notes.fc = { problem: `bom forecast: ${error.message}` };
224
+ fcDue = nextPoll(at, pollMinutes);
225
+ }
226
+ }
227
+ const join = (a, b) => [a, b].filter(Boolean).join("; ") || undefined;
228
+ return {
229
+ now,
230
+ forecast,
231
+ problem: join(notes.obs.problem, notes.fc.problem),
232
+ anomaly: join(notes.obs.anomaly, notes.fc.anomaly),
233
+ nextAt: options.forecastLocation ? Math.min(obsDue, fcDue) : obsDue,
234
+ };
235
+ },
236
+ };
237
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * A `fetch` that also reads anonymous `ftp://` URLs (BOM's products), passing every other URL to the global `fetch`.
3
+ * One connection per request; a missing file answers 404. Honours `init.signal`, so a caller's timeout applies.
4
+ */
5
+ export declare function ftpFetch(timeoutMs?: number): typeof fetch;
@@ -0,0 +1,46 @@
1
+ import { Client, FTPError } from "basic-ftp";
2
+ import { Writable } from "node:stream";
3
+ // The one file in the library that uses Node APIs (net and stream, through basic-ftp), so it sits outside the
4
+ // runtime-neutral build (tsconfig.build.json) and has its own (tsconfig.node.json). Runs in Bun and Node.
5
+ /**
6
+ * A `fetch` that also reads anonymous `ftp://` URLs (BOM's products), passing every other URL to the global `fetch`.
7
+ * One connection per request; a missing file answers 404. Honours `init.signal`, so a caller's timeout applies.
8
+ */
9
+ export function ftpFetch(timeoutMs = 10_000) {
10
+ const f = async (input, init) => {
11
+ const url = new URL(input instanceof Request ? input.url : String(input));
12
+ if (url.protocol !== "ftp:")
13
+ return fetch(input, init);
14
+ const client = new Client(timeoutMs);
15
+ const abort = () => client.close();
16
+ init?.signal?.throwIfAborted();
17
+ init?.signal?.addEventListener("abort", abort, { once: true });
18
+ const chunks = [];
19
+ try {
20
+ await client.access({
21
+ host: url.hostname,
22
+ port: url.port ? Number(url.port) : 21,
23
+ user: url.username || "anonymous",
24
+ password: url.password || "anonymous@",
25
+ });
26
+ const sink = new Writable({
27
+ write: (chunk, _encoding, done) => (chunks.push(chunk), done()),
28
+ });
29
+ await client.downloadTo(sink, decodeURIComponent(url.pathname));
30
+ return new Response(new Uint8Array(Buffer.concat(chunks)), {
31
+ status: 200,
32
+ });
33
+ }
34
+ catch (error) {
35
+ init?.signal?.throwIfAborted();
36
+ if (error instanceof FTPError && error.code === 550)
37
+ return new Response(error.message, { status: 404 });
38
+ throw error;
39
+ }
40
+ finally {
41
+ init?.signal?.removeEventListener("abort", abort);
42
+ client.close();
43
+ }
44
+ };
45
+ return f;
46
+ }
@@ -0,0 +1,31 @@
1
+ import { type Place, type SourceReading, type WeatherRain, type WeatherSource } from "./source.ts";
2
+ export declare const BASE = "https://weather.googleapis.com/v1";
3
+ /**
4
+ * 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.
5
+ * With `place`, each day's `sunEvents` are checked against the computed sun (the owner); one more than 2 minutes out is an anomaly.
6
+ */
7
+ export declare function toSlices(current: unknown, days: unknown, hours: unknown, fetchedAt: number, forecastAt?: number, place?: Place): Omit<SourceReading, "nextAt">;
8
+ /**
9
+ * The minute forecast as a `rain` slice: the segments that start within `RAIN_HORIZON_MINUTES` of `fetchedAt`. A segment with an odd time is
10
+ * skipped and noted; a rain amount in a unit other than millimetres is left out and noted, never converted.
11
+ */
12
+ export declare function toRain(minutes: unknown, fetchedAt: number): {
13
+ rain?: WeatherRain;
14
+ anomaly?: string;
15
+ };
16
+ export type GoogleOptions = {
17
+ key: string;
18
+ fetch?: typeof fetch;
19
+ base?: string;
20
+ };
21
+ /**
22
+ * 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:
23
+ * 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.
24
+ */
25
+ export declare const MIN_POLL_MINUTES = 15;
26
+ export declare const CURRENT_MINUTES = 30;
27
+ /**
28
+ * Google Weather as a source: modelled `now`, `forecast` (10 days available, 2 read), `sky` without radiation, and `rain` from the
29
+ * minute forecast (Experimental at Google, so its failure is a `problem` on `rain` and never stops the other slices).
30
+ */
31
+ export declare function google(options: GoogleOptions): WeatherSource;