Files
homarr/packages/request-handler/src/air-quality.ts
T

283 lines
9.2 KiB
TypeScript

import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import { z } from "zod";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createWidgetRequestHandler } from "./lib/widget-request-handler";
dayjs.extend(utc);
dayjs.extend(timezone);
const FORECAST_DAYS = 7;
const requestedVariables = [
"european_aqi",
"us_aqi",
"uv_index",
"pm10",
"pm2_5",
"carbon_monoxide",
"nitrogen_dioxide",
"sulphur_dioxide",
"ozone",
"european_aqi_pm2_5",
"european_aqi_pm10",
"european_aqi_nitrogen_dioxide",
"european_aqi_ozone",
"european_aqi_sulphur_dioxide",
"us_aqi_pm2_5",
"us_aqi_pm10",
"us_aqi_nitrogen_dioxide",
"us_aqi_carbon_monoxide",
"us_aqi_ozone",
"us_aqi_sulphur_dioxide",
"alder_pollen",
"birch_pollen",
"grass_pollen",
"mugwort_pollen",
"olive_pollen",
"ragweed_pollen",
] as const;
export const airQualityRequestHandler = createWidgetRequestHandler({
// Coordinates are a public, continuous key space. Keep this cache process-local and bounded.
cacheTtlMs: 15 * 60 * 1000,
fallbackToStaleOnError: true,
staleIfErrorTtlMs: 24 * 60 * 60 * 1000,
requestTimeoutMs: 10_000,
async requestAsync(input: { latitude: number; longitude: number }, signal: AbortSignal) {
const url = new URL("https://air-quality-api.open-meteo.com/v1/air-quality");
url.searchParams.set("latitude", input.latitude.toString());
url.searchParams.set("longitude", input.longitude.toString());
url.searchParams.set("current", requestedVariables.join(","));
url.searchParams.set("hourly", requestedVariables.join(","));
url.searchParams.set("forecast_days", FORECAST_DAYS.toString());
url.searchParams.set("timezone", "auto");
const response = await fetchWithTrustedCertificatesAsync(url.toString(), { signal });
if (!response.ok) throw new ResponseError(response);
const json: unknown = await response.json();
const airQuality = await atLocationOutput.parseAsync(json);
const hourly = airQuality.hourly.time.map((value, index) => {
return normalizePoint(airQuality.hourly, index, toUtcInstant(value, airQuality.timezone));
});
return {
timezone: airQuality.timezone,
utcOffsetSeconds: airQuality.utc_offset_seconds,
current: normalizePoint(
airQuality.current,
undefined,
toUtcInstant(airQuality.current.time, airQuality.timezone),
),
hourly,
daily: getDailyMaximums(airQuality.hourly),
} satisfies AirQuality;
},
});
const nullableNumber = z.number().nullable();
const nullableNumberArray = z.array(nullableNumber);
const currentValues = z.object({
time: z.string(),
european_aqi: nullableNumber,
us_aqi: nullableNumber,
uv_index: nullableNumber,
pm10: nullableNumber,
pm2_5: nullableNumber,
carbon_monoxide: nullableNumber,
nitrogen_dioxide: nullableNumber,
sulphur_dioxide: nullableNumber,
ozone: nullableNumber,
european_aqi_pm2_5: nullableNumber,
european_aqi_pm10: nullableNumber,
european_aqi_nitrogen_dioxide: nullableNumber,
european_aqi_ozone: nullableNumber,
european_aqi_sulphur_dioxide: nullableNumber,
us_aqi_pm2_5: nullableNumber,
us_aqi_pm10: nullableNumber,
us_aqi_nitrogen_dioxide: nullableNumber,
us_aqi_carbon_monoxide: nullableNumber,
us_aqi_ozone: nullableNumber,
us_aqi_sulphur_dioxide: nullableNumber,
alder_pollen: nullableNumber,
birch_pollen: nullableNumber,
grass_pollen: nullableNumber,
mugwort_pollen: nullableNumber,
olive_pollen: nullableNumber,
ragweed_pollen: nullableNumber,
});
const hourlyValues = z.object({
time: z.array(z.string()),
european_aqi: nullableNumberArray,
us_aqi: nullableNumberArray,
uv_index: nullableNumberArray,
pm10: nullableNumberArray,
pm2_5: nullableNumberArray,
carbon_monoxide: nullableNumberArray,
nitrogen_dioxide: nullableNumberArray,
sulphur_dioxide: nullableNumberArray,
ozone: nullableNumberArray,
european_aqi_pm2_5: nullableNumberArray,
european_aqi_pm10: nullableNumberArray,
european_aqi_nitrogen_dioxide: nullableNumberArray,
european_aqi_ozone: nullableNumberArray,
european_aqi_sulphur_dioxide: nullableNumberArray,
us_aqi_pm2_5: nullableNumberArray,
us_aqi_pm10: nullableNumberArray,
us_aqi_nitrogen_dioxide: nullableNumberArray,
us_aqi_carbon_monoxide: nullableNumberArray,
us_aqi_ozone: nullableNumberArray,
us_aqi_sulphur_dioxide: nullableNumberArray,
alder_pollen: nullableNumberArray,
birch_pollen: nullableNumberArray,
grass_pollen: nullableNumberArray,
mugwort_pollen: nullableNumberArray,
olive_pollen: nullableNumberArray,
ragweed_pollen: nullableNumberArray,
});
const atLocationOutput = z.object({
timezone: z.string().min(1),
utc_offset_seconds: z.number(),
current: currentValues,
hourly: hourlyValues,
});
type CurrentValues = z.infer<typeof currentValues>;
type HourlyValues = z.infer<typeof hourlyValues>;
const getValue = (values: CurrentValues | HourlyValues, field: keyof Omit<CurrentValues, "time">, index?: number) => {
const value = values[field];
if (Array.isArray(value)) return value[index ?? 0] ?? null;
return value;
};
const normalizePoint = (
values: CurrentValues | HourlyValues,
index: number | undefined,
observedAt: string,
): AirQualityPoint => ({
observedAt,
europeanAqi: getValue(values, "european_aqi", index),
usAqi: getValue(values, "us_aqi", index),
uvIndex: getValue(values, "uv_index", index),
pollutants: {
pm2_5: getValue(values, "pm2_5", index),
pm10: getValue(values, "pm10", index),
ozone: getValue(values, "ozone", index),
nitrogenDioxide: getValue(values, "nitrogen_dioxide", index),
sulphurDioxide: getValue(values, "sulphur_dioxide", index),
carbonMonoxide: getValue(values, "carbon_monoxide", index),
},
europeanSubIndices: {
pm2_5: getValue(values, "european_aqi_pm2_5", index),
pm10: getValue(values, "european_aqi_pm10", index),
ozone: getValue(values, "european_aqi_ozone", index),
nitrogenDioxide: getValue(values, "european_aqi_nitrogen_dioxide", index),
sulphurDioxide: getValue(values, "european_aqi_sulphur_dioxide", index),
carbonMonoxide: null,
},
usSubIndices: {
pm2_5: getValue(values, "us_aqi_pm2_5", index),
pm10: getValue(values, "us_aqi_pm10", index),
ozone: getValue(values, "us_aqi_ozone", index),
nitrogenDioxide: getValue(values, "us_aqi_nitrogen_dioxide", index),
sulphurDioxide: getValue(values, "us_aqi_sulphur_dioxide", index),
carbonMonoxide: getValue(values, "us_aqi_carbon_monoxide", index),
},
pollen: {
alder: getValue(values, "alder_pollen", index),
birch: getValue(values, "birch_pollen", index),
grass: getValue(values, "grass_pollen", index),
mugwort: getValue(values, "mugwort_pollen", index),
olive: getValue(values, "olive_pollen", index),
ragweed: getValue(values, "ragweed_pollen", index),
},
});
const getDailyMaximums = (hourly: HourlyValues): AirQuality["daily"] => {
const grouped = new Map<string, AirQuality["daily"][number]>();
hourly.time.forEach((time, index) => {
const date = time.slice(0, 10);
const existing = grouped.get(date) ?? {
date,
europeanAqiMax: null,
usAqiMax: null,
uvIndexMax: null,
pm2_5Max: null,
pm10Max: null,
};
existing.europeanAqiMax = maxNullable(existing.europeanAqiMax, hourly.european_aqi[index]);
existing.usAqiMax = maxNullable(existing.usAqiMax, hourly.us_aqi[index]);
existing.uvIndexMax = maxNullable(existing.uvIndexMax, hourly.uv_index[index]);
existing.pm2_5Max = maxNullable(existing.pm2_5Max, hourly.pm2_5[index]);
existing.pm10Max = maxNullable(existing.pm10Max, hourly.pm10[index]);
grouped.set(date, existing);
});
return Array.from(grouped.values()).slice(0, FORECAST_DAYS);
};
const maxNullable = (current: number | null, candidate: number | null | undefined): number | null => {
if (candidate === null || candidate === undefined) return current;
if (current === null) return candidate;
return Math.max(current, candidate);
};
const toUtcInstant = (value: string, timeZone: string): string => {
const parsed = dayjs.tz(value, timeZone);
if (!parsed.isValid()) throw new Error(`Invalid air quality timestamp: ${value}`);
return parsed.utc().toISOString();
};
export interface AirQualityPollutants {
pm2_5: number | null;
pm10: number | null;
ozone: number | null;
nitrogenDioxide: number | null;
sulphurDioxide: number | null;
carbonMonoxide: number | null;
}
export interface AirQualityPoint {
observedAt: string;
europeanAqi: number | null;
usAqi: number | null;
uvIndex: number | null;
pollutants: AirQualityPollutants;
europeanSubIndices: AirQualityPollutants;
usSubIndices: AirQualityPollutants;
pollen: {
alder: number | null;
birch: number | null;
grass: number | null;
mugwort: number | null;
olive: number | null;
ragweed: number | null;
};
}
export interface AirQuality {
timezone: string;
utcOffsetSeconds: number;
current: AirQualityPoint;
hourly: AirQualityPoint[];
daily: {
date: string;
europeanAqiMax: number | null;
usAqiMax: number | null;
uvIndexMax: number | null;
pm2_5Max: number | null;
pm10Max: number | null;
}[];
}