import { fetchJson, fromUnix, maybe, maybeMsToKph, MAX_DAYS, msToKph, ProviderError, round1 } from "../../http"; import type { CurrentWeather, DailyForecast, HourlyForecast, ProviderForecast } from "../../types"; import { OWM_LABEL, popToPercent, precipitation, toCondition, type OwmWeatherTag } from "./shared"; /** * OpenWeather ONE CALL 4.0 (needs a separate "One Call" subscription; * 1,000 calls/day free, card on file). Docs: https://openweathermap.org/api/one-call-4 * * /data/4.0/onecall/current → now * /data/4.0/onecall/timeline/1h → hourly, 48 h of forecast, 20 records per page * /data/4.0/onecall/timeline/1day → daily, 10 records per page * * Timelines are paginated with a `next` URL, and each page costs one call. * A full load here is about 5 calls; the 10-minute cache keeps that sane. */ const BASE_URL = "https://api.openweathermap.org/data/4.0/onecall"; const HOURS_AVAILABLE = 48; interface OcRecord { dt: number; temp: number | { min: number; max: number; day: number }; feels_like: number | { day: number }; humidity: number; pressure: number; dew_point: number; uvi: number; clouds: number; visibility?: number; // metres wind_speed: number; wind_gust?: number; wind_deg: number; pop?: number; rain?: number | { "1h": number }; snow?: number | { "1h": number }; weather: OwmWeatherTag[]; sunrise?: number; sunset?: number; summary?: string; } interface OcPage { data: OcRecord[]; next?: string; timezone?: string; } /** Rain/snow is a number on daily records and `{ "1h": n }` on current/hourly ones. */ const volume = (v: OcRecord["rain"]) => (typeof v === "number" ? v : v?.["1h"]); /** Follows `next` links until we have `wanted` records or the timeline ends. */ async function fetchTimeline(firstUrl: string, key: string, wanted: number): Promise { const records: OcRecord[] = []; let url: string | undefined = firstUrl; while (url && records.length < wanted) { const page: OcPage = await fetchJson(url, OWM_LABEL); records.push(...page.data); // Make sure follow-up pages carry the key even if `next` omits it. url = page.next ? (page.next.includes("appid=") ? page.next : `${page.next}&appid=${key}`) : undefined; } return records.slice(0, wanted); } function toHourly(r: OcRecord): HourlyForecast { return { time: fromUnix(r.dt), tempC: round1(r.temp as number), feelsLikeC: round1(r.feels_like as number), condition: toCondition(r.weather[0].id), isDay: r.weather[0].icon.endsWith("d"), precipChance: popToPercent(r.pop), precipMm: precipitation(volume(r.rain), volume(r.snow)), windKph: msToKph(r.wind_speed), gustKph: maybeMsToKph(r.wind_gust), windDeg: r.wind_deg, humidity: r.humidity, uvIndex: maybe(r.uvi), }; } function toDaily(r: OcRecord): DailyForecast { const temp = r.temp as { min: number; max: number }; return { time: fromUnix(r.dt), // OpenWeather stamps daily records at local midday highC: round1(temp.max), lowC: round1(temp.min), condition: toCondition(r.weather[0].id), summary: r.summary ?? r.weather[0].description, precipChance: popToPercent(r.pop), precipMm: precipitation(volume(r.rain), volume(r.snow)), windKph: msToKph(r.wind_speed), gustKph: maybeMsToKph(r.wind_gust), windDeg: r.wind_deg, uvIndexMax: maybe(r.uvi), humidity: r.humidity, sunrise: r.sunrise ? fromUnix(r.sunrise) : null, sunset: r.sunset ? fromUnix(r.sunset) : null, }; } function toCurrent(r: OcRecord): CurrentWeather { const hour = toHourly(r); return { time: hour.time, tempC: hour.tempC, feelsLikeC: hour.feelsLikeC ?? hour.tempC, condition: hour.condition, description: r.weather[0].description, isDay: hour.isDay, humidity: r.humidity, windKph: hour.windKph, gustKph: hour.gustKph, windDeg: hour.windDeg, pressureHpa: r.pressure, dewPointC: round1(r.dew_point), visibilityKm: r.visibility === undefined ? null : round1(r.visibility / 1000), uvIndex: maybe(r.uvi), cloudCover: r.clouds, precipMm: hour.precipMm, }; } export async function getOneCallForecast(lat: number, lon: number, key: string): Promise { const query = `lat=${lat}&lon=${lon}&units=metric&appid=${key}`; const nowSeconds = Math.floor(Date.now() / 1000); const startOfHour = nowSeconds - (nowSeconds % 3600); const [currentPage, hourly, daily] = await Promise.all([ fetchJson(`${BASE_URL}/current?${query}`, OWM_LABEL), fetchTimeline(`${BASE_URL}/timeline/1h?${query}&start=${startOfHour}`, key, HOURS_AVAILABLE), fetchTimeline(`${BASE_URL}/timeline/1day?${query}&start=${nowSeconds}`, key, MAX_DAYS), ]); const currentRecord = currentPage.data[0]; if (!currentRecord) throw new ProviderError("OpenWeather returned no current reading", 502); return { timeZone: currentPage.timezone ?? null, hourlyStepHours: 1, alerts: [], // 4.0 only returns alert IDs; the API route adds NWS alerts for US locations current: toCurrent(currentRecord), hourly: hourly.map(toHourly), daily: daily.map(toDaily), }; }