@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.
Files changed (217) hide show
  1. package/.env.example +25 -0
  2. package/CHANGELOG.md +47 -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 +428 -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 +18 -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 +754 -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 +280 -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 +197 -0
  92. package/dist/discover.js +452 -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 +48 -0
  98. package/dist/finders.js +176 -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 +41 -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 +25 -0
  137. package/skills/draw-home/SKILL.md +80 -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 +182 -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 +44 -0
  160. package/src/connectors/cast/codec.ts +126 -0
  161. package/src/connectors/cast/index.ts +552 -0
  162. package/src/connectors/cast/schemas.ts +101 -0
  163. package/src/connectors/cast/serve.ts +82 -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 +83 -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 +937 -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 +396 -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 +638 -0
  206. package/src/energy.ts +221 -0
  207. package/src/finders-entry.ts +2 -0
  208. package/src/finders.ts +250 -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,84 @@
1
+ # Weather
2
+
3
+ Outside weather for the home: what it is now, today and tomorrow by the hour and by morning, afternoon and evening, how much sun is reaching the roof, and where the sun is. `iot_class`: **cloud_polling**. The probe that chose these sources is [`docs/weather-probe.md`](../../../docs/weather-probe.md).
4
+
5
+ ```ts
6
+ import { weather, bom, openMeteo } from "@khalido/home/weather";
7
+ import { ftpFetch } from "@khalido/home/weather/ftp";
8
+
9
+ weather({
10
+ latitude,
11
+ longitude,
12
+ sources: [
13
+ bom({ fetch: ftpFetch(), forecastLocation: "Sydney" }),
14
+ openMeteo(),
15
+ ],
16
+ });
17
+ ```
18
+
19
+ ## Slices
20
+
21
+ Each slice carries `source`, `modelled` (a model, not an instrument), `at` (the time it is for) and `fetchedAt`. Observed and modelled data are never shown as each other.
22
+
23
+ | Slice | What | Owner with BOM first | Fallback |
24
+ | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------- | -------------------------------------------- |
25
+ | `now` | temperature, feels-like, humidity, wind and gust, rain since 9am with its own window, station and distance | BOM (observed) | Open-Meteo `current` (modelled, 15-minutely) |
26
+ | `forecast` | today and tomorrow: min, max, rain chance, rain range (BOM) or amount (models), text, the source's icon code | BOM précis | Open-Meteo daily (modelled) |
27
+ | `hourly` | today and tomorrow hour by hour from the home's midnight: temperature, feels-like, rain chance and amount, wind and gust, cloud, UV, the source's code and a `condition` word. `dayBlocks(hourly, at, timeZone)` cuts it into morning, afternoon and evening | Open-Meteo (the only source read hourly) | none |
28
+ | `sky` | cloud %, UV index, shortwave radiation W/m², now and the next 6 hours | Open-Meteo (the only source with radiation) | Google (no radiation) |
29
+ | `rain` | minute by minute for the next 2 hours, in 15-minute segments: type, chance, amount, weight. `rainSoon(rain, at)` turns it into when rain starts and ends | Google's minute forecast (the only source) | none |
30
+ | `sun` | dawn, sunrise, golden hour, solar noon, sunset, dusk, astronomical night, azimuth, altitude, rising, daylight, and the next dawn, sunrise, sunset and dusk | computed: `sunNow(latitude, longitude, at)` with suncalc, no network | none needed |
31
+
32
+ **Day blocks.** `dayBlocks(state.hourly, now, timeZone, blocks?)` is the owner: by default morning 6–12, afternoon 12–18, evening 18–22 (`BLOCKS`, KO's call) by the local hour each hour starts in, for the rest of today and all of tomorrow. A consumer passes its own parts of the day (`[{ name: "Noon", from: 11, to: 15 }, …]`, each within 0–24), to `dayBlocks` or as `weather({ blocks })`. An hour counts while it has not ended, so at 16:50 today has "afternoon from 16:00" (2 hours) and the evening. Each block holds `hours` (how many it summarises), min and max temperature, the highest rain chance, the rain summed (missing if any hour lacks it, never zero), the highest wind, gust and UV index, `late` when it starts after its part of the day does, and the worst hour's `condition`. `condition` is one word from `CONDITIONS`, mildest first: clear, partly cloudy, cloudy, fog, drizzle, showers, rain, snow, storm; Open-Meteo maps WMO codes to it by family (`wmoCondition`). The snapshot's `blocks` is recomputed at every report and every 5 minutes; `blockText` gives the words `home weather` prints; it names UV from 3 (`UV_SHOWN`, where WHO starts advising sun protection).
33
+
34
+ **The home's time zone.** `weather({ timeZone })`, from the config's `location.timezone` or `HOME_TIMEZONE`, sets where today and tomorrow start for the blocks and the zone every time in the text is shown in. Unset, it is the machine's: on a Pi left on UTC that cuts Sydney's afternoon at UTC's midday, and `home weather` reads "05:50" for 16:50. Checked live on 2026-10-16 (Bun 1.4.3) with `TZ=UTC`.
35
+
36
+ `sources` is the preference order. The default is `[openMeteo()]`, which works anywhere with no key. **In Australia put BOM first; it needs the FTP transport, so it is opt-in**, as in the example above and in `home status`. Put Google last when it is used at all: it needs a key, its calls count against a monthly quota and it has no radiation, so it is the last resort for every slice, not a preference. `mergeWeather` (one owner, used by the integration and `home status`) gives each slice to the first source holding one whose `at` is younger than one polling interval plus what that source allows (`owns`): `now` and `sky` 30 min for every source, BOM `forecast` 24 h, the models' `forecast` 6 h. An older one falls through to the next source. A fourth provider is a `WeatherSource` (`source.ts`): a `name`, `owns`, and `read({ place, at, pollMinutes, force, signal })` returning its slices and `nextAt`. `force` (from `refresh()`) means read everything now, forecasts included, whatever the schedule. The integration is `connected` while some source's latest read brought a slice fetched in that read; a read that returns only its last good slices is a failed read, so the part is `disconnected` and keeps the data.
37
+
38
+ **One cadence.** `weather({ pollMinutes })`, default **15**, is how often every source reads observations and sky, at :05, :20, :35 and :50 (`nextPoll`), so BOM's 10-minutely file, sent about 3 minutes past, has landed. Forecasts follow their own issue times (BOM) or are read hourly (the models). A source billed per call sets a floor, `minPollMinutes`, and reads at whichever is longer: Google's is 15, the length of a rain segment, and inside that read it takes current conditions only every 30 minutes. Nothing reads faster than this, `refresh()` included: it forces only sources without a floor, and skips a billed source whose last read is younger than its floor.
39
+
40
+ **Two outside temperatures.** The Daikin outdoor unit's sensor (`outsideC`) and the BOM station are different instruments in different places. `home status` shows both, the first as "outdoor unit", the second under the station's name; nothing here prefers one.
41
+
42
+ ## Sources
43
+
44
+ **BOM, anonymous FTP** (`bom.ts`, `ftp.ts`). The channel BOM's own block page names for automated access. Two state products, read with `fast-xml-parser`, every element by its `type`, numbers parsed here so "-" or "80%" shows up in `anomaly` instead of becoming NaN:
45
+
46
+ - **Observations `IDx60920`** (NSW 560 KB): every station in the state. `now` is the nearest station reporting air temperature whose last observation is within an hour of the file's newest (some stations are daily manual reports, days old), unless `station` pins a WMO id. Distance is great-circle from the station's 4-decimal coordinates. Most stations report every 10 minutes and the file lands about 3 minutes later; at the 15-minute default this is 96 reads, about **54 MB of FTP a day** for the NSW file.
47
+ - **Précis forecast `IDx11060` and siblings** (NSW 736 KB): needs `forecastLocation` (a name like "Sydney" or an aac like `NSW_PT131`), because the locations carry no coordinates and a guessed town would be worse than asking. Without one, `forecast` falls through. Today is the period containing now; after the afternoon issue it has no min, max or rain range, and they stay missing. Read again at the file's own `next-routine-issue-time-utc` plus 5 minutes (hourly if it names none), so an amendment between routine issues waits for the next one.
48
+
49
+ | State | `observations` | `forecasts` |
50
+ | --------------------- | -------------- | ----------- |
51
+ | NSW and ACT (default) | IDN60920 | IDN11060 |
52
+ | Victoria | IDV60920 | IDV10753 |
53
+ | Queensland | IDQ60920 | IDQ11295 |
54
+ | South Australia | IDS60920 | IDS10044 |
55
+ | Western Australia | IDW60920 | IDW14199 |
56
+ | Tasmania | IDT60920 | IDT16710 |
57
+ | Northern Territory | IDD60920 | IDD10207 |
58
+
59
+ Each id was checked on the server on 2026-10-13 (the file exists; for the forecasts, its `amoc` names the state and service `WSP`/`FPR`). `IDN10064` is a 19 KB Sydney-metro-only forecast in the same shape, if that covers the home.
60
+
61
+ Plain `fetch` cannot read `ftp://` (Bun 1.4.2: "protocol must be http:, https: or s3:"), so `bom()` takes a `fetch` that can: `ftpFetch()` from `@khalido/home/weather/ftp`, `basic-ftp` over Node's `net`, which runs in Bun and Node. It is the one library file using Node APIs, so it is outside the runtime-neutral build (`tsconfig.build.json`) and type-checked by `tsconfig.node.json`. `bom()` without an FTP `fetch` stops at once, like a rejected key: "pass fetch: ftpFetch()" goes in `detail`, nothing is fetched or retried, and its slices fall through. Measured, Bun 1.4.2: `IDN60920` 0.8 s, `IDN10064` 0.3 s, both products together 1.8–2.0 s.
62
+
63
+ **Open-Meteo** (`open-meteo.ts`, [docs](https://open-meteo.com/en/docs)). One keyless JSON call per poll: `current` is 15-minutely model data (`interval: 900`), so it is read every poll, and the daily and hourly parts, from models that update hourly at best (6-hourly for IFS), are added to one call an hour. That call reads 48 hours from today's local midnight (`forecast_days=2`) for `hourly`; `sky.hours` is the 6 from the hour it was read. Open-Meteo gives local times and one `utc_offset_seconds`, and applies that offset to every time, across a daylight-saving change too (it returned 02:00 on the night Sydney skipped it; live, 2026-10-16), so `wall − offset` is right for all of them. `dayBlocks` labels each UTC hour in the real zone. 96 calls a day at the default, against 10,000 free. No `models` parameter: [Open-Meteo's BOM page](https://open-meteo.com/en/docs/bom-api) says BOM's open-data delivery is still suspended, and its `bom_access_global` answers only nulls. Radiation is the mean over the preceding interval. `precipitation` is the last 15 minutes, so `now.rainMm` stays empty rather than pretending to be since 9am. 1.3 s a call from Sydney, nearly all of it the TLS handshake with a server in Germany. Plain `fetch` of the JSON API, not the `openmeteo` SDK: the SDK forces FlatBuffers, reads variables by position, calls the global `fetch` (no injection for tests) and adds about 700 KB.
64
+
65
+ **Google Weather** (`google.ts`, [docs](https://developers.google.com/maps/documentation/weather)). Built from the docs' example responses (`test/fixtures/weather/google-*.json`, the complete objects only; the docs elide the rest) and checked live against Sydney on 2026-10-13: `now`, `forecast` and `sky` in 1.19 s. `google({ key })` reads `forecast/minutes:lookup` every 15 minutes (its floor), `currentConditions:lookup` every 30 and `forecast/days:lookup?days=2` and `forecast/hours:lookup?hours=6` hourly, since Google documents nothing that changes faster. That is 192 calls a day, about 5,800–5,950 a month. Google bills each call as one Weather Usage event: 10,000 free a month, then $0.15 per 1,000 ([pricing](https://developers.google.com/maps/billing-and-pricing/pricing)). Any other use of the same key shares that quota. Whether the minute forecast bills as the same event is unverified: its docs page shows no price. Every slice is `modelled: true`: the docs never say it is observed. A day's rain chance is the higher of its day and night halves, its amount their sum; `currentConditionsHistory.qpf` is a rolling 24 hours, so it is not used as rain since 9am. A 400, 401 or 403 stops the source (`stop`), with no retry loop. The key goes in the query string and never into a message.
66
+
67
+ **Google minute forecast** (`toRain` in `google.ts`, [docs](https://developers.google.com/maps/documentation/weather/minute-forecast)). `GET /v1/forecast/minutes:lookup` for a point returns `overallPredictionTimeframe` and 24 `segments` (6 hours) of 15 minutes, each with `type` (`NONE`, `RAIN`, `SNOW`, `HAIL`), `probability` 0–100, `qpf` in mm and `intensity`. Probed for Sydney CBD on 2026-10-15 (curl): HTTP 200 in 1.0 s, every segment dry; the first segment began 12 minutes before the forecast's start. Google calls it Experimental, so its failure is a `problem` on `rain` and never stops the other slices. The docs do not list its coverage by country, say nothing of how it is made, and allow keeping minute values for one hour at most: `rain` owns 15 minutes of age on top of one poll, so a stale slice is dropped, not served (with a `pollMinutes` above 45 the slice outlives that hour: keep it at 15), and the test fixture is invented values in the real shape. We keep the segments that start within 2 hours (`RAIN_HORIZON_MINUTES`). **`rainSoon(rain, at, windowMin = 120)` is the owner**: when the first wet stretch starts and ends, its chance, amount and weight, and `coveredMin`, how much of the window the forecast covers. A segment with no `type` is unknown, not dry. Next: if the minute forecast fails, fall back to the hourly rain chance (`forecast/hours:lookup` already carries `precipitation`; not read yet).
68
+
69
+ **Sun** (`sun.ts`). `sunNow` with [suncalc](https://github.com/mourner/suncalc) 2.1.1, which bundles its own types (degrees, azimuth clockwise from north; `@types/suncalc` describes v1 and is wrong for it). Times are for the local solar day containing `at`. On 2026-10-13 it matched BOM's own Sydney sunrise and sunset to the second. Dawn and dusk are civil (6° below the horizon), `nightEnd` and `night` astronomical (18°), `goldenHour` starts the evening's and `goldenHourEnd` ends the morning's (6° above). `rising` is before solar noon, which in suncalc's solar day means from solar midnight; `daylight` is altitude above 0°. **`sunNow(latitude, longitude, now)` is the owner**: the snapshot's `sun` is recomputed every 5 minutes, so a consumer showing `rising`, `daylight`, `next` or the position calls `sunNow` with its own clock, as `home status` does. `next` holds each of dawn, sunrise, sunset and dusk at its next occurrence after `at` (today's while still ahead, else tomorrow's), missing on a day that has none. The computed sun is the owner: Google's daily `sunEvents` are parsed only to check it, and a sunrise or sunset more than 2 minutes from the computed one (`sunDisagrees`, comparing within the solar day containing Google's time) goes into Google's `anomaly`, never into the sun.
70
+
71
+ Every request has a 10 s timeout and the user agent `home (+https://github.com/khalido/home.ts)`: BOM's app API gives Bun's default `Bun/1.4.2` no answer at all, so nothing here relies on the default.
72
+
73
+ ## Not built, on purpose
74
+
75
+ - **BOM's app API** (`api.weather.bom.gov.au/v1/locations/<geohash>/…`, used by [bremor/bureau_of_meteorology](https://github.com/bremor/bureau_of_meteorology)). Fast and convenient, but every response says: "This application programming interface (API) is owned by the Bureau of Meteorology. You must not use, copy or share it."
76
+ - **`reg.bom.gov.au/fwo/…`** over HTTPS. It answers anyone today, but it is BOM's Registered User host, a paid service ([charges](https://reg.bom.gov.au/other/charges.shtml)).
77
+ - **`www.bom.gov.au/fwo/…`** with a browser user agent. The 403 it gives other clients says automated access "should stop".
78
+
79
+ ## Licences
80
+
81
+ - **BOM products**: not CC BY. The [data feeds catalogue](https://www.bom.gov.au/catalogue/data-feeds.shtml) says they "are free to access and are not for commercial use"; the [copyright notice](https://www.bom.gov.au/other/copyright.shtml)'s default terms allow personal use and say "You must not supply it to any other person". A home reading them is fine; the BOM fixtures here are invented values in the real shape for that reason.
82
+ - **Open-Meteo**: data [CC BY 4.0](https://open-meteo.com/en/licence) (link to Open-Meteo wherever its data is shown); the free API is non-commercial, under 10,000 calls a day, 5,000 an hour, 600 a minute ([terms](https://open-meteo.com/en/terms)).
83
+ - **Google Weather**: Maps Platform terms, billed per call (see Google Weather above).
84
+ - **Libraries**: suncalc (BSD-2-Clause), basic-ftp (MIT), fast-xml-parser (MIT).
@@ -0,0 +1,173 @@
1
+ import { localDay, localHour, localTime } from "../../clock.ts";
2
+ import { field } from "../../manifest.ts";
3
+ import {
4
+ CONDITIONS,
5
+ type Condition,
6
+ type ForecastHour,
7
+ type WeatherHourly,
8
+ } from "./source.ts";
9
+
10
+ /** One part of a day: its name and the local hours it covers, `from` up to `to` (0–24, within one day). */
11
+ export type BlockDef = { name: string; from: number; to: number };
12
+
13
+ /** The parts of a day by default, by the local hour each starts in: KO's call. A consumer passes its own to `dayBlocks`. */
14
+ export const BLOCKS: readonly BlockDef[] = [
15
+ { name: "morning", from: 6, to: 12 },
16
+ { name: "afternoon", from: 12, to: 18 },
17
+ { name: "evening", from: 18, to: 22 },
18
+ ];
19
+
20
+ /**
21
+ * One part of a day from the hourly forecast: `hours` is how many hours it summarises, fewer than its length when it is under way or
22
+ * the source left hours out. `rainChance` is the highest hour's, `rainMm` the sum (missing if any hour lacks one), `condition` the worst hour's.
23
+ * `late`: it starts after its part of the day does (under way, or the source began later).
24
+ */
25
+ export type DayBlock = {
26
+ name: string;
27
+ date: string; // local YYYY-MM-DD
28
+ from: string; // UTC ISO, the first hour counted
29
+ to: string; // UTC ISO, the end of the last
30
+ hours: number;
31
+ late?: boolean;
32
+ minC?: number;
33
+ maxC?: number;
34
+ rainChance?: number;
35
+ rainMm?: number;
36
+ windKmh?: number; // the highest hour's
37
+ gustKmh?: number;
38
+ uvIndex?: number; // the highest hour's
39
+ condition?: Condition;
40
+ };
41
+ export type DayBlocks = {
42
+ source: string;
43
+ today: DayBlock[];
44
+ tomorrow: DayBlock[];
45
+ };
46
+
47
+ const HOUR_MS = 3_600_000;
48
+
49
+ function summarise(
50
+ block: BlockDef,
51
+ date: string,
52
+ hours: (ForecastHour & { t: number; hour: number })[],
53
+ ): DayBlock {
54
+ const values = (
55
+ k: "tempC" | "rainChance" | "windKmh" | "gustKmh" | "rainMm" | "uvIndex",
56
+ ) => hours.map((h) => h[k]).filter((v) => v !== undefined);
57
+ const max = (k: Parameters<typeof values>[0]) =>
58
+ values(k).length ? Math.max(...values(k)) : undefined;
59
+ const temps = values("tempC");
60
+ const mm = values("rainMm");
61
+ const worst = hours
62
+ .map((h) => h.condition)
63
+ .filter((c) => c !== undefined)
64
+ .sort((a, b) => CONDITIONS.indexOf(b) - CONDITIONS.indexOf(a))[0];
65
+ return {
66
+ name: block.name,
67
+ date,
68
+ from: new Date(hours[0]!.t).toISOString(),
69
+ to: new Date(hours[hours.length - 1]!.t + HOUR_MS).toISOString(),
70
+ hours: hours.length,
71
+ late: hours[0]!.hour > block.from || undefined,
72
+ minC: temps.length ? Math.min(...temps) : undefined,
73
+ maxC: temps.length ? Math.max(...temps) : undefined,
74
+ rainChance: max("rainChance"),
75
+ rainMm:
76
+ mm.length === hours.length
77
+ ? Number(mm.reduce((s, v) => s + v, 0).toFixed(2))
78
+ : undefined, // toFixed drops float noise only
79
+ windKmh: max("windKmh"),
80
+ gustKmh: max("gustKmh"),
81
+ uvIndex: max("uvIndex"),
82
+ condition: worst,
83
+ };
84
+ }
85
+
86
+ /**
87
+ * The rest of today and tomorrow in `blocks` (`BLOCKS` by default), from the hourly forecast, in the home's time zone (the
88
+ * runtime's when left out). The one owner: the integration, `home weather` and a consumer with its own clock all call
89
+ * `dayBlocks(state.hourly, now, timeZone)`. An hour counts while it has not ended; a part with no hours left is left out.
90
+ * Throws on a block outside 0–24 or ending before it starts.
91
+ */
92
+ export function dayBlocks(
93
+ hourly: WeatherHourly | undefined,
94
+ at: number,
95
+ timeZone?: string,
96
+ blocks: readonly BlockDef[] = BLOCKS,
97
+ ): DayBlocks | undefined {
98
+ checkBlocks(blocks);
99
+ if (!hourly) return undefined;
100
+ const hours = hourly.hours
101
+ .map((h) => ({ ...h, t: Date.parse(h.at) }))
102
+ .filter((h) => h.t + HOUR_MS > at)
103
+ .map((h) => ({
104
+ ...h,
105
+ date: localDay(h.t, timeZone),
106
+ hour: localHour(h.t, timeZone),
107
+ }));
108
+ const cut = (date: string) =>
109
+ blocks.flatMap((b) => {
110
+ const inside = hours.filter(
111
+ (h) => h.date === date && h.hour >= b.from && h.hour < b.to,
112
+ );
113
+ return inside.length ? [summarise(b, date, inside)] : [];
114
+ });
115
+ const today = localDay(at, timeZone);
116
+ const [y, m, d] = today.split("-").map(Number);
117
+ const tomorrow = new Date(Date.UTC(y!, m! - 1, d! + 1))
118
+ .toISOString()
119
+ .slice(0, 10); // by the calendar: a day is 23 or 25 hours twice a year
120
+ return { source: hourly.source, today: cut(today), tomorrow: cut(tomorrow) };
121
+ }
122
+
123
+ /** Throw on blocks a day cannot hold: whole hours within 0–24, each ending after it starts. */
124
+ export function checkBlocks(blocks: readonly BlockDef[]): void {
125
+ for (const b of blocks)
126
+ if (!(
127
+ Number.isInteger(b.from) &&
128
+ Number.isInteger(b.to) &&
129
+ b.from >= 0 &&
130
+ b.to <= 24 &&
131
+ b.from < b.to
132
+ ))
133
+ throw new RangeError(
134
+ `day block ${b.name}: hours ${b.from}–${b.to}, want whole hours, 0 ≤ from < to ≤ 24`,
135
+ );
136
+ }
137
+
138
+ /** 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. */
139
+ export const UV_SHOWN = 3;
140
+
141
+ /**
142
+ * "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
143
+ * under way or short; UV shows from `UV_SHOWN`.
144
+ */
145
+ export function blockText(b: DayBlock, timeZone?: string): string {
146
+ const name = b.late
147
+ ? `${b.name} from ${localTime(Date.parse(b.from), timeZone)}`
148
+ : b.name;
149
+ const temp =
150
+ b.minC === undefined || b.maxC === undefined
151
+ ? ""
152
+ : b.minC === b.maxC
153
+ ? `${b.minC} °C`
154
+ : `${b.minC}–${b.maxC} °C`;
155
+ const rain = [
156
+ field(b.rainChance, (v) => `rain ${v}%`),
157
+ b.rainMm
158
+ ? `${b.rainChance === undefined ? "rain " : ""}${b.rainMm} mm`
159
+ : "",
160
+ ]
161
+ .filter(Boolean)
162
+ .join(" ");
163
+ const wind = field(
164
+ b.windKmh,
165
+ (v) =>
166
+ `wind ${v} km/h${b.gustKmh === undefined ? "" : ` gusting ${b.gustKmh}`}`,
167
+ );
168
+ const uv =
169
+ b.uvIndex !== undefined && b.uvIndex >= UV_SHOWN ? `UV ${b.uvIndex}` : "";
170
+ return [`${name} ${temp}`.trim(), b.condition ?? "", rain, wind, uv]
171
+ .filter(Boolean)
172
+ .join(", ");
173
+ }
@@ -0,0 +1,344 @@
1
+ import { XMLParser } from "fast-xml-parser";
2
+ import {
3
+ type ForecastDay,
4
+ getText,
5
+ iso,
6
+ nextPoll,
7
+ num,
8
+ type Place,
9
+ type WeatherForecast,
10
+ type WeatherNow,
11
+ type WeatherSource,
12
+ } from "./source.ts";
13
+
14
+ // The Bureau of Meteorology's anonymous FTP products (https://www.bom.gov.au/catalogue/anon-ftp.shtml): a state's
15
+ // observations (IDx60920, every station, 10-minutely) and its précis forecast (IDx11060). Free, not for commercial use,
16
+ // BOM's default copyright terms. FTP is the channel BOM names for automated access; `fetch` must speak ftp:// (ftp.ts).
17
+
18
+ export const FTP_BASE = "ftp://ftp.bom.gov.au/anon/gen/fwo";
19
+
20
+ /** Stations whose latest observation is this much older than the file's newest are skipped (daily manual stations). */
21
+ const STALE_STATION_MS = 60 * 60_000;
22
+ /** After the forecast's own next routine issue time, wait this long for the file to land. */
23
+ const ISSUE_GRACE_MS = 5 * 60_000;
24
+ const HOUR = 3_600_000;
25
+
26
+ const parser = new XMLParser({
27
+ ignoreAttributes: false,
28
+ attributeNamePrefix: "@",
29
+ parseTagValue: false, // numbers are read here, so "-" or "80%" is seen as written
30
+ parseAttributeValue: false,
31
+ isArray: (name) =>
32
+ [
33
+ "station",
34
+ "period",
35
+ "level",
36
+ "element",
37
+ "area",
38
+ "forecast-period",
39
+ "text",
40
+ ].includes(name),
41
+ });
42
+
43
+ type El = { "#text"?: string; [attr: `@${string}`]: string | undefined };
44
+ type Period = { "@time-utc"?: string; level?: { element?: El[] }[] };
45
+ type Station = {
46
+ "@wmo-id"?: string;
47
+ "@description"?: string;
48
+ "@lat"?: string;
49
+ "@lon"?: string;
50
+ period?: Period[];
51
+ };
52
+ type FPeriod = {
53
+ "@start-time-local"?: string;
54
+ "@start-time-utc"?: string;
55
+ "@end-time-utc"?: string;
56
+ element?: El[];
57
+ text?: El[];
58
+ };
59
+ type Area = {
60
+ "@aac"?: string;
61
+ "@description"?: string;
62
+ "@type"?: string;
63
+ "forecast-period"?: FPeriod[];
64
+ };
65
+ type Product = {
66
+ product?: {
67
+ amoc?: {
68
+ "issue-time-utc"?: string;
69
+ "next-routine-issue-time-utc"?: string;
70
+ };
71
+ observations?: { station?: Station[] };
72
+ forecast?: { area?: Area[] };
73
+ };
74
+ };
75
+
76
+ const byType = (els: El[] | undefined, type: string) =>
77
+ els?.find((e) => e["@type"] === type);
78
+
79
+ /** Great-circle distance in km. */
80
+ export function distanceKm(a: Place, b: Place): number {
81
+ const rad = Math.PI / 180;
82
+ const dLat = (b.latitude - a.latitude) * rad;
83
+ const dLon = (b.longitude - a.longitude) * rad;
84
+ const h =
85
+ Math.sin(dLat / 2) ** 2 +
86
+ Math.cos(a.latitude * rad) *
87
+ Math.cos(b.latitude * rad) *
88
+ Math.sin(dLon / 2) ** 2;
89
+ return 2 * 6371 * Math.asin(Math.sqrt(h));
90
+ }
91
+
92
+ /**
93
+ * The `now` slice from an observations product: the station given by WMO id, else the nearest one that reports air
94
+ * temperature. Rain since 9am keeps its own window, which ends a few minutes after the observation time.
95
+ */
96
+ export function toNow(
97
+ xml: string,
98
+ place: Place,
99
+ fetchedAt: number,
100
+ wmoId?: string,
101
+ ): { now?: WeatherNow; anomaly?: string; problem?: string } {
102
+ const stations =
103
+ (parser.parse(xml) as Product).product?.observations?.station ?? [];
104
+ const odd: string[] = [];
105
+ const surface = (s: Station) => s.period?.[0]?.level?.[0]?.element;
106
+ const time = (s: Station) => Date.parse(s.period?.[0]?.["@time-utc"] ?? "");
107
+ const newest = Math.max(
108
+ ...stations.map(time).filter((t) => !Number.isNaN(t)),
109
+ );
110
+ const located = stations.flatMap((s) => {
111
+ const latitude = Number(s["@lat"]);
112
+ const longitude = Number(s["@lon"]);
113
+ return Number.isFinite(latitude) && Number.isFinite(longitude)
114
+ ? [{ s, km: distanceKm(place, { latitude, longitude }) }]
115
+ : [];
116
+ });
117
+ const pick = wmoId
118
+ ? located.find(({ s }) => s["@wmo-id"] === wmoId) // a pinned station is shown however old; its `at` says so
119
+ : located
120
+ .filter(
121
+ ({ s }) =>
122
+ byType(surface(s), "air_temperature") &&
123
+ newest - time(s) <= STALE_STATION_MS,
124
+ )
125
+ .sort((a, b) => a.km - b.km)[0];
126
+ if (!pick)
127
+ return {
128
+ problem: wmoId
129
+ ? `no station ${wmoId} in this product`
130
+ : "no station with an air temperature in this product",
131
+ };
132
+ const { s, km } = pick;
133
+ const els = surface(s);
134
+ const at = iso(s.period?.[0]?.["@time-utc"]);
135
+ if (!at)
136
+ return { problem: `station ${s["@wmo-id"]} has no observation time` };
137
+ const n = (type: string) => num(byType(els, type)?.["#text"], type, odd);
138
+ const rain = byType(els, "rainfall");
139
+ const now: WeatherNow = {
140
+ source: "bom",
141
+ modelled: false,
142
+ at,
143
+ fetchedAt: new Date(fetchedAt).toISOString(),
144
+ tempC: n("air_temperature"),
145
+ apparentC: n("apparent_temp"),
146
+ humidity: n("rel-humidity"),
147
+ windKmh: n("wind_spd_kmh"),
148
+ windDir: byType(els, "wind_dir")?.["#text"],
149
+ windDeg: n("wind_dir_deg"),
150
+ gustKmh: n("gust_kmh"),
151
+ rainMm: n("rainfall"),
152
+ rainFrom: iso(rain?.["@start-time-utc"]),
153
+ rainTo: iso(rain?.["@end-time-utc"]),
154
+ station: {
155
+ name: s["@description"] ?? s["@wmo-id"] ?? "unnamed",
156
+ distanceKm: km,
157
+ },
158
+ };
159
+ return { now, anomaly: odd.length ? odd.join("; ") : undefined };
160
+ }
161
+
162
+ /** "0 to 1 mm" → [0, 1]; anything else is reported, not guessed. */
163
+ function range(
164
+ text: string | undefined,
165
+ odd: string[],
166
+ ): [number, number] | undefined {
167
+ if (text === undefined) return undefined;
168
+ const m = text.match(/^(\d+(?:\.\d+)?) to (\d+(?:\.\d+)?) mm$/);
169
+ if (!m) odd.push(`rain range not understood: ${text}`);
170
+ return m ? [Number(m[1]), Number(m[2])] : undefined;
171
+ }
172
+
173
+ /**
174
+ * The `forecast` slice for one précis location (its name, e.g. "Sydney", or its `aac`, e.g. "NSW_PT131"). Today is the
175
+ * period containing `fetchedAt`; after the afternoon issue it has no min, max or rain range, and they stay missing.
176
+ */
177
+ export function toForecast(
178
+ xml: string,
179
+ location: string,
180
+ fetchedAt: number,
181
+ ): {
182
+ forecast?: WeatherForecast;
183
+ nextIssue?: number;
184
+ anomaly?: string;
185
+ problem?: string;
186
+ } {
187
+ const p = (parser.parse(xml) as Product).product;
188
+ const nextIssue = Date.parse(p?.amoc?.["next-routine-issue-time-utc"] ?? "");
189
+ const want = location.toLowerCase();
190
+ const areas = (p?.forecast?.area ?? []).filter(
191
+ (a) => a["@type"] === "location",
192
+ );
193
+ const area = areas.find(
194
+ (a) =>
195
+ a["@aac"]?.toLowerCase() === want ||
196
+ a["@description"]?.toLowerCase() === want,
197
+ );
198
+ const result = { nextIssue: Number.isNaN(nextIssue) ? undefined : nextIssue };
199
+ if (!area)
200
+ return {
201
+ ...result,
202
+ problem: `no forecast location "${location}"; have ${areas
203
+ .slice(0, 8)
204
+ .map((a) => a["@description"])
205
+ .join(", ")}…`,
206
+ };
207
+ const odd: string[] = [];
208
+ const periods = area["forecast-period"] ?? [];
209
+ const i = periods.findIndex(
210
+ (f) =>
211
+ Date.parse(f["@start-time-utc"] ?? "") <= fetchedAt &&
212
+ fetchedAt < Date.parse(f["@end-time-utc"] ?? ""),
213
+ );
214
+ const day = (f: FPeriod | undefined): ForecastDay | undefined => {
215
+ if (!f) return undefined;
216
+ const el = (type: string) => byType(f.element, type)?.["#text"];
217
+ const chance = byType(f.text, "probability_of_precipitation")?.["#text"];
218
+ const percent = chance?.match(/^(\d+)%$/)?.[1];
219
+ if (chance !== undefined && percent === undefined)
220
+ odd.push(`rain chance not understood: ${chance}`);
221
+ return {
222
+ date: (f["@start-time-local"] ?? "").slice(0, 10),
223
+ minC: num(el("air_temperature_minimum"), "minimum", odd),
224
+ maxC: num(el("air_temperature_maximum"), "maximum", odd),
225
+ rainChance: percent === undefined ? undefined : Number(percent),
226
+ rainRangeMm: range(el("precipitation_range"), odd),
227
+ text: byType(f.text, "precis")?.["#text"],
228
+ code: el("forecast_icon_code"),
229
+ };
230
+ };
231
+ const issuedAt = iso(p?.amoc?.["issue-time-utc"]);
232
+ const forecast: WeatherForecast = {
233
+ source: "bom",
234
+ modelled: false,
235
+ at: issuedAt ?? new Date(fetchedAt).toISOString(),
236
+ fetchedAt: new Date(fetchedAt).toISOString(),
237
+ issuedAt,
238
+ today: i < 0 ? undefined : day(periods[i]),
239
+ tomorrow: i < 0 ? undefined : day(periods[i + 1]),
240
+ };
241
+ if (i < 0)
242
+ odd.push(
243
+ `no forecast period contains ${new Date(fetchedAt).toISOString()}`,
244
+ );
245
+ return {
246
+ ...result,
247
+ forecast,
248
+ anomaly: odd.length ? odd.join("; ") : undefined,
249
+ };
250
+ }
251
+
252
+ export type BomOptions = {
253
+ /** A `fetch` that speaks ftp://, e.g. `ftpFetch()` from `@khalido/home/weather/ftp`. Without one the source stops at once. */
254
+ fetch?: typeof fetch;
255
+ /** The précis location by name or aac. Without one there is no BOM forecast and that slice falls to the next source. */
256
+ forecastLocation?: string;
257
+ /** A station's WMO id, to pin it instead of the nearest. */
258
+ station?: string;
259
+ /** Another state's product ids (README has the table checked on the server). Default NSW: IDN60920 and IDN11060. */
260
+ observations?: string;
261
+ forecasts?: string;
262
+ base?: string;
263
+ };
264
+
265
+ /** BOM as a weather source: observed `now` every polling interval, the forecaster's `forecast` at its issue times. */
266
+ export function bom(options: BomOptions = {}): WeatherSource {
267
+ const fetcher = options.fetch ?? fetch;
268
+ const base = options.base ?? FTP_BASE;
269
+ const obsUrl = `${base}/${options.observations ?? "IDN60920"}.xml`;
270
+ const fcUrl = `${base}/${options.forecasts ?? "IDN11060"}.xml`;
271
+ let now: WeatherNow | undefined;
272
+ let forecast: WeatherForecast | undefined;
273
+ let obsDue = 0;
274
+ let fcDue = 0;
275
+ const notes = {
276
+ obs: {} as { problem?: string; anomaly?: string },
277
+ fc: {} as { problem?: string; anomaly?: string },
278
+ };
279
+ return {
280
+ name: "bom",
281
+ owns: { now: 30 * 60_000, forecast: 24 * HOUR },
282
+ async read({ place, at, pollMinutes, force, signal }) {
283
+ // Plain fetch never reads ftp:// (Bun and Node fail differently), so this is terminal, like a rejected key.
284
+ if (!options.fetch && base.startsWith("ftp:"))
285
+ return {
286
+ stop: "bom: plain fetch cannot read ftp://; pass fetch: ftpFetch() from @khalido/home/weather/ftp",
287
+ nextAt: Infinity,
288
+ };
289
+ if (force || at >= obsDue) {
290
+ try {
291
+ const r = toNow(
292
+ await getText(fetcher, obsUrl, signal),
293
+ place,
294
+ at,
295
+ options.station,
296
+ );
297
+ now = r.now ?? now;
298
+ notes.obs = {
299
+ problem: r.problem && `bom observations: ${r.problem}`,
300
+ anomaly: r.anomaly,
301
+ };
302
+ } catch (error) {
303
+ notes.obs = {
304
+ problem: `bom observations: ${(error as Error).message}`,
305
+ };
306
+ }
307
+ obsDue = nextPoll(at, pollMinutes);
308
+ }
309
+ if (!options.forecastLocation)
310
+ notes.fc = { problem: "bom forecast: no forecastLocation set" };
311
+ else if (force || at >= fcDue) {
312
+ try {
313
+ const r = toForecast(
314
+ await getText(fetcher, fcUrl, signal),
315
+ options.forecastLocation,
316
+ at,
317
+ );
318
+ forecast = r.forecast ?? forecast;
319
+ notes.fc = {
320
+ problem: r.problem && `bom forecast: ${r.problem}`,
321
+ anomaly: r.anomaly,
322
+ };
323
+ // At the file's own next routine issue; hourly only when it names none. An amendment between issues waits (README).
324
+ fcDue =
325
+ r.nextIssue && r.nextIssue > at
326
+ ? r.nextIssue + ISSUE_GRACE_MS
327
+ : at + HOUR;
328
+ } catch (error) {
329
+ notes.fc = { problem: `bom forecast: ${(error as Error).message}` };
330
+ fcDue = nextPoll(at, pollMinutes);
331
+ }
332
+ }
333
+ const join = (a?: string, b?: string) =>
334
+ [a, b].filter(Boolean).join("; ") || undefined;
335
+ return {
336
+ now,
337
+ forecast,
338
+ problem: join(notes.obs.problem, notes.fc.problem),
339
+ anomaly: join(notes.obs.anomaly, notes.fc.anomaly),
340
+ nextAt: options.forecastLocation ? Math.min(obsDue, fcDue) : obsDue,
341
+ };
342
+ },
343
+ };
344
+ }
@@ -0,0 +1,48 @@
1
+ import { Client, FTPError } from "basic-ftp";
2
+ import { Writable } from "node:stream";
3
+
4
+ // The one file in the library that uses Node APIs (net and stream, through basic-ftp), so it sits outside the
5
+ // runtime-neutral build (tsconfig.build.json) and has its own (tsconfig.node.json). Runs in Bun and Node.
6
+
7
+ /**
8
+ * A `fetch` that also reads anonymous `ftp://` URLs (BOM's products), passing every other URL to the global `fetch`.
9
+ * One connection per request; a missing file answers 404. Honours `init.signal`, so a caller's timeout applies.
10
+ */
11
+ export function ftpFetch(timeoutMs = 10_000): typeof fetch {
12
+ const f = async (
13
+ input: RequestInfo | URL,
14
+ init?: RequestInit,
15
+ ): Promise<Response> => {
16
+ const url = new URL(input instanceof Request ? input.url : String(input));
17
+ if (url.protocol !== "ftp:") return fetch(input, init);
18
+ const client = new Client(timeoutMs);
19
+ const abort = () => client.close();
20
+ init?.signal?.throwIfAborted();
21
+ init?.signal?.addEventListener("abort", abort, { once: true });
22
+ const chunks: Buffer[] = [];
23
+ try {
24
+ await client.access({
25
+ host: url.hostname,
26
+ port: url.port ? Number(url.port) : 21,
27
+ user: url.username || "anonymous",
28
+ password: url.password || "anonymous@",
29
+ });
30
+ const sink = new Writable({
31
+ write: (chunk: Buffer, _encoding, done) => (chunks.push(chunk), done()),
32
+ });
33
+ await client.downloadTo(sink, decodeURIComponent(url.pathname));
34
+ return new Response(new Uint8Array(Buffer.concat(chunks)), {
35
+ status: 200,
36
+ });
37
+ } catch (error) {
38
+ init?.signal?.throwIfAborted();
39
+ if (error instanceof FTPError && error.code === 550)
40
+ return new Response(error.message, { status: 404 });
41
+ throw error;
42
+ } finally {
43
+ init?.signal?.removeEventListener("abort", abort);
44
+ client.close();
45
+ }
46
+ };
47
+ return f as typeof fetch;
48
+ }