147 lines
5.1 KiB
TypeScript
147 lines
5.1 KiB
TypeScript
|
|
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<OcRecord[]> {
|
||
|
|
const records: OcRecord[] = [];
|
||
|
|
let url: string | undefined = firstUrl;
|
||
|
|
|
||
|
|
while (url && records.length < wanted) {
|
||
|
|
const page: OcPage = await fetchJson<OcPage>(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<ProviderForecast> {
|
||
|
|
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<OcPage>(`${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),
|
||
|
|
};
|
||
|
|
}
|