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 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)) weather.illuminance = nearest.shortwave_radiation; 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, };