wayfarer-weather/lib/weather/providers/openmeteo.ts
2026-10-31 21:50:28 -05:00

133 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { CONDITION_LABELS } from "../describe";
import { fetchJson, fromUnix, maybe, MAX_DAYS, MAX_HOURS, nextHours, round1 } from "../http";
import type { Condition, WeatherProvider } from "../types";
/**
* Open-Meteo — free, no key, and the deepest data: hourly and daily out to 16 days.
* Docs: https://open-meteo.com/en/docs
*
* We ask for `timeformat=unixtime` so every time comes back as a plain number.
*/
const HOURLY_VARS = [
"temperature_2m", "apparent_temperature", "precipitation_probability", "precipitation",
"weather_code", "wind_speed_10m", "wind_gusts_10m", "wind_direction_10m",
"relative_humidity_2m", "uv_index", "is_day",
] as const;
const DAILY_VARS = [
"weather_code", "temperature_2m_max", "temperature_2m_min", "precipitation_probability_max",
"precipitation_sum", "wind_speed_10m_max", "wind_gusts_10m_max", "wind_direction_10m_dominant",
"uv_index_max", "sunrise", "sunset",
] as const;
const CURRENT_VARS = [
"temperature_2m", "apparent_temperature", "relative_humidity_2m", "is_day", "precipitation",
"weather_code", "cloud_cover", "pressure_msl", "wind_speed_10m", "wind_direction_10m",
"wind_gusts_10m", "dew_point_2m", "visibility", "uv_index",
] as const;
// Each section is "variable name → value" (current) or "variable name → array of values".
type Row<Names extends readonly string[]> = Record<Names[number], number>;
type Columns<Names extends readonly string[]> = Record<Names[number], (number | null)[]> & { time: number[] };
interface OpenMeteoResponse {
timezone: string;
current: Row<typeof CURRENT_VARS> & { time: number };
hourly: Columns<typeof HOURLY_VARS>;
daily: Columns<typeof DAILY_VARS>;
}
/** WMO weather codes, listed at the bottom of the Open-Meteo docs page. */
function toCondition(code: number | null): Condition {
if (code === null || code <= 1) return "clear";
if (code <= 3) return "clouds";
if (code === 45 || code === 48) return "fog";
if ((code >= 71 && code <= 77) || code === 85 || code === 86) return "snow";
if (code >= 95) return "storm";
return "rain"; // drizzle (51–57), rain (61–67), showers (80–82)
}
const TWELVE_HOURS = 12 * 60 * 60;
export const openMeteo: WeatherProvider = {
id: "openmeteo",
label: "Open-Meteo",
envKey: null,
limits: () => ({ forecastDays: 14, hourlyStepHours: 1 }),
async getForecast(lat, lon) {
const params = new URLSearchParams({
latitude: String(lat),
longitude: String(lon),
current: CURRENT_VARS.join(","),
hourly: HOURLY_VARS.join(","),
daily: DAILY_VARS.join(","),
timezone: "auto",
timeformat: "unixtime",
forecast_days: String(MAX_DAYS),
});
const { current: c, hourly: h, daily: d, timezone } = await fetchJson<OpenMeteoResponse>(
`https://api.open-meteo.com/v1/forecast?${params}`,
"Open-Meteo",
);
const condition = toCondition(c.weather_code);
const hourly = h.time.map((t, i) => ({
time: fromUnix(t),
tempC: round1(h.temperature_2m[i] ?? 0),
feelsLikeC: maybe(h.apparent_temperature[i]),
condition: toCondition(h.weather_code[i]),
isDay: h.is_day[i] === 1,
precipChance: maybe(h.precipitation_probability[i]),
precipMm: maybe(h.precipitation[i]),
windKph: round1(h.wind_speed_10m[i] ?? 0),
gustKph: maybe(h.wind_gusts_10m[i]),
windDeg: maybe(h.wind_direction_10m[i]),
humidity: maybe(h.relative_humidity_2m[i]),
uvIndex: maybe(h.uv_index[i]),
}));
return {
timeZone: timezone,
hourlyStepHours: 1,
alerts: [], // Open-Meteo has no alerts; the API route adds NWS alerts for US locations
current: {
time: fromUnix(c.time),
tempC: round1(c.temperature_2m),
feelsLikeC: round1(c.apparent_temperature),
condition,
description: CONDITION_LABELS[condition],
isDay: c.is_day === 1,
humidity: c.relative_humidity_2m,
windKph: round1(c.wind_speed_10m),
gustKph: maybe(c.wind_gusts_10m),
windDeg: maybe(c.wind_direction_10m),
pressureHpa: maybe(c.pressure_msl),
dewPointC: maybe(c.dew_point_2m),
visibilityKm: c.visibility == null ? null : round1(c.visibility / 1000),
uvIndex: maybe(c.uv_index),
cloudCover: maybe(c.cloud_cover),
precipMm: maybe(c.precipitation),
},
hourly: nextHours(hourly, MAX_HOURS),
daily: d.time.map((t, i) => ({
time: fromUnix(t + TWELVE_HOURS), // daily times are local midnight; use midday
highC: round1(d.temperature_2m_max[i] ?? 0),
lowC: round1(d.temperature_2m_min[i] ?? 0),
condition: toCondition(d.weather_code[i]),
summary: null,
precipChance: maybe(d.precipitation_probability_max[i]),
precipMm: maybe(d.precipitation_sum[i]),
windKph: maybe(d.wind_speed_10m_max[i]),
gustKph: maybe(d.wind_gusts_10m_max[i]),
windDeg: maybe(d.wind_direction_10m_dominant[i]),
uvIndexMax: maybe(d.uv_index_max[i]),
humidity: null,
sunrise: d.sunrise[i] ? fromUnix(d.sunrise[i]!) : null,
sunset: d.sunset[i] ? fromUnix(d.sunset[i]!) : null,
})),
};
},
};