166 lines
6.1 KiB
JavaScript
166 lines
6.1 KiB
JavaScript
const { fetch: httpFetch } = require("undici");
|
|
const { DateTime } = require("luxon");
|
|
const { hasValue } = require("../constants");
|
|
const { buildTimeWindow } = require("../time");
|
|
|
|
function isConfigured(config) {
|
|
return config && Number.isFinite(config.latitude) && Number.isFinite(config.longitude);
|
|
}
|
|
|
|
function normalizeTarget(target, zone) {
|
|
if (!target) return null;
|
|
if (DateTime.isDateTime(target)) return target.setZone(zone || undefined);
|
|
if (target instanceof Date) return DateTime.fromJSDate(target, { zone });
|
|
if (typeof target === "string") return DateTime.fromISO(target, { zone });
|
|
return null;
|
|
}
|
|
|
|
function selectNearestHour(hourly, target, timezone, windowMinutes) {
|
|
if (!hourly || !Array.isArray(hourly.time) || hourly.time.length === 0) return null;
|
|
|
|
const window = buildTimeWindow(target, windowMinutes);
|
|
let nearest = null;
|
|
let smallestDiff = Number.POSITIVE_INFINITY;
|
|
|
|
for (let i = 0; i < hourly.time.length; i++) {
|
|
const timeValue = DateTime.fromISO(hourly.time[i], { zone: timezone });
|
|
if (!timeValue.isValid) continue;
|
|
|
|
if (timeValue < window.start || timeValue > window.end) continue;
|
|
|
|
const diff = Math.abs(timeValue.diff(target).as("milliseconds"));
|
|
|
|
if (diff < smallestDiff) {
|
|
smallestDiff = diff;
|
|
nearest = {
|
|
time: timeValue,
|
|
temperature_2m: hourly.temperature_2m?.[i],
|
|
relative_humidity_2m: hourly.relative_humidity_2m?.[i],
|
|
surface_pressure: hourly.surface_pressure?.[i],
|
|
shortwave_radiation: hourly.shortwave_radiation?.[i],
|
|
precipitation: hourly.precipitation?.[i],
|
|
wind_speed_10m: hourly.wind_speed_10m?.[i],
|
|
wind_direction_10m: hourly.wind_direction_10m?.[i],
|
|
};
|
|
}
|
|
}
|
|
|
|
return nearest;
|
|
}
|
|
|
|
function createOpenMeteoProvider(config = {}, globalConfig = {}) {
|
|
if (!isConfigured(config)) return null;
|
|
|
|
const windowMinutes = config.windowMinutes ?? globalConfig.windowMinutes ?? 60;
|
|
const precipitationThreshold = config.precipitationThreshold ?? globalConfig.precipitationThreshold ?? 0.1;
|
|
const pressureOffset = Number.isFinite(config.pressureOffset) ? config.pressureOffset : 0;
|
|
const illuminanceToLuxFactor = Number.isFinite(config.illuminanceToLuxFactor)
|
|
? config.illuminanceToLuxFactor
|
|
: Number.isFinite(globalConfig.providers?.openMeteo?.illuminanceToLuxFactor)
|
|
? globalConfig.providers.openMeteo.illuminanceToLuxFactor
|
|
: 126.7;
|
|
const timezone = config.timezone || globalConfig.timezone || "UTC";
|
|
|
|
async function fetchData({ target }) {
|
|
const normalized = normalizeTarget(target, timezone);
|
|
|
|
if (!normalized || !normalized.isValid) return null;
|
|
|
|
const isFuture = normalized > DateTime.now().setZone(timezone).plus({ days: 1 });
|
|
const baseUrl = isFuture ? "https://api.open-meteo.com/v1/forecast" : "https://archive-api.open-meteo.com/v1/archive";
|
|
const params = new URLSearchParams({
|
|
latitude: config.latitude.toString(),
|
|
longitude: config.longitude.toString(),
|
|
timezone,
|
|
start_date: normalized.toISODate(),
|
|
end_date: normalized.toISODate(),
|
|
hourly: [
|
|
"temperature_2m",
|
|
"relative_humidity_2m",
|
|
"surface_pressure",
|
|
"shortwave_radiation",
|
|
"precipitation",
|
|
"wind_speed_10m",
|
|
"wind_direction_10m",
|
|
].join(","),
|
|
});
|
|
|
|
if (isFuture) {
|
|
params.set("forecast_days", "16");
|
|
params.set("past_days", "7");
|
|
}
|
|
|
|
const url = `${baseUrl}?${params.toString()}`;
|
|
let json;
|
|
|
|
try {
|
|
const response = await httpFetch(url);
|
|
|
|
if (!response || typeof response.ok === "undefined") {
|
|
const sanitizedUrl = url
|
|
.replace(/(latitude=)[^&]+/, "$1***")
|
|
.replace(/(longitude=)[^&]+/, "$1***");
|
|
console.error("Open-Meteo unexpected response", {
|
|
type: typeof response,
|
|
ctor: response?.constructor?.name,
|
|
url: sanitizedUrl,
|
|
});
|
|
throw new Error("Invalid response object");
|
|
}
|
|
|
|
if (!response.ok) {
|
|
let details = "";
|
|
try {
|
|
details = await response.text();
|
|
} catch (_) {
|
|
// ignore
|
|
}
|
|
const suffix = details ? `: ${details}` : "";
|
|
throw new Error(`HTTP ${response.status}${suffix}`);
|
|
}
|
|
|
|
json = await response.json();
|
|
} catch (error) {
|
|
console.error(`Open-Meteo error: ${error.message}`);
|
|
return null;
|
|
}
|
|
|
|
if (!json?.hourly) return null;
|
|
|
|
const nearest = selectNearestHour(json.hourly, target, timezone, windowMinutes);
|
|
if (!nearest) return null;
|
|
|
|
const weather = {};
|
|
|
|
if (hasValue(nearest.temperature_2m)) weather.temperature = nearest.temperature_2m;
|
|
if (hasValue(nearest.relative_humidity_2m)) weather.humidity = nearest.relative_humidity_2m;
|
|
if (hasValue(nearest.surface_pressure)) weather.pressure = nearest.surface_pressure + pressureOffset;
|
|
if (hasValue(nearest.shortwave_radiation)) {
|
|
const factor = Number.isFinite(illuminanceToLuxFactor) ? illuminanceToLuxFactor : 126.7;
|
|
weather.illuminance = nearest.shortwave_radiation * factor;
|
|
}
|
|
|
|
if (hasValue(nearest.precipitation)) {
|
|
weather.precipitations = nearest.precipitation > precipitationThreshold;
|
|
}
|
|
|
|
if (hasValue(nearest.wind_speed_10m)) weather.wind_speed = nearest.wind_speed_10m;
|
|
if (hasValue(nearest.wind_direction_10m)) weather.wind_direction = nearest.wind_direction_10m;
|
|
|
|
if (Object.keys(weather).length === 0) return null;
|
|
|
|
weather.source = ["open-meteo"];
|
|
|
|
return weather;
|
|
}
|
|
|
|
return {
|
|
name: "open-meteo",
|
|
fetch: fetchData,
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
createOpenMeteoProvider,
|
|
};
|