init
This commit is contained in:
commit
6250d23297
53 changed files with 6381 additions and 0 deletions
134
lib/weather/providers/openweather/free.ts
Normal file
134
lib/weather/providers/openweather/free.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { fetchJson, fromUnix, maybe, maybeMsToKph, msToKph, round1 } from "../../http";
|
||||
import type { DailyForecast, HourlyForecast, ProviderForecast } from "../../types";
|
||||
import { OWM_LABEL, popToPercent, precipitation, toCondition, type OwmWeatherTag } from "./shared";
|
||||
|
||||
/**
|
||||
* OpenWeather FREE plan:
|
||||
* /data/2.5/weather → current conditions
|
||||
* /data/2.5/forecast → 5 days in 3-hour steps
|
||||
* There is no daily endpoint, so we build days by grouping the 3-hour steps.
|
||||
*/
|
||||
|
||||
const BASE_URL = "https://api.openweathermap.org/data/2.5";
|
||||
|
||||
interface OwmCurrentResponse {
|
||||
dt: number;
|
||||
timezone: number; // seconds from UTC
|
||||
weather: OwmWeatherTag[];
|
||||
main: { temp: number; feels_like: number; humidity: number; pressure: number };
|
||||
visibility?: number; // metres
|
||||
wind: { speed: number; deg?: number; gust?: number }; // m/s
|
||||
clouds?: { all: number };
|
||||
rain?: { "1h"?: number };
|
||||
snow?: { "1h"?: number };
|
||||
sys: { sunrise: number; sunset: number };
|
||||
}
|
||||
|
||||
interface OwmForecastEntry {
|
||||
dt: number;
|
||||
main: { temp: number; feels_like: number; temp_min: number; temp_max: number; humidity: number };
|
||||
weather: OwmWeatherTag[];
|
||||
wind: { speed: number; deg?: number; gust?: number };
|
||||
pop?: number;
|
||||
rain?: { "3h"?: number };
|
||||
snow?: { "3h"?: number };
|
||||
sys: { pod: "d" | "n" };
|
||||
}
|
||||
|
||||
interface OwmForecastResponse {
|
||||
list: OwmForecastEntry[];
|
||||
city: { timezone: number };
|
||||
}
|
||||
|
||||
function toHourly(e: OwmForecastEntry): HourlyForecast {
|
||||
return {
|
||||
time: fromUnix(e.dt),
|
||||
tempC: round1(e.main.temp),
|
||||
feelsLikeC: round1(e.main.feels_like),
|
||||
condition: toCondition(e.weather[0].id),
|
||||
isDay: e.sys.pod === "d",
|
||||
precipChance: popToPercent(e.pop),
|
||||
precipMm: precipitation(e.rain?.["3h"], e.snow?.["3h"]),
|
||||
windKph: msToKph(e.wind.speed),
|
||||
gustKph: maybeMsToKph(e.wind.gust),
|
||||
windDeg: maybe(e.wind.deg),
|
||||
humidity: e.main.humidity,
|
||||
uvIndex: null, // not on the free plan
|
||||
};
|
||||
}
|
||||
|
||||
/** Groups the 3-hour steps by local calendar day and summarises each day. */
|
||||
function buildDaily(response: OwmForecastResponse, today: OwmCurrentResponse): DailyForecast[] {
|
||||
const offset = response.city.timezone;
|
||||
const localDay = (e: OwmForecastEntry) => new Date((e.dt + offset) * 1000).toISOString().slice(0, 10);
|
||||
const localHour = (e: OwmForecastEntry) => new Date((e.dt + offset) * 1000).getUTCHours();
|
||||
|
||||
const entriesByDay = new Map<string, OwmForecastEntry[]>();
|
||||
for (const entry of response.list) {
|
||||
const day = localDay(entry);
|
||||
entriesByDay.set(day, [...(entriesByDay.get(day) ?? []), entry]);
|
||||
}
|
||||
|
||||
return [...entriesByDay.values()].map((entries, index) => {
|
||||
// The step nearest midday best represents "what the day looks like".
|
||||
const midday = entries.reduce((best, e) => (Math.abs(localHour(e) - 12) < Math.abs(localHour(best) - 12) ? e : best));
|
||||
const pops = entries.map((e) => popToPercent(e.pop) ?? 0);
|
||||
const gusts = entries.map((e) => e.wind.gust ?? 0);
|
||||
|
||||
return {
|
||||
time: fromUnix(midday.dt),
|
||||
highC: round1(Math.max(...entries.map((e) => e.main.temp_max))),
|
||||
lowC: round1(Math.min(...entries.map((e) => e.main.temp_min))),
|
||||
condition: toCondition(midday.weather[0].id),
|
||||
summary: midday.weather[0].description,
|
||||
precipChance: Math.max(...pops),
|
||||
precipMm: round1(entries.reduce((sum, e) => sum + (precipitation(e.rain?.["3h"], e.snow?.["3h"]) ?? 0), 0)),
|
||||
windKph: msToKph(Math.max(...entries.map((e) => e.wind.speed))),
|
||||
gustKph: Math.max(...gusts) > 0 ? msToKph(Math.max(...gusts)) : null,
|
||||
windDeg: maybe(midday.wind.deg),
|
||||
uvIndexMax: null,
|
||||
humidity: Math.round(entries.reduce((sum, e) => sum + e.main.humidity, 0) / entries.length),
|
||||
// Only today's sun times are available on this plan.
|
||||
sunrise: index === 0 ? fromUnix(today.sys.sunrise) : null,
|
||||
sunset: index === 0 ? fromUnix(today.sys.sunset) : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function getFreeForecast(lat: number, lon: number, key: string): Promise<ProviderForecast> {
|
||||
const query = `lat=${lat}&lon=${lon}&units=metric&appid=${key}`;
|
||||
|
||||
// Both requests are independent, so run them at the same time.
|
||||
const [now, forecast] = await Promise.all([
|
||||
fetchJson<OwmCurrentResponse>(`${BASE_URL}/weather?${query}`, OWM_LABEL),
|
||||
fetchJson<OwmForecastResponse>(`${BASE_URL}/forecast?${query}`, OWM_LABEL),
|
||||
]);
|
||||
|
||||
const tag = now.weather[0];
|
||||
|
||||
return {
|
||||
timeZone: null, // only a UTC offset is given; the API route fills in the name
|
||||
hourlyStepHours: 3,
|
||||
alerts: [],
|
||||
current: {
|
||||
time: fromUnix(now.dt),
|
||||
tempC: round1(now.main.temp),
|
||||
feelsLikeC: round1(now.main.feels_like),
|
||||
condition: toCondition(tag.id),
|
||||
description: tag.description,
|
||||
isDay: tag.icon.endsWith("d"),
|
||||
humidity: now.main.humidity,
|
||||
windKph: msToKph(now.wind.speed),
|
||||
gustKph: maybeMsToKph(now.wind.gust),
|
||||
windDeg: maybe(now.wind.deg),
|
||||
pressureHpa: now.main.pressure,
|
||||
dewPointC: null,
|
||||
visibilityKm: now.visibility === undefined ? null : round1(now.visibility / 1000),
|
||||
uvIndex: null,
|
||||
cloudCover: maybe(now.clouds?.all),
|
||||
precipMm: precipitation(now.rain?.["1h"], now.snow?.["1h"]),
|
||||
},
|
||||
hourly: forecast.list.map(toHourly),
|
||||
daily: buildDaily(forecast, now),
|
||||
};
|
||||
}
|
||||
26
lib/weather/providers/openweather/index.ts
Normal file
26
lib/weather/providers/openweather/index.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { readKey } from "../../http";
|
||||
import type { WeatherProvider } from "../../types";
|
||||
import { getFreeForecast } from "./free";
|
||||
import { getOneCallForecast } from "./onecall";
|
||||
|
||||
/**
|
||||
* OpenWeather (the default provider).
|
||||
*
|
||||
* Set OPENWEATHER_PLAN in .env.local:
|
||||
* free (default) → 5 days in 3-hour steps
|
||||
* onecall → One Call 4.0: 48 hours hourly + daily, plus dew point and UV
|
||||
*/
|
||||
const usesOneCall = () => process.env.OPENWEATHER_PLAN === "onecall";
|
||||
|
||||
export const openWeather: WeatherProvider = {
|
||||
id: "openweather",
|
||||
label: "OpenWeather",
|
||||
envKey: "OPENWEATHER_API_KEY",
|
||||
signupUrl: "https://home.openweathermap.org/users/sign_up",
|
||||
limits: () => (usesOneCall() ? { forecastDays: 8, hourlyStepHours: 1 } : { forecastDays: 5, hourlyStepHours: 3 }),
|
||||
|
||||
getForecast(lat, lon) {
|
||||
const key = readKey("OPENWEATHER_API_KEY");
|
||||
return usesOneCall() ? getOneCallForecast(lat, lon, key) : getFreeForecast(lat, lon, key);
|
||||
},
|
||||
};
|
||||
146
lib/weather/providers/openweather/onecall.ts
Normal file
146
lib/weather/providers/openweather/onecall.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
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),
|
||||
};
|
||||
}
|
||||
32
lib/weather/providers/openweather/shared.ts
Normal file
32
lib/weather/providers/openweather/shared.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import type { Condition } from "../../types";
|
||||
|
||||
/** Shared by both OpenWeather plans. */
|
||||
|
||||
export const OWM_LABEL = "OpenWeather";
|
||||
|
||||
export interface OwmWeatherTag {
|
||||
id: number;
|
||||
description: string;
|
||||
icon: string; // ends in "d" (day) or "n" (night)
|
||||
}
|
||||
|
||||
/** Condition codes: https://openweathermap.org/weather-conditions */
|
||||
export function toCondition(code: number): Condition {
|
||||
if (code >= 200 && code < 300) return "storm";
|
||||
if (code >= 300 && code < 600) return "rain"; // drizzle + rain
|
||||
if (code >= 600 && code < 700) return "snow";
|
||||
if (code >= 700 && code < 800) return "fog"; // mist, haze, smoke, dust…
|
||||
if (code === 800) return "clear";
|
||||
return "clouds";
|
||||
}
|
||||
|
||||
/** OpenWeather gives probability of precipitation as 0–1; we use 0–100. */
|
||||
export function popToPercent(pop: number | undefined): number | null {
|
||||
return pop === undefined ? null : Math.round(pop * 100);
|
||||
}
|
||||
|
||||
/** Rain + snow volume, either of which may be missing. */
|
||||
export function precipitation(rain?: number, snow?: number): number | null {
|
||||
if (rain === undefined && snow === undefined) return null;
|
||||
return Math.round(((rain ?? 0) + (snow ?? 0)) * 10) / 10;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue