1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
|
import type { SourceModule } from "../registry";
import { getSettings } from "@/lib/store";
import {
AqiConfig,
AqiPayload,
type AqiConfig as Config,
type AqiPayload as Payload,
} from "@/lib/schemas/sources/aqi";
// Same location resolution as weather: config → settings → env.
function resolveLocation(config: Config): { lat: number; lon: number; label: string } | null {
if (config.lat != null && config.lon != null) {
return { lat: config.lat, lon: config.lon, label: config.label ?? "Custom" };
}
const loc = getSettings().location;
if (loc) return { lat: loc.lat, lon: loc.lon, label: loc.label || "Home" };
const envLat = process.env.WEATHER_LAT;
const envLon = process.env.WEATHER_LON;
if (envLat && envLon) return { lat: Number(envLat), lon: Number(envLon), label: "Home" };
return null;
}
function aqiCategory(aqi: number | null): string {
if (aqi == null) return "—";
if (aqi <= 50) return "Good";
if (aqi <= 100) return "Moderate";
if (aqi <= 150) return "Unhealthy (sensitive)";
if (aqi <= 200) return "Unhealthy";
if (aqi <= 300) return "Very unhealthy";
return "Hazardous";
}
// Pollen is reported in grains/m³ (Europe-only in Open-Meteo). Rough banding.
function pollenLevel(v: number): string {
if (v < 10) return "Low";
if (v < 30) return "Moderate";
if (v < 70) return "High";
return "Very High";
}
const POLLENS: Array<[string, string]> = [
["grass_pollen", "Grass"],
["birch_pollen", "Birch"],
["alder_pollen", "Alder"],
["ragweed_pollen", "Ragweed"],
["mugwort_pollen", "Mugwort"],
["olive_pollen", "Olive"],
];
export const aqiModule: SourceModule<Config, Payload> = {
kind: "aqi",
label: "Air Quality",
keyless: true,
defaultRefreshSeconds: 3600,
configSchema: AqiConfig,
payloadSchema: AqiPayload,
isConfigured: (config) => resolveLocation(config) !== null,
async fetch({ config, signal }) {
const place = resolveLocation(config);
if (!place) throw new Error("No location set — add one in Settings.");
const url = new URL("https://air-quality-api.open-meteo.com/v1/air-quality");
url.searchParams.set("latitude", String(place.lat));
url.searchParams.set("longitude", String(place.lon));
url.searchParams.set("timezone", "auto");
url.searchParams.set("current", "us_aqi,pm2_5,pm10,ozone");
url.searchParams.set("hourly", POLLENS.map(([k]) => k).join(","));
const res = await fetch(url, { signal });
if (!res.ok) throw new Error(`Air quality ${res.status}`);
const j = (await res.json()) as {
current?: {
time: string;
us_aqi?: number | null;
pm2_5?: number | null;
pm10?: number | null;
ozone?: number | null;
};
hourly?: { time: string[] } & Record<string, (number | null)[]>;
};
const c = j.current ?? ({} as NonNullable<typeof j.current>);
const aqi = c.us_aqi ?? null;
// Pollen: pick the hourly slot nearest the current hour; keep non-zero types.
const pollen: Payload["pollen"] = [];
if (j.hourly?.time?.length) {
const nowHour = (c.time ?? j.hourly.time[0]).slice(0, 13);
let idx = j.hourly.time.findIndex((t) => t.slice(0, 13) === nowHour);
if (idx < 0) idx = 0;
for (const [key, label] of POLLENS) {
const v = j.hourly[key]?.[idx];
if (typeof v === "number" && v > 0) {
pollen.push({ label, value: Math.round(v), level: pollenLevel(v) });
}
}
}
return {
location: place.label,
aqi,
category: aqiCategory(aqi),
pm25: c.pm2_5 ?? null,
pm10: c.pm10 ?? null,
ozone: c.ozone ?? null,
pollen,
};
},
};
|