chore: import upstream snapshot with attribution
Enforce Pull-Request Rules / check (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:19:24 +08:00
commit dbe3ade0dc
449 changed files with 71828 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
# Weather Module
This module will be configurable to be used as a current weather view, or to show the forecast. This way the module can be used twice to fulfill both purposes.
For configuration options, please check the [MagicMirror² documentation](https://docs.magicmirror.builders/modules/weather.html).
+97
View File
@@ -0,0 +1,97 @@
{% macro humidity() %}
{% if current.humidity %}
<span class="humidity"
><span>{{ current.humidity | decimalSymbol }}</span><sup>&nbsp;<i class="wi wi-humidity humidity-icon"></i></sup
></span>
{% endif %}
{% endmacro %}
{% if current %}
{% if not config.onlyTemp %}
<div class="normal medium">
<span class="wi wi-strong-wind dimmed"></span>
<span>
{{ current.windSpeed | unit("wind") | round }}
{% if config.showWindDirection %}
<sup>
{% if config.showWindDirectionAsArrow %}
<i class="fas fa-long-arrow-alt-down" style="transform:rotate({{ current.windFromDirection }}deg)"></i>
{% else %}
{{ current.cardinalWindDirection() | translate }}
{% endif %}
&nbsp;
</sup>
{% endif %}
</span>
{% if config.showHumidity === "wind" %}
{{ humidity() }}
{% endif %}
{% if config.showSun and current.nextSunAction() %}
<span class="wi dimmed wi-{{ current.nextSunAction() }}"></span>
<span>
{% if current.nextSunAction() === "sunset" %}
{{ current.sunset | formatTime }}
{% else %}
{{ current.sunrise | formatTime }}
{% endif %}
</span>
{% endif %}
{% if config.showUVIndex %}
<td class="align-right bright uv-index">
<div class="wi dimmed wi-hot"></div>
{{ current.uvIndex }}
</td>
{% endif %}
</div>
{% endif %}
<div class="flex large type-temp">
{% if config.showIndoorTemperature and indoor.temperature or config.showIndoorHumidity and indoor.humidity %}
<span class="medium fas fa-home"></span>
<span style="display: inline-block">
{% if config.showIndoorTemperature and indoor.temperature %}
<sup class="small" style="position: relative; display: block; text-align: left;">
<span> {{ indoor.temperature | roundValue | unit("temperature") | decimalSymbol }} </span>
</sup>
{% endif %}
{% if config.showIndoorHumidity and indoor.humidity %}
<sub class="small" style="position: relative; display: block; text-align: left;">
<span> {{ indoor.humidity | roundValue | unit("humidity") | decimalSymbol }} </span>
</sub>
{% endif %}
</span>
{% endif %}
{% if current.weatherType %}
<span class="light wi weathericon wi-{{ current.weatherType }}"></span>
{% endif %}
<span class="light bright">{{ current.temperature | roundValue | unit("temperature") | decimalSymbol }}</span>
{% if config.showHumidity === "temp" %}
<span class="medium bright">{{ humidity() }}</span>
{% endif %}
</div>
{% if (config.showFeelsLike or config.showPrecipitationAmount or config.showPrecipitationProbability) and not config.onlyTemp %}
<div class="normal medium feelslike">
{% if config.showFeelsLike %}
<span class="dimmed">
{% if config.showHumidity === "feelslike" %}
{{ humidity() }}
{% endif %}
{{ "FEELS" | translate({DEGREE: current.feelsLike() | roundValue | unit("temperature") | decimalSymbol }) }}
</span>
<br />
{% endif %}
{% if config.showPrecipitationAmount and current.precipitationAmount is defined and current.precipitationAmount is not none %}
<span class="dimmed"> <span class="precipitationLeadText">{{ "PRECIP_AMOUNT" | translate }}</span> {{ current.precipitationAmount | unit("precip", current.precipitationUnits) }} </span>
<br />
{% endif %}
{% if config.showPrecipitationProbability and current.precipitationProbability is defined and current.precipitationProbability is not none %}
<span class="dimmed"> <span class="precipitationLeadText">{{ "PRECIP_POP" | translate }}</span> {{ current.precipitationProbability }}% </span>
{% endif %}
</div>
{% endif %}
{% if config.showHumidity === "below" %}
<span class="medium dimmed">{{ humidity() }}</span>
{% endif %}
{% else %}
<div class="dimmed light small">{{ "LOADING" | translate }}</div>
{% endif %}
<!-- Uncomment the line below to see the contents of the `current` object. -->
<!-- <div style="word-wrap:break-word" class="xsmall dimmed">{{ current | dump }}</div> -->
+46
View File
@@ -0,0 +1,46 @@
{% if forecast %}
{% set numSteps = forecast | calcNumSteps %}
{% set currentStep = 0 %}
<table class="{{ config.tableClass }}">
{% if config.ignoreToday %}
{% set forecast = forecast.splice(1) %}
{% endif %}
{% set forecast = forecast.slice(0, numSteps) %}
{% for f in forecast %}
<tr
{% if config.colored %}class="colored"{% endif %}
{% if config.fade %}style="opacity: {{ currentStep | opacity(numSteps) }};"{% endif %}
>
{% if (currentStep == 0) and config.ignoreToday == false and config.absoluteDates == false %}
<td class="day">{{ "TODAY" | translate }}</td>
{% elif (currentStep == 1) and config.ignoreToday == false and config.absoluteDates == false %}
<td class="day">{{ "TOMORROW" | translate }}</td>
{% else %}
<td class="day">{{ f.date.format(config.forecastDateFormat) }}</td>
{% endif %}
<td class="bright weather-icon">
<span class="wi weathericon wi-{{ f.weatherType }}"></span>
</td>
<td class="align-right bright max-temp">{{ f.maxTemperature | roundValue | unit("temperature") | decimalSymbol }}</td>
<td class="align-right min-temp">{{ f.minTemperature | roundValue | unit("temperature") | decimalSymbol }}</td>
{% if config.showPrecipitationAmount %}
<td class="align-right bright precipitation-amount">{{ f.precipitationAmount | unit("precip", f.precipitationUnits) }}</td>
{% endif %}
{% if config.showPrecipitationProbability %}
<td class="align-right bright precipitation-prob">{{ f.precipitationProbability | unit('precip', '%') }}</td>
{% endif %}
{% if config.showUVIndex %}
<td class="align-right dimmed uv-index">
{{ f.uvIndex }}
<span class="wi dimmed weathericon wi-hot"></span>
</td>
{% endif %}
</tr>
{% set currentStep = currentStep + 1 %}
{% endfor %}
</table>
{% else %}
<div class="dimmed light small">{{ "LOADING" | translate }}</div>
{% endif %}
<!-- Uncomment the line below to see the contents of the `forecast` object. -->
<!-- <div style="word-wrap:break-word" class="xsmall dimmed">{{ forecast | dump }}</div> -->
+48
View File
@@ -0,0 +1,48 @@
{% if hourly %}
{% set numSteps = hourly | calcNumEntries %}
{% set currentStep = 0 %}
<table class="{{ config.tableClass }}">
{% set hours = hourly.slice(0, numSteps) %}
{% for hour in hours %}
<tr
{% if config.colored %}class="colored"{% endif %}
{% if config.fade %}style="opacity: {{ currentStep | opacity(numSteps) }};"{% endif %}
>
<td class="day">{{ hour.date | formatTime }}</td>
<td class="bright weather-icon">
<span class="wi weathericon wi-{{ hour.weatherType }}"></span>
</td>
<td class="align-right bright">{{ hour.temperature | roundValue | unit("temperature") }}</td>
{% if config.showUVIndex %}
<td class="align-right bright uv-index">
{% if hour.uvIndex!=0 %}
{{ hour.uvIndex }}
<span class="wi weathericon wi-hot"></span>
{% endif %}
</td>
{% endif %}
{% if config.showHumidity != "none" %}
<td class="align-left bright humidity-hourly">
{{ hour.humidity }}
<span class="wi wi-humidity humidity-icon"></span>
</td>
{% endif %}
{% if config.showPrecipitationAmount %}
{% if (not config.hideZeroes or hour.precipitationAmount>0) %}
<td class="align-right bright precipitation-amount">{{ hour.precipitationAmount | unit("precip", hour.precipitationUnits) }}</td>
{% endif %}
{% endif %}
{% if config.showPrecipitationProbability %}
{% if (not config.hideZeroes or hour.precipitationAmount>0) %}
<td class="align-right bright precipitation-prob">{{ hour.precipitationProbability | unit('precip', '%') }}</td>
{% endif %}
{% endif %}
</tr>
{% set currentStep = currentStep + 1 %}
{% endfor %}
</table>
{% else %}
<div class="dimmed light small">{{ "LOADING" | translate }}</div>
{% endif %}
<!-- Uncomment the line below to see the contents of the `hourly` object. -->
<!-- <div style="word-wrap:break-word" class="xsmall dimmed">{{ hourly | dump }}</div> -->
+112
View File
@@ -0,0 +1,112 @@
const path = require("node:path");
const NodeHelper = require("node_helper");
const Log = require("logger");
module.exports = NodeHelper.create({
providers: {},
lastData: {},
start () {
Log.log(`Starting node helper for: ${this.name}`);
},
socketNotificationReceived (notification, payload) {
if (notification === "INIT_WEATHER") {
Log.log(`Received INIT_WEATHER for instance ${payload.instanceId}`);
this.initWeatherProvider(payload);
} else if (notification === "STOP_WEATHER") {
Log.log(`Received STOP_WEATHER for instance ${payload.instanceId}`);
this.stopWeatherProvider(payload.instanceId);
}
// FETCH_WEATHER is no longer needed - HTTPFetcher handles periodic fetching
},
/**
* Initialize a weather provider
* @param {object} config The configuration object
*/
async initWeatherProvider (config) {
const identifier = config.weatherProvider.toLowerCase();
const instanceId = config.instanceId;
Log.log(`Attempting to initialize provider ${identifier} for instance ${instanceId}`);
if (this.providers[instanceId]) {
Log.log(`Weather provider ${identifier} already initialized for instance ${instanceId}, re-sending WEATHER_INITIALIZED`);
// Client may have restarted (e.g. page reload) - re-send so it recovers location name
this.sendSocketNotification("WEATHER_INITIALIZED", {
instanceId,
locationName: this.providers[instanceId].locationName
});
// Push cached data immediately so reconnecting clients don't wait for next scheduled fetch
if (this.lastData[instanceId]) {
this.sendSocketNotification("WEATHER_DATA", this.lastData[instanceId]);
}
return;
}
try {
// Dynamically load the provider module
const providerPath = path.join(__dirname, "providers", `${identifier}.js`);
Log.log(`Loading provider from: ${providerPath}`);
const ProviderClass = require(providerPath);
// Create provider instance
const provider = new ProviderClass(config);
// Set up callbacks before initializing
provider.setCallbacks(
(data) => {
// On data received
const payload = { instanceId, type: config.type, data };
this.lastData[instanceId] = payload;
this.sendSocketNotification("WEATHER_DATA", payload);
},
(errorInfo) => {
// On error
this.sendSocketNotification("WEATHER_ERROR", {
instanceId,
error: errorInfo.message || "Unknown error",
translationKey: errorInfo.translationKey
});
}
);
await provider.initialize();
this.providers[instanceId] = provider;
this.sendSocketNotification("WEATHER_INITIALIZED", {
instanceId,
locationName: provider.locationName
});
// Start periodic fetching
provider.start();
Log.log(`Weather provider ${identifier} initialized for instance ${instanceId}`);
} catch (error) {
Log.error(`Failed to initialize weather provider ${identifier}:`, error);
this.sendSocketNotification("WEATHER_ERROR", {
instanceId,
error: error.message
});
}
},
/**
* Stop and cleanup a weather provider
* @param {string} instanceId The instance identifier
*/
stopWeatherProvider (instanceId) {
const provider = this.providers[instanceId];
if (provider) {
Log.log(`Stopping weather provider for instance ${instanceId}`);
provider.stop();
delete this.providers[instanceId];
delete this.lastData[instanceId];
} else {
Log.warn(`No provider found for instance ${instanceId}`);
}
}
});
+181
View File
@@ -0,0 +1,181 @@
/**
* Shared utility functions for weather providers
*/
const SunCalc = require("suncalc");
/**
* Convert OpenWeatherMap icon codes to internal weather types
* @param {string} weatherType - OpenWeatherMap icon code (e.g., "01d", "02n")
* @returns {string|null} Internal weather type
*/
function convertWeatherType (weatherType) {
const weatherTypes = {
"01d": "day-sunny",
"02d": "day-cloudy",
"03d": "cloudy",
"04d": "cloudy-windy",
"09d": "showers",
"10d": "rain",
"11d": "thunderstorm",
"13d": "snow",
"50d": "fog",
"01n": "night-clear",
"02n": "night-cloudy",
"03n": "night-cloudy",
"04n": "night-cloudy",
"09n": "night-showers",
"10n": "night-rain",
"11n": "night-thunderstorm",
"13n": "night-snow",
"50n": "night-alt-cloudy-windy"
};
return weatherTypes.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null;
}
/**
* Apply timezone offset to a date
* @param {Date} date - The date to apply offset to
* @param {number} offsetMinutes - Timezone offset in minutes
* @returns {Date} Date with applied offset
*/
function applyTimezoneOffset (date, offsetMinutes) {
const utcTime = date.getTime() + (date.getTimezoneOffset() * 60000);
return new Date(utcTime + (offsetMinutes * 60000));
}
/**
* Limit decimal places for coordinates (truncate, not round)
* @param {number} value - The coordinate value
* @param {number} decimals - Maximum number of decimal places
* @returns {number} Value with limited decimal places
*/
function limitDecimals (value, decimals) {
const str = value.toString();
if (str.includes(".")) {
const parts = str.split(".");
if (parts[1].length > decimals) {
return parseFloat(`${parts[0]}.${parts[1].substring(0, decimals)}`);
}
}
return value;
}
/**
* Get sunrise and sunset times for a given date and location
* @param {Date} date - The date to calculate for
* @param {number} lat - Latitude
* @param {number} lon - Longitude
* @returns {object} Object with sunrise and sunset Date objects
*/
function getSunTimes (date, lat, lon) {
const sunTimes = SunCalc.getTimes(date, lat, lon);
return {
sunrise: sunTimes.sunrise,
sunset: sunTimes.sunset
};
}
/**
* Check if a given time is during daylight hours
* @param {Date} date - The date/time to check
* @param {Date} sunrise - Sunrise time
* @param {Date} sunset - Sunset time
* @returns {boolean} True if during daylight hours
*/
function isDayTime (date, sunrise, sunset) {
if (!sunrise || !sunset) {
return true; // Default to day if times unavailable
}
return date >= sunrise && date < sunset;
}
/**
* Format timezone offset as string (e.g., "+01:00", "-05:30")
* @param {number} offsetMinutes - Timezone offset in minutes (use -new Date().getTimezoneOffset() for local)
* @returns {string} Formatted offset string
*/
function formatTimezoneOffset (offsetMinutes) {
const hours = Math.floor(Math.abs(offsetMinutes) / 60);
const minutes = Math.abs(offsetMinutes) % 60;
const sign = offsetMinutes >= 0 ? "+" : "-";
return `${sign}${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`;
}
/**
* Get date string in YYYY-MM-DD format (local time)
* @param {Date} date - The date to format
* @returns {string} Date string in YYYY-MM-DD format
*/
function getDateString (date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
/**
* Convert wind speed from km/h to m/s
* @param {number} kmh - Wind speed in km/h
* @returns {number} Wind speed in m/s
*/
function convertKmhToMs (kmh) {
return kmh / 3.6;
}
/**
* Convert cardinal wind direction string to degrees
* @param {string} direction - Cardinal direction (e.g., "N", "NNE", "SW")
* @returns {number|null} Direction in degrees (0-360) or null if unknown
*/
function cardinalToDegrees (direction) {
const directions = {
N: 0,
NNE: 22.5,
NE: 45,
ENE: 67.5,
E: 90,
ESE: 112.5,
SE: 135,
SSE: 157.5,
S: 180,
SSW: 202.5,
SW: 225,
WSW: 247.5,
W: 270,
WNW: 292.5,
NW: 315,
NNW: 337.5
};
return directions[direction] ?? null;
}
/**
* Validate and limit coordinate precision
* @param {object} config - Configuration object with lat/lon properties
* @param {number} maxDecimals - Maximum decimal places to preserve
* @throws {Error} If coordinates are missing or invalid
*/
function validateCoordinates (config, maxDecimals = 4) {
if (config.lat == null || config.lon == null
|| !Number.isFinite(config.lat) || !Number.isFinite(config.lon)) {
throw new Error("Latitude and longitude are required");
}
config.lat = limitDecimals(config.lat, maxDecimals);
config.lon = limitDecimals(config.lon, maxDecimals);
}
module.exports = {
convertWeatherType,
applyTimezoneOffset,
limitDecimals,
getSunTimes,
isDayTime,
formatTimezoneOffset,
getDateString,
convertKmhToMs,
cardinalToDegrees,
validateCoordinates
};
@@ -0,0 +1,3 @@
# Weather Module Weather Provider Development Documentation
For how to develop your own weather provider, please check the [MagicMirror² documentation](https://docs.magicmirror.builders/module-development/weather-provider.html).
@@ -0,0 +1,349 @@
const Log = require("logger");
const HTTPFetcher = require("#http_fetcher");
const BUIENRADAR_API_BASE = "https://forecast.buienradar.nl/2.0/forecast";
const ERROR_TRANSLATION_KEY = "MODULE_ERROR_UNSPECIFIED";
const TIMESTAMP_HAS_TIME_ZONE = /[zZ]|[+-]\d{2}:?\d{2}$/;
// Mapping from Buienradar icon code to weather-icons class names (https://erikflowers.github.io/weather-icons/).
// Icon filenames use the Buienradar code: https://cdn.buienradar.nl/resources/images/icons/weather/30x30/a.png
const WEATHER_ICON_MAP = {
a: "day-sunny",
aa: "night-clear",
b: "day-cloudy",
bb: "night-alt-cloudy",
c: "cloudy",
cc: "cloudy",
d: "day-fog",
dd: "night-fog",
f: "day-sprinkle",
ff: "night-alt-sprinkle",
g: "day-storm-showers",
gg: "night-alt-storm-showers",
h: "day-rain",
hh: "night-alt-rain",
i: "day-rain-mix",
ii: "night-alt-rain-mix",
j: "day-cloudy",
jj: "night-alt-cloudy",
k: "day-showers",
kk: "night-alt-showers",
l: "showers",
ll: "showers",
m: "sprinkle",
mm: "sprinkle",
n: "day-haze",
nn: "night-fog",
o: "day-cloudy",
oo: "night-alt-cloudy",
p: "cloudy",
pp: "cloudy",
q: "showers",
qq: "showers",
r: "day-cloudy",
rr: "night-alt-cloudy",
s: "thunderstorm",
ss: "thunderstorm",
t: "snow",
tt: "snow",
u: "day-snow",
uu: "night-alt-snow",
v: "snow",
vv: "snow",
w: "rain-mix",
ww: "rain-mix"
};
/**
* Server-side weather provider for Buienradar
* Netherlands/Belgium only, metric system, no API key required
* see https://buienradar.nl
*/
class BuienradarProvider {
constructor (config) {
this.config = {
apiBase: BUIENRADAR_API_BASE,
locationId: null,
type: "current",
maxEntries: 5,
updateInterval: 10 * 60 * 1000,
...config
};
this.locationName = null;
this.fetcher = null;
this.onDataCallback = null;
this.onErrorCallback = null;
}
initialize () {
if (!this.config.locationId) {
Log.error("[buienradar] No locationId configured");
this.#sendErrorCallback("Buienradar locationId required. See https://www.buienradar.nl/overbuienradar/gratis-weerdata");
return;
}
this.#initializeFetcher();
}
setCallbacks (onData, onError) {
this.onDataCallback = onData;
this.onErrorCallback = onError;
}
start () {
if (this.fetcher) {
this.fetcher.startPeriodicFetch();
}
}
stop () {
if (this.fetcher) {
this.fetcher.clearTimer();
}
}
#initializeFetcher () {
this.fetcher = new HTTPFetcher(this.#getUrl(), {
reloadInterval: this.config.updateInterval,
headers: { "Cache-Control": "no-cache" },
logContext: "weatherprovider.buienradar"
});
this.fetcher.on("response", async (response) => {
if (response.status === 304) return;
try {
const data = await response.json();
this.#handleResponse(data);
} catch (error) {
Log.error("[buienradar] Failed to parse JSON:", error);
this.#sendErrorCallback("Failed to parse API response");
}
});
this.fetcher.on("error", (errorInfo) => {
if (this.onErrorCallback) {
this.onErrorCallback(errorInfo);
}
});
}
#handleResponse (data) {
try {
if (!Array.isArray(data?.days) || data.days.length === 0) {
throw new Error("Invalid API response");
}
this.#setLocationName(data.location);
let weatherData;
switch (this.config.type) {
case "current":
weatherData = this.#generateCurrentWeather(data.days[0]);
break;
case "forecast":
case "daily":
weatherData = this.#generateDailyForecast(data.days);
break;
case "hourly":
weatherData = this.#generateHourlyForecast(data.days);
break;
default:
throw new Error(`Unknown weather type: ${this.config.type}`);
}
if (this.onDataCallback && weatherData) {
this.onDataCallback(weatherData);
}
} catch (error) {
Log.error("[buienradar] Error processing weather data:", error);
this.#sendErrorCallback(error.message);
}
}
#sendErrorCallback (message) {
if (this.onErrorCallback) {
this.onErrorCallback({
message,
translationKey: ERROR_TRANSLATION_KEY
});
}
}
#setLocationName (location) {
if (location?.name) {
this.locationName = location.name;
}
}
#generateCurrentWeather (day) {
const closestHour = this.#getClosestHour(day.hours ?? []);
const weather = this.#parseHour(closestHour);
const sunrise = this.#parseDate(day.sunrise);
if (sunrise) weather.sunrise = sunrise;
const sunset = this.#parseDate(day.sunset);
if (sunset) weather.sunset = sunset;
const minTemperature = this.#parseNumber(day.mintemp);
if (minTemperature !== null) weather.minTemperature = minTemperature;
const maxTemperature = this.#parseNumber(day.maxtemp);
if (maxTemperature !== null) weather.maxTemperature = maxTemperature;
return weather;
}
#generateDailyForecast (days) {
return days
.slice(0, this.config.maxEntries)
.map((day) => this.#parseDay(day));
}
#generateHourlyForecast (days) {
const hours = [];
for (const day of days) {
for (const hour of day.hours ?? []) {
hours.push(this.#parseHour(hour));
if (hours.length >= this.config.maxEntries) {
return hours;
}
}
}
return hours;
}
#parseDay (day) {
const weather = {};
const date = this.#parseDate(day.date);
if (date) weather.date = date;
const minTemperature = this.#parseNumber(day.mintemp);
if (minTemperature !== null) weather.minTemperature = minTemperature;
const maxTemperature = this.#parseNumber(day.maxtemp);
if (maxTemperature !== null) weather.maxTemperature = maxTemperature;
const humidity = this.#parseNumber(day.humidity);
if (humidity !== null) weather.humidity = humidity;
const windSpeed = this.#parseNumber(day.windspeedms);
if (windSpeed !== null) weather.windSpeed = windSpeed;
const windFromDirection = this.#parseNumber(day.winddirectiondegrees);
if (windFromDirection !== null) weather.windFromDirection = windFromDirection;
this.#applyPrecipitation(weather, day);
const sunrise = this.#parseDate(day.sunrise);
if (sunrise) weather.sunrise = sunrise;
const sunset = this.#parseDate(day.sunset);
if (sunset) weather.sunset = sunset;
weather.weatherType = this.#convertWeatherType(day.iconcode);
return weather;
}
#parseHour (hour) {
const weather = {};
const date = this.#parseDate(hour.datetime);
if (date) weather.date = date;
const temperature = this.#parseNumber(hour.temperature);
if (temperature !== null) weather.temperature = temperature;
const feelsLikeTemp = this.#parseNumber(hour.feeltemperature);
if (feelsLikeTemp !== null) weather.feelsLikeTemp = feelsLikeTemp;
const humidity = this.#parseNumber(hour.humidity);
if (humidity !== null) weather.humidity = humidity;
const windSpeed = this.#parseNumber(hour.windspeedms);
if (windSpeed !== null) weather.windSpeed = windSpeed;
const windFromDirection = this.#parseNumber(hour.winddirectiondegrees);
if (windFromDirection !== null) weather.windFromDirection = windFromDirection;
this.#applyPrecipitation(weather, hour);
weather.weatherType = this.#convertWeatherType(hour.iconcode);
return weather;
}
#applyPrecipitation (weather, source) {
const precipitationAmount = this.#parseNumber(source.precipitationmm);
if (precipitationAmount !== null) {
weather.precipitationAmount = precipitationAmount;
weather.precipitationUnits = "mm";
}
const precipitationProbability = this.#parseNumber(source.precipitation);
if (precipitationProbability !== null) {
weather.precipitationProbability = precipitationProbability;
}
}
#getClosestHour (hours) {
if (hours.length === 0) {
return {};
}
const now = Date.now();
let closest = hours[0];
let closestDiff = Number.POSITIVE_INFINITY;
for (const hour of hours) {
const date = this.#parseDate(hour.datetime);
if (!date) continue;
const diff = Math.abs(date.getTime() - now);
if (diff < closestDiff) {
closestDiff = diff;
closest = hour;
}
}
return closest;
}
#getUrl () {
const now = new Date();
const year = now.getUTCFullYear();
const month = `${now.getUTCMonth() + 1}`.padStart(2, "0");
const day = `${now.getUTCDate()}`.padStart(2, "0");
const hours = `${now.getUTCHours()}`.padStart(2, "0");
const minutes = `${now.getUTCMinutes()}`.padStart(2, "0");
const cacheBust = `${year}${month}${day}${hours}${minutes}`;
const params = new URLSearchParams({ btc: cacheBust });
return `${this.config.apiBase}/${this.config.locationId}?${params}`;
}
#parseDate (value) {
if (!value) return null;
const text = `${value}`;
const date = new Date(TIMESTAMP_HAS_TIME_ZONE.test(text) ? text : `${text}Z`);
return Number.isNaN(date.getTime()) ? null : date;
}
#parseNumber (value) {
const number = parseFloat(value);
return Number.isFinite(number) ? number : null;
}
#convertWeatherType (icon) {
if (!icon) return null;
return WEATHER_ICON_MAP[`${icon}`.toLowerCase()] ?? null;
}
}
module.exports = BuienradarProvider;
@@ -0,0 +1,451 @@
const Log = require("logger");
const { convertKmhToMs } = require("../provider-utils");
const HTTPFetcher = require("#http_fetcher");
/**
* Server-side weather provider for Environment Canada MSC Datamart
* Canada only, no API key required (anonymous access)
*
* Documentation:
* https://dd.weather.gc.ca/citypage_weather/schema/
* https://eccc-msc.github.io/open-data/msc-datamart/readme_en/
*
* Requires siteCode and provCode config parameters
* See https://dd.weather.gc.ca/citypage_weather/docs/site_list_en.csv
*/
class EnvCanadaProvider {
constructor (config) {
this.config = {
siteCode: "s0000000",
provCode: "ON",
type: "current",
updateInterval: 10 * 60 * 1000,
...config
};
this.fetcher = null;
this.onDataCallback = null;
this.onErrorCallback = null;
this.lastCityPageURL = null;
this.cacheCurrentTemp = null;
this.currentHour = null; // Track current hour for URL updates
}
initialize () {
this.#validateConfig();
this.#initializeFetcher();
}
setCallbacks (onData, onError) {
this.onDataCallback = onData;
this.onErrorCallback = onError;
}
start () {
if (this.fetcher) {
this.fetcher.startPeriodicFetch();
}
}
stop () {
if (this.fetcher) {
this.fetcher.clearTimer();
}
}
#validateConfig () {
if (!this.config.siteCode || !this.config.provCode) {
throw new Error("siteCode and provCode are required");
}
}
#initializeFetcher () {
this.currentHour = new Date().toISOString().substring(11, 13);
const indexURL = this.#getIndexUrl();
this.fetcher = new HTTPFetcher(indexURL, {
reloadInterval: this.config.updateInterval,
logContext: "weatherprovider.envcanada"
});
this.fetcher.on("response", async (response) => {
if (response.status === 304) return;
try {
// Check if hour changed - restart fetcher with new URL
const newHour = new Date().toISOString().substring(11, 13);
if (newHour !== this.currentHour) {
Log.info("[envcanada] Hour changed, reinitializing fetcher");
this.stop();
this.#initializeFetcher();
this.start();
return;
}
const html = await response.text();
const cityPageURL = this.#extractCityPageURL(html);
if (!cityPageURL) {
// This can happen during hour transitions when old responses arrive
Log.debug("[envcanada] Could not find city page URL (may be stale response from previous hour)");
return;
}
if (cityPageURL === this.lastCityPageURL) {
Log.debug("[envcanada] City page unchanged");
return;
}
this.lastCityPageURL = cityPageURL;
await this.#fetchCityPage(cityPageURL);
} catch (error) {
Log.error("[envcanada] Error:", error);
if (this.onErrorCallback) {
this.onErrorCallback({
message: error.message,
translationKey: "MODULE_ERROR_UNSPECIFIED"
});
}
}
});
this.fetcher.on("error", (errorInfo) => {
if (this.onErrorCallback) {
this.onErrorCallback(errorInfo);
}
});
}
async #fetchCityPage (url) {
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const xml = await response.text();
const weatherData = this.#parseWeatherData(xml);
if (this.onDataCallback) {
this.onDataCallback(weatherData);
}
} catch (error) {
Log.error("[envcanada] Fetch city page error:", error);
if (this.onErrorCallback) {
this.onErrorCallback({
message: "Failed to fetch city data",
translationKey: "MODULE_ERROR_UNSPECIFIED"
});
}
}
}
#parseWeatherData (xml) {
switch (this.config.type) {
case "current":
return this.#generateCurrentWeather(xml);
case "forecast":
case "daily":
return this.#generateForecast(xml);
case "hourly":
return this.#generateHourly(xml);
default:
Log.error(`[envcanada] Unknown weather type: ${this.config.type}`);
return null;
}
}
#generateCurrentWeather (xml) {
const current = { date: new Date() };
// Try to get temperature from currentConditions first
const currentTempStr = this.#extract(xml, /<currentConditions>.*?<temperature[^>]*>(.*?)<\/temperature>/s);
if (currentTempStr && currentTempStr !== "") {
current.temperature = parseFloat(currentTempStr);
this.cacheCurrentTemp = current.temperature;
} else {
// Fallback: extract from first forecast period if currentConditions is empty
const firstForecast = xml.match(/<forecast>(.*?)<\/forecast>/s);
if (firstForecast) {
const forecastXml = firstForecast[1];
const temp = this.#extract(forecastXml, /<temperature[^>]*>(.*?)<\/temperature>/);
if (temp && temp !== "") {
current.temperature = parseFloat(temp);
this.cacheCurrentTemp = current.temperature;
} else if (this.cacheCurrentTemp !== null) {
current.temperature = this.cacheCurrentTemp;
} else {
current.temperature = null;
}
}
}
// Wind chill / humidex for feels like temperature
const windChill = this.#extract(xml, /<windChill[^>]*>(.*?)<\/windChill>/);
const humidex = this.#extract(xml, /<humidex[^>]*>(.*?)<\/humidex>/);
if (windChill) {
current.feelsLikeTemp = parseFloat(windChill);
} else if (humidex) {
current.feelsLikeTemp = parseFloat(humidex);
}
// Get wind and icon from currentConditions or first forecast
const firstForecast = xml.match(/<forecast>(.*?)<\/forecast>/s);
if (!firstForecast) {
Log.warn("[envcanada] No forecast data available");
return current;
}
const forecastXml = firstForecast[1];
// Wind speed - try currentConditions first, fallback to forecast
let windSpeed = this.#extract(xml, /<currentConditions>.*?<wind>.*?<speed[^>]*>(.*?)<\/speed>/s);
if (!windSpeed) {
windSpeed = this.#extract(forecastXml, /<speed[^>]*>(.*?)<\/speed>/);
}
if (windSpeed) {
current.windSpeed = (windSpeed === "calm") ? 0 : convertKmhToMs(parseFloat(windSpeed));
}
// Wind bearing - try currentConditions first, fallback to forecast
let windBearing = this.#extract(xml, /<currentConditions>.*?<wind>.*?<bearing[^>]*>(.*?)<\/bearing>/s);
if (!windBearing) {
windBearing = this.#extract(forecastXml, /<bearing[^>]*>(.*?)<\/bearing>/);
}
if (windBearing) current.windFromDirection = parseFloat(windBearing);
// Try icon from currentConditions first, fallback to forecast
let iconCode = this.#extract(xml, /<currentConditions>.*?<iconCode[^>]*>(.*?)<\/iconCode>/s);
if (!iconCode) {
iconCode = this.#extract(forecastXml, /<iconCode[^>]*>(.*?)<\/iconCode>/);
}
if (iconCode) current.weatherType = this.#convertWeatherType(iconCode);
// Humidity from currentConditions
const humidity = this.#extract(xml, /<currentConditions>.*?<relativeHumidity[^>]*>(.*?)<\/relativeHumidity>/s);
if (humidity) current.humidity = parseFloat(humidity);
// Precipitation probability from forecast
const pop = this.#extract(forecastXml, /<pop[^>]*>(.*?)<\/pop>/);
if (pop && pop !== "") {
current.precipitationProbability = parseFloat(pop);
}
// Sunrise/sunset (from riseSet, independent of currentConditions)
const sunriseTime = this.#extract(xml, /<dateTime[^>]*name="sunrise"[^>]*>.*?<timeStamp>(.*?)<\/timeStamp>/s);
const sunsetTime = this.#extract(xml, /<dateTime[^>]*name="sunset"[^>]*>.*?<timeStamp>(.*?)<\/timeStamp>/s);
if (sunriseTime) current.sunrise = this.#parseECTime(sunriseTime);
if (sunsetTime) current.sunset = this.#parseECTime(sunsetTime);
return current;
}
#generateForecast (xml) {
const days = [];
const forecasts = xml.match(/<forecast>(.*?)<\/forecast>/gs) || [];
if (forecasts.length === 0) return days;
// Get current temp
const currentTempStr = this.#extract(xml, /<currentConditions>.*?<temperature[^>]*>(.*?)<\/temperature>/s);
const currentTemp = currentTempStr ? parseFloat(currentTempStr) : null;
// Check if first forecast is Today or Tonight
const isToday = forecasts[0].includes("textForecastName=\"Today\"");
let nextDay = isToday ? 2 : 1;
const lastDay = isToday ? 12 : 11;
// Process first day
const firstDay = {
date: new Date(),
precipitationProbability: null
};
this.#extractForecastTemps(firstDay, forecasts, 0, isToday, currentTemp);
this.#extractForecastPrecip(firstDay, forecasts, 0);
const firstIcon = this.#extract(forecasts[0], /<iconCode[^>]*>(.*?)<\/iconCode>/);
if (firstIcon) firstDay.weatherType = this.#convertWeatherType(firstIcon);
days.push(firstDay);
// Process remaining days
let date = new Date();
for (let i = nextDay; i < lastDay && i < forecasts.length; i += 2) {
date = new Date(date);
date.setDate(date.getDate() + 1);
const day = {
date: new Date(date),
precipitationProbability: null
};
this.#extractForecastTemps(day, forecasts, i, true, currentTemp);
this.#extractForecastPrecip(day, forecasts, i);
const icon = this.#extract(forecasts[i], /<iconCode[^>]*>(.*?)<\/iconCode>/);
if (icon) day.weatherType = this.#convertWeatherType(icon);
days.push(day);
}
return days;
}
#extractForecastTemps (weather, forecasts, index, hasToday, currentTemp) {
let tempToday = null;
let tempTonight = null;
if (hasToday && forecasts[index]) {
const temp = this.#extract(forecasts[index], /<temperature[^>]*>(.*?)<\/temperature>/);
if (temp) tempToday = parseFloat(temp);
}
if (forecasts[index + 1]) {
const temp = this.#extract(forecasts[index + 1], /<temperature[^>]*>(.*?)<\/temperature>/);
if (temp) tempTonight = parseFloat(temp);
}
if (tempToday !== null && tempTonight !== null) {
weather.maxTemperature = Math.max(tempToday, tempTonight);
weather.minTemperature = Math.min(tempToday, tempTonight);
} else if (tempToday !== null) {
weather.maxTemperature = tempToday;
weather.minTemperature = currentTemp || tempToday;
} else if (tempTonight !== null) {
weather.maxTemperature = currentTemp || tempTonight;
weather.minTemperature = tempTonight;
}
}
#extractForecastPrecip (weather, forecasts, index) {
const precips = [];
if (forecasts[index]) {
const pop = this.#extract(forecasts[index], /<pop[^>]*>(.*?)<\/pop>/);
if (pop) precips.push(parseFloat(pop));
}
if (forecasts[index + 1]) {
const pop = this.#extract(forecasts[index + 1], /<pop[^>]*>(.*?)<\/pop>/);
if (pop) precips.push(parseFloat(pop));
}
if (precips.length > 0) {
weather.precipitationProbability = Math.max(...precips);
}
}
#generateHourly (xml) {
const hours = [];
const hourlyMatches = xml.matchAll(/<hourlyForecast[^>]*dateTimeUTC="([^"]*)"[^>]*>(.*?)<\/hourlyForecast>/gs);
for (const [, dateTimeUTC, hourXML] of hourlyMatches) {
const weather = {};
weather.date = this.#parseECTime(dateTimeUTC);
const temp = this.#extract(hourXML, /<temperature[^>]*>(.*?)<\/temperature>/);
if (temp) weather.temperature = parseFloat(temp);
const lop = this.#extract(hourXML, /<lop[^>]*>(.*?)<\/lop>/);
if (lop) weather.precipitationProbability = parseFloat(lop);
const icon = this.#extract(hourXML, /<iconCode[^>]*>(.*?)<\/iconCode>/);
if (icon) weather.weatherType = this.#convertWeatherType(icon);
hours.push(weather);
if (hours.length >= 24) break;
}
return hours;
}
#extract (text, pattern) {
const match = text.match(pattern);
return match ? match[1].trim() : null;
}
#getIndexUrl () {
const hour = new Date().toISOString().substring(11, 13);
return `https://dd.weather.gc.ca/today/citypage_weather/${this.config.provCode}/${hour}/`;
}
#extractCityPageURL (html) {
// New format: {timestamp}_MSC_CitypageWeather_{siteCode}_en.xml
const pattern = `[^"]*_MSC_CitypageWeather_${this.config.siteCode}_en\\.xml`;
const match = html.match(new RegExp(`href="(${pattern})"`));
if (match && match[1]) {
return this.#getIndexUrl() + match[1];
}
return null;
}
#parseECTime (timeStr) {
if (!timeStr || timeStr.length < 12) return new Date();
const y = parseInt(timeStr.substring(0, 4), 10);
const m = parseInt(timeStr.substring(4, 6), 10) - 1;
const d = parseInt(timeStr.substring(6, 8), 10);
const h = parseInt(timeStr.substring(8, 10), 10);
const min = parseInt(timeStr.substring(10, 12), 10);
const s = timeStr.length >= 14 ? parseInt(timeStr.substring(12, 14), 10) : 0;
// Create UTC date since input timestamps are in UTC
return new Date(Date.UTC(y, m, d, h, min, s));
}
#convertWeatherType (iconCode) {
const code = parseInt(iconCode, 10);
const map = {
0: "day-sunny",
1: "day-sunny",
2: "day-sunny-overcast",
3: "day-cloudy",
4: "day-cloudy",
5: "day-cloudy",
6: "day-sprinkle",
7: "day-showers",
8: "snow",
9: "day-thunderstorm",
10: "cloud",
11: "showers",
12: "rain",
13: "rain",
14: "sleet",
15: "sleet",
16: "snow",
17: "snow",
18: "snow",
19: "thunderstorm",
20: "cloudy",
21: "cloudy",
22: "day-cloudy",
23: "day-haze",
24: "fog",
25: "snow-wind",
26: "sleet",
27: "sleet",
28: "rain",
29: "na",
30: "night-clear",
31: "night-clear",
32: "night-partly-cloudy",
33: "night-alt-cloudy",
34: "night-alt-cloudy",
35: "night-partly-cloudy",
36: "night-alt-showers",
37: "night-rain-mix",
38: "night-alt-snow",
39: "night-thunderstorm",
40: "snow-wind",
41: "tornado",
42: "tornado",
43: "windy",
44: "smoke",
45: "sandstorm",
46: "thunderstorm",
47: "thunderstorm",
48: "tornado"
};
return map[code] || null;
}
}
module.exports = EnvCanadaProvider;
@@ -0,0 +1,525 @@
const Log = require("logger");
const HTTPFetcher = require("#http_fetcher");
// https://www.bigdatacloud.com/docs/api/free-reverse-geocode-to-city-api
const GEOCODE_BASE = "https://api.bigdatacloud.net/data/reverse-geocode-client";
const OPEN_METEO_BASE = "https://api.open-meteo.com/v1";
/**
* Server-side weather provider for Open-Meteo
* see https://open-meteo.com/
*/
class OpenMeteoProvider {
// https://open-meteo.com/en/docs
hourlyParams = [
"temperature_2m",
"relativehumidity_2m",
"dewpoint_2m",
"apparent_temperature",
"pressure_msl",
"surface_pressure",
"cloudcover",
"cloudcover_low",
"cloudcover_mid",
"cloudcover_high",
"windspeed_10m",
"windspeed_80m",
"windspeed_120m",
"windspeed_180m",
"winddirection_10m",
"winddirection_80m",
"winddirection_120m",
"winddirection_180m",
"windgusts_10m",
"shortwave_radiation",
"direct_radiation",
"direct_normal_irradiance",
"diffuse_radiation",
"vapor_pressure_deficit",
"cape",
"evapotranspiration",
"et0_fao_evapotranspiration",
"precipitation",
"snowfall",
"precipitation_probability",
"rain",
"showers",
"weathercode",
"snow_depth",
"freezinglevel_height",
"visibility",
"soil_temperature_0cm",
"soil_temperature_6cm",
"soil_temperature_18cm",
"soil_temperature_54cm",
"soil_moisture_0_1cm",
"soil_moisture_1_3cm",
"soil_moisture_3_9cm",
"soil_moisture_9_27cm",
"soil_moisture_27_81cm",
"uv_index",
"uv_index_clear_sky",
"is_day",
"terrestrial_radiation",
"terrestrial_radiation_instant",
"shortwave_radiation_instant",
"diffuse_radiation_instant",
"direct_radiation_instant",
"direct_normal_irradiance_instant"
];
dailyParams = [
"temperature_2m_max",
"temperature_2m_min",
"apparent_temperature_min",
"apparent_temperature_max",
"precipitation_sum",
"rain_sum",
"showers_sum",
"snowfall_sum",
"precipitation_hours",
"weathercode",
"sunrise",
"sunset",
"windspeed_10m_max",
"windgusts_10m_max",
"winddirection_10m_dominant",
"shortwave_radiation_sum",
"uv_index_max",
"et0_fao_evapotranspiration"
];
constructor (config) {
this.config = {
apiBase: OPEN_METEO_BASE,
lat: 0,
lon: 0,
pastDays: 0,
type: "current",
maxNumberOfDays: 5,
updateInterval: 10 * 60 * 1000,
...config
};
this.locationName = null;
this.fetcher = null;
this.onDataCallback = null;
this.onErrorCallback = null;
}
async initialize () {
await this.#fetchLocation();
this.#initializeFetcher();
}
/**
* Set callbacks for data/error events
* @param {(data: object) => void} onData - Called with weather data
* @param {(error: object) => void} onError - Called with error info
*/
setCallbacks (onData, onError) {
this.onDataCallback = onData;
this.onErrorCallback = onError;
}
/**
* Start periodic fetching
*/
start () {
if (this.fetcher) {
this.fetcher.startPeriodicFetch();
}
}
/**
* Stop periodic fetching
*/
stop () {
if (this.fetcher) {
this.fetcher.clearTimer();
}
}
async #fetchLocation () {
const url = `${GEOCODE_BASE}?latitude=${this.config.lat}&longitude=${this.config.lon}&localityLanguage=${this.config.lang || "en"}`;
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
if (data && data.city) {
this.locationName = `${data.city}, ${data.principalSubdivisionCode}`;
}
} catch (error) {
Log.debug("[openmeteo] Could not load location data:", error.message);
}
}
#initializeFetcher () {
const url = this.#getUrl();
this.fetcher = new HTTPFetcher(url, {
reloadInterval: this.config.updateInterval,
headers: { "Cache-Control": "no-cache" },
logContext: "weatherprovider.openmeteo"
});
this.fetcher.on("response", async (response) => {
if (response.status === 304) return;
try {
const data = await response.json();
this.#handleResponse(data);
} catch (error) {
Log.error("[openmeteo] Failed to parse JSON:", error);
if (this.onErrorCallback) {
this.onErrorCallback({
message: "Failed to parse API response",
translationKey: "MODULE_ERROR_UNSPECIFIED"
});
}
}
});
this.fetcher.on("error", (errorInfo) => {
if (this.onErrorCallback) {
this.onErrorCallback(errorInfo);
}
});
}
#handleResponse (data) {
const parsedData = this.#parseWeatherApiResponse(data);
if (!parsedData) {
if (this.onErrorCallback) {
this.onErrorCallback({
message: "Invalid API response",
translationKey: "MODULE_ERROR_UNSPECIFIED"
});
}
return;
}
try {
let weatherData;
switch (this.config.type) {
case "current":
weatherData = this.#generateWeatherDayFromCurrentWeather(parsedData);
break;
case "forecast":
case "daily":
weatherData = this.#generateWeatherObjectsFromForecast(parsedData);
break;
case "hourly":
weatherData = this.#generateWeatherObjectsFromHourly(parsedData);
break;
default:
Log.error(`[openmeteo] Unknown type: ${this.config.type}`);
throw new Error(`Unknown weather type: ${this.config.type}`);
}
if (weatherData && this.onDataCallback) {
this.onDataCallback(weatherData);
}
} catch (error) {
Log.error("[openmeteo] Error processing weather data:", error);
if (this.onErrorCallback) {
this.onErrorCallback({
message: error.message,
translationKey: "MODULE_ERROR_UNSPECIFIED"
});
}
}
}
#getQueryParameters () {
let maxNumberOfDays = this.config.maxNumberOfDays;
if (this.config.maxNumberOfDays !== undefined && !isNaN(parseFloat(this.config.maxNumberOfDays))) {
const maxEntriesLimit = ["daily", "forecast"].includes(this.config.type) ? 7 : this.config.type === "hourly" ? 48 : 0;
const daysFactor = ["daily", "forecast"].includes(this.config.type) ? 1 : this.config.type === "hourly" ? 24 : 0;
const maxEntries = Math.max(1, Math.min(Math.round(parseFloat(this.config.maxNumberOfDays)) * daysFactor, maxEntriesLimit));
maxNumberOfDays = Math.ceil(maxEntries / Math.max(1, daysFactor));
}
const params = {
latitude: this.config.lat,
longitude: this.config.lon,
timeformat: "unixtime",
timezone: "auto",
past_days: this.config.pastDays ?? 0,
daily: this.dailyParams,
hourly: this.hourlyParams,
temperature_unit: "celsius",
windspeed_unit: "ms",
precipitation_unit: "mm"
};
switch (this.config.type) {
case "hourly":
case "daily":
case "forecast":
params.forecast_days = maxNumberOfDays + 1; // Open-Meteo counts today as day 1, so maxNumberOfDays=5 needs forecast_days=6
break;
case "current":
params.current_weather = true;
params.forecast_days = 1;
break;
default:
return "";
}
return Object.keys(params)
.filter((key) => params[key] !== undefined && params[key] !== null && params[key] !== "")
.map((key) => {
switch (key) {
case "hourly":
case "daily":
return `${encodeURIComponent(key)}=${params[key].join(",")}`;
default:
return `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`;
}
})
.join("&");
}
#getUrl () {
return `${this.config.apiBase}/forecast?${this.#getQueryParameters()}`;
}
#transposeDataMatrix (data) {
return data.time.map((_, index) => Object.keys(data).reduce((row, key) => {
const value = data[key][index];
return {
...row,
// Convert Unix timestamps to Date objects
// timezone: "auto" returns times already in location timezone
[key]: ["time", "sunrise", "sunset"].includes(key) ? new Date(value * 1000) : value
};
}, {}));
}
#parseWeatherApiResponse (data) {
const validByType = {
current: data.current_weather && data.current_weather.time,
hourly: data.hourly && data.hourly.time && Array.isArray(data.hourly.time) && data.hourly.time.length > 0,
daily: data.daily && data.daily.time && Array.isArray(data.daily.time) && data.daily.time.length > 0
};
const type = ["daily", "forecast"].includes(this.config.type) ? "daily" : this.config.type;
if (!validByType[type]) return null;
if (type === "current" && !validByType.daily && !validByType.hourly) {
return null;
}
for (const key of ["hourly", "daily"]) {
if (typeof data[key] === "object") {
data[key] = this.#transposeDataMatrix(data[key]);
}
}
if (data.current_weather) {
data.current_weather.time = new Date(data.current_weather.time * 1000);
}
return data;
}
#convertWeatherType (weathercode, isDayTime) {
const weatherConditions = {
0: "clear",
1: "mainly-clear",
2: "partly-cloudy",
3: "overcast",
45: "fog",
48: "depositing-rime-fog",
51: "drizzle-light-intensity",
53: "drizzle-moderate-intensity",
55: "drizzle-dense-intensity",
56: "freezing-drizzle-light-intensity",
57: "freezing-drizzle-dense-intensity",
61: "rain-slight-intensity",
63: "rain-moderate-intensity",
65: "rain-heavy-intensity",
66: "freezing-rain-light-intensity",
67: "freezing-rain-heavy-intensity",
71: "snow-fall-slight-intensity",
73: "snow-fall-moderate-intensity",
75: "snow-fall-heavy-intensity",
77: "snow-grains",
80: "rain-showers-slight",
81: "rain-showers-moderate",
82: "rain-showers-violent",
85: "snow-showers-slight",
86: "snow-showers-heavy",
95: "thunderstorm",
96: "thunderstorm-slight-hail",
99: "thunderstorm-heavy-hail"
};
if (!(weathercode in weatherConditions)) return null;
const mappings = {
clear: isDayTime ? "day-sunny" : "night-clear",
"mainly-clear": isDayTime ? "day-cloudy" : "night-alt-cloudy",
"partly-cloudy": isDayTime ? "day-cloudy" : "night-alt-cloudy",
overcast: isDayTime ? "day-sunny-overcast" : "night-alt-partly-cloudy",
fog: isDayTime ? "day-fog" : "night-fog",
"depositing-rime-fog": isDayTime ? "day-fog" : "night-fog",
"drizzle-light-intensity": isDayTime ? "day-sprinkle" : "night-sprinkle",
"rain-slight-intensity": isDayTime ? "day-sprinkle" : "night-sprinkle",
"rain-showers-slight": isDayTime ? "day-sprinkle" : "night-sprinkle",
"drizzle-moderate-intensity": isDayTime ? "day-showers" : "night-showers",
"rain-moderate-intensity": isDayTime ? "day-showers" : "night-showers",
"rain-showers-moderate": isDayTime ? "day-showers" : "night-showers",
"drizzle-dense-intensity": isDayTime ? "day-thunderstorm" : "night-thunderstorm",
"rain-heavy-intensity": isDayTime ? "day-thunderstorm" : "night-thunderstorm",
"rain-showers-violent": isDayTime ? "day-thunderstorm" : "night-thunderstorm",
"freezing-rain-light-intensity": isDayTime ? "day-rain-mix" : "night-rain-mix",
"freezing-drizzle-light-intensity": "snowflake-cold",
"freezing-drizzle-dense-intensity": "snowflake-cold",
"snow-grains": isDayTime ? "day-sleet" : "night-sleet",
"snow-fall-slight-intensity": isDayTime ? "day-snow-wind" : "night-snow-wind",
"snow-fall-moderate-intensity": isDayTime ? "day-snow-wind" : "night-snow-wind",
"snow-fall-heavy-intensity": isDayTime ? "day-snow-thunderstorm" : "night-snow-thunderstorm",
"freezing-rain-heavy-intensity": isDayTime ? "day-snow-thunderstorm" : "night-snow-thunderstorm",
"snow-showers-slight": isDayTime ? "day-rain-mix" : "night-rain-mix",
"snow-showers-heavy": isDayTime ? "day-rain-mix" : "night-rain-mix",
thunderstorm: isDayTime ? "day-thunderstorm" : "night-thunderstorm",
"thunderstorm-slight-hail": isDayTime ? "day-sleet" : "night-sleet",
"thunderstorm-heavy-hail": isDayTime ? "day-sleet-storm" : "night-sleet-storm"
};
return mappings[weatherConditions[`${weathercode}`]] || "na";
}
#isDayTime (date, sunrise, sunset) {
const time = date.getTime();
return time >= sunrise.getTime() && time < sunset.getTime();
}
#generateWeatherDayFromCurrentWeather (parsedData) {
// Basic current weather data
const current = {
date: parsedData.current_weather.time,
windSpeed: parsedData.current_weather.windspeed,
windFromDirection: parsedData.current_weather.winddirection,
temperature: parsedData.current_weather.temperature,
weatherType: this.#convertWeatherType(parsedData.current_weather.weathercode, true)
};
// Add hourly data if available
if (parsedData.hourly) {
// Open-Meteo updates current_weather every 15 min, but hourly entries only
// exist at full hours — find the last entry at or before the current time.
const currentMs = parsedData.current_weather.time.getTime();
const hourlyIndex = parsedData.hourly.findLastIndex((hour) => hour.time.getTime() <= currentMs);
const hourData = parsedData.hourly[Math.max(0, hourlyIndex)];
current.humidity = hourData.relativehumidity_2m;
current.feelsLikeTemp = hourData.apparent_temperature;
current.rain = hourData.rain;
current.snow = hourData.snowfall != null ? hourData.snowfall * 10 : null;
current.precipitationAmount = hourData.precipitation;
current.precipitationProbability = hourData.precipitation_probability;
current.uvIndex = hourData.uv_index;
}
// Add daily data if available (after transpose, daily is array of objects)
if (parsedData.daily && Array.isArray(parsedData.daily) && parsedData.daily[0]) {
const today = parsedData.daily[0];
if (today.sunrise) {
current.sunrise = today.sunrise;
}
if (today.sunset) {
current.sunset = today.sunset;
// Update weatherType with correct day/night status
if (current.sunrise && current.sunset) {
current.weatherType = this.#convertWeatherType(
parsedData.current_weather.weathercode,
this.#isDayTime(parsedData.current_weather.time, current.sunrise, current.sunset)
);
}
}
if (today.temperature_2m_min !== undefined) {
current.minTemperature = today.temperature_2m_min;
}
if (today.temperature_2m_max !== undefined) {
current.maxTemperature = today.temperature_2m_max;
}
}
return current;
}
#generateWeatherObjectsFromForecast (parsedData) {
return parsedData.daily.map((weather) => ({
date: weather.time,
windSpeed: weather.windspeed_10m_max,
windFromDirection: weather.winddirection_10m_dominant,
sunrise: weather.sunrise,
sunset: weather.sunset,
temperature: parseFloat((weather.temperature_2m_max + weather.temperature_2m_min) / 2),
minTemperature: parseFloat(weather.temperature_2m_min),
maxTemperature: parseFloat(weather.temperature_2m_max),
weatherType: this.#convertWeatherType(weather.weathercode, true),
rain: weather.rain_sum != null ? parseFloat(weather.rain_sum) : null,
snow: weather.snowfall_sum != null ? parseFloat(weather.snowfall_sum * 10) : null,
precipitationAmount: weather.precipitation_sum != null ? parseFloat(weather.precipitation_sum) : null,
precipitationProbability: weather.precipitation_hours != null ? parseFloat(weather.precipitation_hours * 100 / 24) : null,
uvIndex: weather.uv_index_max != null ? parseFloat(weather.uv_index_max) : null
}));
}
#generateWeatherObjectsFromHourly (parsedData) {
const hours = [];
const now = new Date();
parsedData.hourly.forEach((weather, i) => {
// Skip past entries
if (weather.time <= now) {
return;
}
// Calculate daily index with bounds check
const h = Math.ceil((i + 1) / 24) - 1;
const safeH = Math.max(0, Math.min(h, parsedData.daily.length - 1));
const dailyData = parsedData.daily[safeH];
const hourlyWeather = {
date: weather.time,
windSpeed: weather.windspeed_10m,
windFromDirection: weather.winddirection_10m,
sunrise: dailyData.sunrise,
sunset: dailyData.sunset,
temperature: parseFloat(weather.temperature_2m),
minTemperature: parseFloat(dailyData.temperature_2m_min),
maxTemperature: parseFloat(dailyData.temperature_2m_max),
weatherType: this.#convertWeatherType(
weather.weathercode,
this.#isDayTime(weather.time, dailyData.sunrise, dailyData.sunset)
),
humidity: weather.relativehumidity_2m != null ? parseFloat(weather.relativehumidity_2m) : null,
rain: weather.rain != null ? parseFloat(weather.rain) : null,
snow: weather.snowfall != null ? parseFloat(weather.snowfall * 10) : null,
precipitationAmount: weather.precipitation != null ? parseFloat(weather.precipitation) : null,
precipitationProbability: weather.precipitation_probability != null ? parseFloat(weather.precipitation_probability) : null,
uvIndex: weather.uv_index != null ? parseFloat(weather.uv_index) : null
};
hours.push(hourlyWeather);
});
return hours;
}
}
module.exports = OpenMeteoProvider;
@@ -0,0 +1,407 @@
const Log = require("logger");
const weatherUtils = require("../provider-utils");
const HTTPFetcher = require("#http_fetcher");
/**
* Server-side weather provider for OpenWeatherMap
* see https://openweathermap.org/
*/
class OpenWeatherMapProvider {
constructor (config) {
this.config = {
apiVersion: "3.0",
apiBase: "https://api.openweathermap.org/data/",
weatherEndpoint: "/onecall",
locationID: false,
location: false,
lat: 0,
lon: 0,
apiKey: "",
type: "current",
updateInterval: 10 * 60 * 1000,
...config
};
this.fetcher = null;
this.onDataCallback = null;
this.onErrorCallback = null;
this.locationName = null;
}
initialize () {
// Validate callbacks exist
if (typeof this.onErrorCallback !== "function") {
throw new Error("setCallbacks() must be called before initialize()");
}
if (!this.config.apiKey) {
Log.error("[openweathermap] API key is required");
this.onErrorCallback({
message: "API key is required",
translationKey: "MODULE_ERROR_UNSPECIFIED"
});
return;
}
this.#initializeFetcher();
}
setCallbacks (onData, onError) {
this.onDataCallback = onData;
this.onErrorCallback = onError;
}
start () {
if (this.fetcher) {
this.fetcher.startPeriodicFetch();
}
}
stop () {
if (this.fetcher) {
this.fetcher.clearTimer();
}
}
#initializeFetcher () {
const url = this.#getUrl();
this.fetcher = new HTTPFetcher(url, {
reloadInterval: this.config.updateInterval,
headers: { "Cache-Control": "no-cache" },
logContext: "weatherprovider.openweathermap"
});
this.fetcher.on("response", async (response) => {
if (response.status === 304) return;
try {
const data = await response.json();
this.#handleResponse(data);
} catch (error) {
Log.error("[openweathermap] Failed to parse JSON:", error);
if (this.onErrorCallback) {
this.onErrorCallback({
message: "Failed to parse API response",
translationKey: "MODULE_ERROR_UNSPECIFIED"
});
}
}
});
this.fetcher.on("error", (errorInfo) => {
if (this.onErrorCallback) {
this.onErrorCallback(errorInfo);
}
});
}
#handleResponse (data) {
try {
let weatherData;
if (this.config.weatherEndpoint === "/onecall") {
// One Call API (v3.0)
if (data.timezone) {
this.locationName = data.timezone;
}
const onecallData = this.#generateWeatherObjectsFromOnecall(data);
switch (this.config.type) {
case "current":
weatherData = onecallData.current;
break;
case "forecast":
case "daily":
weatherData = onecallData.days;
break;
case "hourly":
weatherData = onecallData.hours;
break;
default:
Log.error(`[openweathermap] Unknown type: ${this.config.type}`);
throw new Error(`Unknown weather type: ${this.config.type}`);
}
} else if (this.config.weatherEndpoint === "/weather") {
// Current weather endpoint (API v2.5)
weatherData = this.#generateWeatherObjectFromCurrentWeather(data);
} else if (this.config.weatherEndpoint === "/forecast") {
// 3-hourly forecast endpoint (API v2.5)
weatherData = this.config.type === "hourly"
? this.#generateHourlyWeatherObjectsFromForecast(data)
: this.#generateDailyWeatherObjectsFromForecast(data);
} else {
throw new Error(`Unknown weather endpoint: ${this.config.weatherEndpoint}`);
}
if (weatherData && this.onDataCallback) {
this.onDataCallback(weatherData);
}
} catch (error) {
Log.error("[openweathermap] Error processing weather data:", error);
if (this.onErrorCallback) {
this.onErrorCallback({
message: error.message,
translationKey: "MODULE_ERROR_UNSPECIFIED"
});
}
}
}
#generateWeatherObjectFromCurrentWeather (data) {
const timezoneOffsetMinutes = (data.timezone ?? 0) / 60;
if (data.name && data.sys?.country) {
this.locationName = `${data.name}, ${data.sys.country}`;
} else if (data.name) {
this.locationName = data.name;
}
const weather = {};
weather.date = weatherUtils.applyTimezoneOffset(new Date(data.dt * 1000), timezoneOffsetMinutes);
weather.temperature = data.main.temp;
weather.feelsLikeTemp = data.main.feels_like;
weather.humidity = data.main.humidity;
weather.windSpeed = data.wind.speed;
weather.windFromDirection = data.wind.deg;
weather.weatherType = weatherUtils.convertWeatherType(data.weather[0].icon);
weather.sunrise = weatherUtils.applyTimezoneOffset(new Date(data.sys.sunrise * 1000), timezoneOffsetMinutes);
weather.sunset = weatherUtils.applyTimezoneOffset(new Date(data.sys.sunset * 1000), timezoneOffsetMinutes);
return weather;
}
#extractThreeHourPrecipitation (forecast) {
const rain = Number.parseFloat(forecast.rain?.["3h"] ?? "") || 0;
const snow = Number.parseFloat(forecast.snow?.["3h"] ?? "") || 0;
const precipitationAmount = rain + snow;
return {
rain,
snow,
precipitationAmount,
hasPrecipitation: precipitationAmount > 0
};
}
#generateHourlyWeatherObjectsFromForecast (data) {
const timezoneOffsetSeconds = data.city?.timezone ?? 0;
const timezoneOffsetMinutes = timezoneOffsetSeconds / 60;
if (data.city?.name && data.city?.country) {
this.locationName = `${data.city.name}, ${data.city.country}`;
}
return data.list.map((forecast) => {
const weather = {};
weather.date = weatherUtils.applyTimezoneOffset(new Date(forecast.dt * 1000), timezoneOffsetMinutes);
weather.temperature = forecast.main.temp;
weather.feelsLikeTemp = forecast.main.feels_like;
weather.humidity = forecast.main.humidity;
weather.windSpeed = forecast.wind.speed;
weather.windFromDirection = forecast.wind.deg;
weather.weatherType = weatherUtils.convertWeatherType(forecast.weather[0].icon);
weather.precipitationProbability = forecast.pop !== undefined ? forecast.pop * 100 : undefined;
const precipitation = this.#extractThreeHourPrecipitation(forecast);
if (precipitation.hasPrecipitation) {
weather.rain = precipitation.rain;
weather.snow = precipitation.snow;
weather.precipitationAmount = precipitation.precipitationAmount;
}
return weather;
});
}
#generateDailyWeatherObjectsFromForecast (data) {
const timezoneOffsetSeconds = data.city?.timezone ?? 0;
const timezoneOffsetMinutes = timezoneOffsetSeconds / 60;
if (data.city?.name && data.city?.country) {
this.locationName = `${data.city.name}, ${data.city.country}`;
}
const dayMap = new Map();
for (const forecast of data.list) {
// Shift dt by timezone offset so UTC fields represent local time
const localDate = new Date((forecast.dt + timezoneOffsetSeconds) * 1000);
const dateKey = `${localDate.getUTCFullYear()}-${String(localDate.getUTCMonth() + 1).padStart(2, "0")}-${String(localDate.getUTCDate()).padStart(2, "0")}`;
if (!dayMap.has(dateKey)) {
dayMap.set(dateKey, {
date: weatherUtils.applyTimezoneOffset(new Date(forecast.dt * 1000), timezoneOffsetMinutes),
minTemps: [],
maxTemps: [],
rain: 0,
snow: 0,
weatherType: weatherUtils.convertWeatherType(forecast.weather[0].icon)
});
}
const day = dayMap.get(dateKey);
day.minTemps.push(forecast.main.temp_min);
day.maxTemps.push(forecast.main.temp_max);
const hour = localDate.getUTCHours();
if (hour >= 8 && hour <= 17) {
day.weatherType = weatherUtils.convertWeatherType(forecast.weather[0].icon);
}
const precipitation = this.#extractThreeHourPrecipitation(forecast);
day.rain += precipitation.rain;
day.snow += precipitation.snow;
}
return Array.from(dayMap.values()).map((day) => ({
date: day.date,
minTemperature: Math.min(...day.minTemps),
maxTemperature: Math.max(...day.maxTemps),
weatherType: day.weatherType,
rain: day.rain,
snow: day.snow,
precipitationAmount: day.rain + day.snow
}));
}
#generateWeatherObjectsFromOnecall (data) {
let precip;
// Get current weather
const current = {};
if (data.hasOwnProperty("current")) {
const timezoneOffset = data.timezone_offset / 60;
current.date = weatherUtils.applyTimezoneOffset(new Date(data.current.dt * 1000), timezoneOffset);
current.windSpeed = data.current.wind_speed;
current.windFromDirection = data.current.wind_deg;
current.sunrise = weatherUtils.applyTimezoneOffset(new Date(data.current.sunrise * 1000), timezoneOffset);
current.sunset = weatherUtils.applyTimezoneOffset(new Date(data.current.sunset * 1000), timezoneOffset);
current.temperature = data.current.temp;
current.weatherType = weatherUtils.convertWeatherType(data.current.weather[0].icon);
current.humidity = data.current.humidity;
current.uvIndex = data.current.uvi;
precip = false;
if (data.current.hasOwnProperty("rain") && !isNaN(data.current.rain["1h"])) {
current.rain = data.current.rain["1h"];
precip = true;
}
if (data.current.hasOwnProperty("snow") && !isNaN(data.current.snow["1h"])) {
current.snow = data.current.snow["1h"];
precip = true;
}
if (precip) {
current.precipitationAmount = (current.rain ?? 0) + (current.snow ?? 0);
}
current.feelsLikeTemp = data.current.feels_like;
}
// Get hourly weather
const hours = [];
if (data.hasOwnProperty("hourly")) {
const timezoneOffset = data.timezone_offset / 60;
for (const hour of data.hourly) {
const weather = {};
weather.date = weatherUtils.applyTimezoneOffset(new Date(hour.dt * 1000), timezoneOffset);
weather.temperature = hour.temp;
weather.feelsLikeTemp = hour.feels_like;
weather.humidity = hour.humidity;
weather.windSpeed = hour.wind_speed;
weather.windFromDirection = hour.wind_deg;
weather.weatherType = weatherUtils.convertWeatherType(hour.weather[0].icon);
weather.precipitationProbability = hour.pop !== undefined ? hour.pop * 100 : undefined;
weather.uvIndex = hour.uvi;
precip = false;
if (hour.hasOwnProperty("rain") && !isNaN(hour.rain["1h"])) {
weather.rain = hour.rain["1h"];
precip = true;
}
if (hour.hasOwnProperty("snow") && !isNaN(hour.snow["1h"])) {
weather.snow = hour.snow["1h"];
precip = true;
}
if (precip) {
weather.precipitationAmount = (weather.rain ?? 0) + (weather.snow ?? 0);
}
hours.push(weather);
}
}
// Get daily weather
const days = [];
if (data.hasOwnProperty("daily")) {
const timezoneOffset = data.timezone_offset / 60;
for (const day of data.daily) {
const weather = {};
weather.date = weatherUtils.applyTimezoneOffset(new Date(day.dt * 1000), timezoneOffset);
weather.sunrise = weatherUtils.applyTimezoneOffset(new Date(day.sunrise * 1000), timezoneOffset);
weather.sunset = weatherUtils.applyTimezoneOffset(new Date(day.sunset * 1000), timezoneOffset);
weather.minTemperature = day.temp.min;
weather.maxTemperature = day.temp.max;
weather.humidity = day.humidity;
weather.windSpeed = day.wind_speed;
weather.windFromDirection = day.wind_deg;
weather.weatherType = weatherUtils.convertWeatherType(day.weather[0].icon);
weather.precipitationProbability = day.pop !== undefined ? day.pop * 100 : undefined;
weather.uvIndex = day.uvi;
precip = false;
if (!isNaN(day.rain)) {
weather.rain = day.rain;
precip = true;
}
if (!isNaN(day.snow)) {
weather.snow = day.snow;
precip = true;
}
if (precip) {
weather.precipitationAmount = (weather.rain ?? 0) + (weather.snow ?? 0);
}
days.push(weather);
}
}
return { current, hours, days };
}
#getUrl () {
return this.config.apiBase + this.config.apiVersion + this.config.weatherEndpoint + this.#getParams();
}
#getParams () {
let params = "?";
if (this.config.weatherEndpoint === "/onecall") {
params += `lat=${this.config.lat}`;
params += `&lon=${this.config.lon}`;
if (this.config.type === "current") {
params += "&exclude=minutely,hourly,daily";
} else if (this.config.type === "hourly") {
params += "&exclude=current,minutely,daily";
} else if (this.config.type === "daily" || this.config.type === "forecast") {
params += "&exclude=current,minutely,hourly";
} else {
params += "&exclude=minutely";
}
} else if (this.config.lat && this.config.lon) {
params += `lat=${this.config.lat}&lon=${this.config.lon}`;
} else if (this.config.locationID) {
params += `id=${this.config.locationID}`;
} else if (this.config.location) {
params += `q=${this.config.location}`;
}
params += "&units=metric";
params += `&lang=${this.config.lang || "en"}`;
params += `&APPID=${this.config.apiKey}`;
return params;
}
}
module.exports = OpenWeatherMapProvider;
@@ -0,0 +1,271 @@
const Log = require("logger");
const HTTPFetcher = require("#http_fetcher");
class PirateweatherProvider {
constructor (config) {
this.config = {
apiBase: "https://api.pirateweather.net",
weatherEndpoint: "/forecast",
apiKey: "",
lat: 0,
lon: 0,
type: "current",
updateInterval: 10 * 60 * 1000,
lang: "en",
...config
};
this.fetcher = null;
this.onDataCallback = null;
this.onErrorCallback = null;
}
setCallbacks (onDataCallback, onErrorCallback) {
this.onDataCallback = onDataCallback;
this.onErrorCallback = onErrorCallback;
}
initialize () {
if (!this.config.apiKey) {
Log.error("[pirateweather] No API key configured");
if (this.onErrorCallback) {
this.onErrorCallback({
message: "API key required",
translationKey: "MODULE_ERROR_UNSPECIFIED"
});
}
return;
}
this.#initializeFetcher();
}
#initializeFetcher () {
const url = this.#getUrl();
this.fetcher = new HTTPFetcher(url, {
reloadInterval: this.config.updateInterval,
headers: {
"Cache-Control": "no-cache",
Accept: "application/json"
},
logContext: "weatherprovider.pirateweather"
});
this.fetcher.on("response", async (response) => {
if (response.status === 304) return;
try {
const data = await response.json();
this.#handleResponse(data);
} catch (error) {
Log.error("[pirateweather] Parse error:", error);
if (this.onErrorCallback) {
this.onErrorCallback({
message: "Failed to parse API response",
translationKey: "MODULE_ERROR_UNSPECIFIED"
});
}
}
});
this.fetcher.on("error", (errorInfo) => {
if (this.onErrorCallback) {
this.onErrorCallback(errorInfo);
}
});
}
#handleResponse (data) {
if (!data || (!data.currently && !data.daily && !data.hourly)) {
Log.error("[pirateweather] No usable data received");
if (this.onErrorCallback) {
this.onErrorCallback({
message: "No usable data in API response",
translationKey: "MODULE_ERROR_UNSPECIFIED"
});
}
return;
}
let weatherData;
switch (this.config.type) {
case "current":
weatherData = this.#generateCurrent(data);
break;
case "forecast":
case "daily":
weatherData = this.#generateDaily(data);
break;
case "hourly":
weatherData = this.#generateHourly(data);
break;
default:
Log.error(`[pirateweather] Unknown weather type: ${this.config.type}`);
if (this.onErrorCallback) {
this.onErrorCallback({
message: `Unknown weather type: ${this.config.type}`,
translationKey: "MODULE_ERROR_UNSPECIFIED"
});
}
return;
}
if (weatherData && this.onDataCallback) {
this.onDataCallback(weatherData);
}
}
#generateCurrent (data) {
if (!data.currently || typeof data.currently.temperature === "undefined") {
return null;
}
const current = {
date: new Date(),
humidity: data.currently.humidity != null ? parseFloat(data.currently.humidity) * 100 : null,
temperature: parseFloat(data.currently.temperature),
feelsLikeTemp: data.currently.apparentTemperature != null ? parseFloat(data.currently.apparentTemperature) : null,
windSpeed: data.currently.windSpeed != null ? parseFloat(data.currently.windSpeed) : null,
windFromDirection: data.currently.windBearing || null,
weatherType: this.#convertWeatherType(data.currently.icon),
sunrise: null,
sunset: null
};
// Add sunrise/sunset from daily data if available
if (data.daily && data.daily.data && data.daily.data.length > 0) {
const today = data.daily.data[0];
if (today.sunriseTime) {
current.sunrise = new Date(today.sunriseTime * 1000);
}
if (today.sunsetTime) {
current.sunset = new Date(today.sunsetTime * 1000);
}
}
return current;
}
#generateDaily (data) {
if (!data.daily || !data.daily.data || !data.daily.data.length) {
return [];
}
const days = [];
for (const forecast of data.daily.data) {
const day = {
date: new Date(forecast.time * 1000),
minTemperature: forecast.temperatureMin != null ? parseFloat(forecast.temperatureMin) : null,
maxTemperature: forecast.temperatureMax != null ? parseFloat(forecast.temperatureMax) : null,
weatherType: this.#convertWeatherType(forecast.icon),
snow: 0,
rain: 0,
precipitationAmount: 0,
precipitationProbability: forecast.precipProbability != null ? parseFloat(forecast.precipProbability) * 100 : null
};
// Handle precipitation
let precip = 0;
if (forecast.hasOwnProperty("precipAccumulation")) {
precip = forecast.precipAccumulation * 10; // cm to mm
}
day.precipitationAmount = precip;
if (forecast.precipType) {
if (forecast.precipType === "snow") {
day.snow = precip;
} else {
day.rain = precip;
}
}
days.push(day);
}
return days;
}
#generateHourly (data) {
if (!data.hourly || !data.hourly.data || !data.hourly.data.length) {
return [];
}
const hours = [];
for (const forecast of data.hourly.data) {
const hour = {
date: new Date(forecast.time * 1000),
temperature: forecast.temperature !== undefined ? parseFloat(forecast.temperature) : null,
feelsLikeTemp: forecast.apparentTemperature !== undefined ? parseFloat(forecast.apparentTemperature) : null,
weatherType: this.#convertWeatherType(forecast.icon),
windSpeed: forecast.windSpeed !== undefined ? parseFloat(forecast.windSpeed) : null,
windFromDirection: forecast.windBearing || null,
precipitationProbability: forecast.precipProbability ? parseFloat(forecast.precipProbability) * 100 : null,
snow: 0,
rain: 0,
precipitationAmount: 0
};
// Handle precipitation
let precip = 0;
if (forecast.hasOwnProperty("precipAccumulation")) {
precip = forecast.precipAccumulation * 10; // cm to mm
}
hour.precipitationAmount = precip;
if (forecast.precipType) {
if (forecast.precipType === "snow") {
hour.snow = precip;
} else {
hour.rain = precip;
}
}
hours.push(hour);
}
return hours;
}
#getUrl () {
const apiBase = this.config.apiBase || "https://api.pirateweather.net";
const weatherEndpoint = this.config.weatherEndpoint || "/forecast";
const lang = this.config.lang || "en";
return `${apiBase}${weatherEndpoint}/${this.config.apiKey}/${this.config.lat},${this.config.lon}?units=si&lang=${lang}`;
}
#convertWeatherType (weatherType) {
const weatherTypes = {
"clear-day": "day-sunny",
"clear-night": "night-clear",
rain: "rain",
snow: "snow",
sleet: "snow",
wind: "windy",
fog: "fog",
cloudy: "cloudy",
"partly-cloudy-day": "day-cloudy",
"partly-cloudy-night": "night-cloudy"
};
return weatherTypes[weatherType] || null;
}
start () {
if (this.fetcher) {
this.fetcher.startPeriodicFetch();
}
}
stop () {
if (this.fetcher) {
this.fetcher.clearTimer();
}
}
}
module.exports = PirateweatherProvider;
+504
View File
@@ -0,0 +1,504 @@
const Log = require("logger");
const { getSunTimes, isDayTime, validateCoordinates } = require("../provider-utils");
const HTTPFetcher = require("#http_fetcher");
/**
* Server-side weather provider for SMHI (Swedish Meteorological and Hydrological Institute)
* Sweden only, metric system
*
* API: SNOW1gv1 — https://opendata.smhi.se/metfcst/snow1gv1
* Migrated from PMP3gv2 (deprecated 2026-03-31, returns HTTP 404)
*
* Version: 2.0.1 (2026-04-02)
*
* Key differences from PMP3gv2:
* - URL: snow1g/version/1 (was pmp3g/version/2)
* - Time key: "time" (was "validTime")
* - Data structure: flat object entry.data.X (was parameters[].find().values[0])
* - Parameter names: human-readable (air_temperature, wind_speed, etc.)
* - Coordinates: flat [lon, lat] (was nested [[lon, lat]])
* - Precipitation types: different value mapping (1=rain, not snow)
*/
/**
* Maps user-facing config precipitationValue to SNOW1gv1 parameter names.
* Maintains backward compatibility with existing MagicMirror configs.
*/
const PRECIP_VALUE_MAP = {
pmin: "precipitation_amount_min",
pmean: "precipitation_amount_mean",
pmedian: "precipitation_amount_median",
pmax: "precipitation_amount_max"
};
class SMHIProvider {
constructor (config) {
this.config = {
lat: 0,
lon: 0,
precipitationValue: "pmedian", // pmin, pmean, pmedian, pmax
type: "current",
updateInterval: 5 * 60 * 1000,
...config
};
// Validate precipitationValue
if (!Object.keys(PRECIP_VALUE_MAP).includes(this.config.precipitationValue)) {
Log.warn(`[smhi] Invalid precipitationValue: ${this.config.precipitationValue}, using pmedian`);
this.config.precipitationValue = "pmedian";
}
this.fetcher = null;
this.onDataCallback = null;
this.onErrorCallback = null;
}
initialize () {
try {
// SMHI requires max 6 decimal places
validateCoordinates(this.config, 6);
this.#initializeFetcher();
} catch (error) {
Log.error("[smhi] Initialization failed:", error);
if (this.onErrorCallback) {
this.onErrorCallback({
message: error.message,
translationKey: "MODULE_ERROR_UNSPECIFIED"
});
}
}
}
setCallbacks (onData, onError) {
this.onDataCallback = onData;
this.onErrorCallback = onError;
}
start () {
if (this.fetcher) {
this.fetcher.startPeriodicFetch();
}
}
stop () {
if (this.fetcher) {
this.fetcher.clearTimer();
}
}
#initializeFetcher () {
const url = this.#getUrl();
this.fetcher = new HTTPFetcher(url, {
reloadInterval: this.config.updateInterval,
logContext: "weatherprovider.smhi"
});
this.fetcher.on("response", async (response) => {
if (response.status === 304) return;
try {
const data = await response.json();
this.#handleResponse(data);
} catch (error) {
Log.error("[smhi] Failed to parse JSON:", error);
if (this.onErrorCallback) {
this.onErrorCallback({
message: "Failed to parse API response",
translationKey: "MODULE_ERROR_UNSPECIFIED"
});
}
}
});
this.fetcher.on("error", (errorInfo) => {
if (this.onErrorCallback) {
this.onErrorCallback(errorInfo);
}
});
}
#handleResponse (data) {
try {
if (!data.timeSeries || !Array.isArray(data.timeSeries)) {
throw new Error("Invalid weather data");
}
const coordinates = this.#resolveCoordinates(data);
let weatherData;
switch (this.config.type) {
case "current":
weatherData = this.#generateCurrentWeather(data.timeSeries, coordinates);
break;
case "forecast":
case "daily":
weatherData = this.#generateForecast(data.timeSeries, coordinates);
break;
case "hourly":
weatherData = this.#generateHourly(data.timeSeries, coordinates);
break;
default:
Log.error(`[smhi] Unknown weather type: ${this.config.type}`);
if (this.onErrorCallback) {
this.onErrorCallback({
message: `Unknown weather type: ${this.config.type}`,
translationKey: "MODULE_ERROR_UNSPECIFIED"
});
}
return;
}
if (this.onDataCallback) {
this.onDataCallback(weatherData);
}
} catch (error) {
Log.error("[smhi] Error processing weather data:", error);
if (this.onErrorCallback) {
this.onErrorCallback({
message: error.message,
translationKey: "MODULE_ERROR_UNSPECIFIED"
});
}
}
}
#generateCurrentWeather (timeSeries, coordinates) {
const closest = this.#getClosestToCurrentTime(timeSeries);
return this.#convertWeatherDataToObject(closest, coordinates);
}
#generateForecast (timeSeries, coordinates) {
const filled = this.#fillInGaps(timeSeries);
return this.#convertWeatherDataGroupedBy(filled, coordinates, "day");
}
#generateHourly (timeSeries, coordinates) {
const filled = this.#fillInGaps(timeSeries);
return this.#convertWeatherDataGroupedBy(filled, coordinates, "hour");
}
/**
* Find the time series entry closest to the current time.
* SNOW1gv1 uses "time" instead of PMP3gv2's "validTime".
* @param {Array<object>} times - Array of SNOW1gv1 time series entries.
* @returns {object} The time series entry closest to the current time.
*/
#getClosestToCurrentTime (times) {
const now = new Date();
let minDiff = null;
let closest = times[0];
for (const time of times) {
const entryTime = new Date(time.time);
const diff = Math.abs(entryTime - now);
if (minDiff === null || diff < minDiff) {
minDiff = diff;
closest = time;
}
}
return closest;
}
/**
* Convert a single SNOW1gv1 time series entry to MagicMirror weather object.
*
* SNOW1gv1 data structure: entry.data.parameter_name (flat object)
* PMP3gv2 used: entry.parameters[{name, values}] (array of objects)
* @param {object} weatherData - A single SNOW1gv1 time series entry.
* @param {object} coordinates - Object with lat and lon properties.
* @returns {object} MagicMirror-formatted weather data object.
*/
#convertWeatherDataToObject (weatherData, coordinates) {
const date = new Date(weatherData.time);
const { sunrise, sunset } = getSunTimes(date, coordinates.lat, coordinates.lon);
const isDay = isDayTime(date, sunrise, sunset);
const current = {
date: date,
humidity: this.#paramValue(weatherData, "relative_humidity"),
temperature: this.#paramValue(weatherData, "air_temperature"),
windSpeed: this.#paramValue(weatherData, "wind_speed"),
windFromDirection: this.#paramValue(weatherData, "wind_from_direction"),
weatherType: this.#convertWeatherType(this.#paramValue(weatherData, "symbol_code"), isDay),
feelsLikeTemp: this.#calculateApparentTemperature(weatherData),
sunrise: sunrise,
sunset: sunset,
snow: 0,
rain: 0,
precipitationAmount: 0
};
// Map user config (pmedian/pmean/pmin/pmax) to SNOW1gv1 parameter name
const precipParamName = PRECIP_VALUE_MAP[this.config.precipitationValue];
const precipitationValue = this.#paramValue(weatherData, precipParamName);
const pcat = this.#paramValue(weatherData, "predominant_precipitation_type_at_surface");
// SNOW1gv1 precipitation type mapping (differs from PMP3gv2!):
// 0 = no precipitation
// 1 = rain
// 2 = sleet (snow + rain mix)
// 5 = snow / freezing rain
// 6 = freezing mixed precipitation
// 11 = drizzle / light rain
switch (pcat) {
case 1: // Rain
case 11: // Drizzle / light rain
current.rain = precipitationValue;
current.precipitationAmount = precipitationValue;
break;
case 2: // Sleet / mixed rain and snow
current.snow = precipitationValue / 2;
current.rain = precipitationValue / 2;
current.precipitationAmount = precipitationValue;
break;
case 5: // Snow / freezing rain
case 6: // Freezing mixed precipitation
current.snow = precipitationValue;
current.precipitationAmount = precipitationValue;
break;
case 0:
default:
break;
}
return current;
}
#convertWeatherDataGroupedBy (allWeatherData, coordinates, groupBy = "day") {
const result = [];
let currentWeather = null;
let dayWeatherTypes = [];
const allWeatherObjects = allWeatherData.map((data) => this.#convertWeatherDataToObject(data, coordinates));
for (const weatherObject of allWeatherObjects) {
const objDate = new Date(weatherObject.date);
// Check if we need a new group (day or hour change)
const needNewGroup = !currentWeather || !this.#isSamePeriod(currentWeather.date, objDate, groupBy);
if (needNewGroup) {
currentWeather = {
date: objDate,
temperature: weatherObject.temperature,
minTemperature: Infinity,
maxTemperature: -Infinity,
snow: 0,
rain: 0,
precipitationAmount: 0,
sunrise: weatherObject.sunrise,
sunset: weatherObject.sunset
};
dayWeatherTypes = [];
result.push(currentWeather);
}
// Track weather types during daytime
const { sunrise: daySunrise, sunset: daySunset } = getSunTimes(objDate, coordinates.lat, coordinates.lon);
const isDay = isDayTime(objDate, daySunrise, daySunset);
if (isDay) {
dayWeatherTypes.push(weatherObject.weatherType);
}
// Use median weather type from daytime hours
if (dayWeatherTypes.length > 0) {
currentWeather.weatherType = dayWeatherTypes[Math.floor(dayWeatherTypes.length / 2)];
} else {
currentWeather.weatherType = weatherObject.weatherType;
}
// Aggregate min/max and precipitation
currentWeather.minTemperature = Math.min(currentWeather.minTemperature, weatherObject.temperature);
currentWeather.maxTemperature = Math.max(currentWeather.maxTemperature, weatherObject.temperature);
currentWeather.snow += weatherObject.snow;
currentWeather.rain += weatherObject.rain;
currentWeather.precipitationAmount += weatherObject.precipitationAmount;
}
return result;
}
#isSamePeriod (date1, date2, groupBy) {
if (groupBy === "hour") {
return date1.getFullYear() === date2.getFullYear()
&& date1.getMonth() === date2.getMonth()
&& date1.getDate() === date2.getDate()
&& date1.getHours() === date2.getHours();
} else { // day
return date1.getFullYear() === date2.getFullYear()
&& date1.getMonth() === date2.getMonth()
&& date1.getDate() === date2.getDate();
}
}
/**
* Fill gaps in time series data for forecast/hourly grouping.
* SNOW1gv1 has variable time steps: 1h (0-48h), 2h (49-72h), 6h (73-132h), 12h (133h+).
* Uses "time" key instead of PMP3gv2's "validTime".
* @param {Array<object>} data - Array of SNOW1gv1 time series entries.
* @returns {Array<object>} Time series with hourly gaps filled using previous entry data.
*/
#fillInGaps (data) {
if (data.length === 0) return [];
const result = [];
result.push(data[0]);
for (let i = 1; i < data.length; i++) {
const from = new Date(data[i - 1].time);
const to = new Date(data[i].time);
const hours = Math.floor((to - from) / (1000 * 60 * 60));
// Fill gaps with previous data point (start at j=1 since j=0 is already pushed)
for (let j = 1; j < hours; j++) {
const current = { ...data[i - 1] };
const newTime = new Date(from);
newTime.setHours(from.getHours() + j);
current.time = newTime.toISOString();
result.push(current);
}
// Push original data point
result.push(data[i]);
}
return result;
}
/**
* Extract coordinates from SNOW1gv1 response.
* SNOW1gv1 returns flat GeoJSON Point: { coordinates: [lon, lat] }
* PMP3gv2 returned nested: { coordinates: [[lon, lat]] }
* @param {object} data - The full SNOW1gv1 API response object.
* @returns {object} Object with lat and lon properties.
*/
#resolveCoordinates (data) {
const coords = data?.geometry?.coordinates;
if (Array.isArray(coords) && coords.length >= 2 && typeof coords[0] === "number") {
// SNOW1gv1 flat format: [lon, lat]
return {
lat: coords[1],
lon: coords[0]
};
}
Log.warn("[smhi] Invalid coordinate structure in response, using config values");
return {
lat: this.config.lat,
lon: this.config.lon
};
}
/**
* Calculate apparent (feels-like) temperature using humidity and wind.
* Uses SNOW1gv1 parameter names.
* @param {object} weatherData - A single SNOW1gv1 time series entry.
* @returns {number|null} Apparent temperature in °C, or raw temperature if data is missing.
*/
#calculateApparentTemperature (weatherData) {
const Ta = this.#paramValue(weatherData, "air_temperature");
const rh = this.#paramValue(weatherData, "relative_humidity");
const ws = this.#paramValue(weatherData, "wind_speed");
if (Ta === null || rh === null || ws === null) {
return Ta; // Fallback to raw temperature if data missing
}
const p = (rh / 100) * 6.105 * Math.exp((17.27 * Ta) / (237.7 + Ta));
return Ta + 0.33 * p - 0.7 * ws - 4;
}
/**
* Get parameter value from SNOW1gv1 flat data structure.
* SNOW1gv1: weatherData.data.parameter_name (direct property access)
* PMP3gv2 used: weatherData.parameters.find(p => p.name === name).values[0]
*
* Returns null if parameter missing or equals SMHI missing value (9999).
* @param {object} weatherData - A single SNOW1gv1 time series entry.
* @param {string} name - The SNOW1gv1 parameter name to look up.
* @returns {number|null} The parameter value, or null if missing.
*/
#paramValue (weatherData, name) {
const value = weatherData.data?.[name];
if (value === undefined || value === null) {
return null;
}
// SMHI uses 9999 as missing value sentinel for all parameters
if (value === 9999) {
return null;
}
return value;
}
/**
* Convert SMHI symbol_code (1-27) to MagicMirror weather icon names.
* Symbol codes are identical between PMP3gv2 and SNOW1gv1.
* @param {number} input - SMHI symbol_code value (1-27).
* @param {boolean} isDayTime - Whether the current time is during daytime.
* @returns {string|null} MagicMirror weather icon name, or null if unknown.
*/
#convertWeatherType (input, isDayTime) {
switch (input) {
case 1:
return isDayTime ? "day-sunny" : "night-clear"; // Clear sky
case 2:
return isDayTime ? "day-sunny-overcast" : "night-partly-cloudy"; // Nearly clear sky
case 3:
case 4:
return isDayTime ? "day-cloudy" : "night-cloudy"; // Variable/halfclear cloudiness
case 5:
case 6:
return "cloudy"; // Cloudy/overcast
case 7:
return "fog";
case 8:
case 9:
case 10:
return "showers"; // Light/moderate/heavy rain showers
case 11:
case 21:
return "thunderstorm";
case 12:
case 13:
case 14:
case 22:
case 23:
case 24:
return "sleet"; // Light/moderate/heavy sleet (showers)
case 15:
case 16:
case 17:
case 25:
case 26:
case 27:
return "snow"; // Light/moderate/heavy snow (showers/fall)
case 18:
case 19:
case 20:
return "rain"; // Light/moderate/heavy rain
default:
return null;
}
}
/**
* Build SNOW1gv1 forecast URL.
* Changed from: pmp3g/version/2
* Changed to: snow1g/version/1
* @returns {string} The full SNOW1gv1 API URL for the configured coordinates.
*/
#getUrl () {
const lon = this.config.lon.toFixed(6);
const lat = this.config.lat.toFixed(6);
return `https://opendata-download-metfcst.smhi.se/api/category/snow1g/version/1/geotype/point/lon/${lon}/lat/${lat}/data.json`;
}
}
module.exports = SMHIProvider;
@@ -0,0 +1,330 @@
const Log = require("logger");
const { getSunTimes } = require("../provider-utils");
const HTTPFetcher = require("#http_fetcher");
/**
* UK Met Office Data Hub provider
* For more information: https://www.metoffice.gov.uk/services/data/datapoint/notifications/weather-datahub
*
* Data available:
* - Hourly data for next 2 days (for current weather)
* - 3-hourly data for next 7 days (for hourly forecasts)
* - Daily data for next 7 days (for daily forecasts)
*
* Free accounts limited to 360 requests/day per service (once every 4 minutes)
*/
class UkMetOfficeDataHubProvider {
constructor (config) {
this.config = {
apiBase: "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/",
apiKey: "",
lat: 0,
lon: 0,
type: "current",
updateInterval: 10 * 60 * 1000,
...config
};
this.fetcher = null;
this.onDataCallback = null;
this.onErrorCallback = null;
}
setCallbacks (onDataCallback, onErrorCallback) {
this.onDataCallback = onDataCallback;
this.onErrorCallback = onErrorCallback;
}
initialize () {
if (!this.config.apiKey || this.config.apiKey === "YOUR_API_KEY_HERE") {
Log.error("[ukmetofficedatahub] No API key configured");
if (this.onErrorCallback) {
this.onErrorCallback({
message: "UK Met Office DataHub API key required. Get one at https://datahub.metoffice.gov.uk/",
translationKey: "MODULE_ERROR_UNSPECIFIED"
});
}
return;
}
this.#initializeFetcher();
}
#initializeFetcher () {
const forecastType = this.#getForecastType();
const url = this.#getUrl(forecastType);
this.fetcher = new HTTPFetcher(url, {
reloadInterval: this.config.updateInterval,
headers: {
Accept: "application/json",
apikey: this.config.apiKey
},
logContext: "weatherprovider.ukmetofficedatahub"
});
this.fetcher.on("response", async (response) => {
if (response.status === 304) return;
try {
const data = await response.json();
this.#handleResponse(data);
} catch (error) {
Log.error("[ukmetofficedatahub] Parse error:", error);
if (this.onErrorCallback) {
this.onErrorCallback({
message: "Failed to parse API response",
translationKey: "MODULE_ERROR_UNSPECIFIED"
});
}
}
});
this.fetcher.on("error", (errorInfo) => {
if (this.onErrorCallback) {
this.onErrorCallback(errorInfo);
}
});
}
#getForecastType () {
switch (this.config.type) {
case "hourly":
return "three-hourly";
case "forecast":
case "daily":
return "daily";
case "current":
default:
return "hourly";
}
}
#getUrl (forecastType) {
const base = this.config.apiBase.endsWith("/") ? this.config.apiBase : `${this.config.apiBase}/`;
const queryStrings = `?latitude=${this.config.lat}&longitude=${this.config.lon}&includeLocationName=true`;
return `${base}${forecastType}${queryStrings}`;
}
#handleResponse (data) {
if (!data || !data.features || !data.features[0] || !data.features[0].properties || !data.features[0].properties.timeSeries || data.features[0].properties.timeSeries.length === 0) {
Log.error("[ukmetofficedatahub] No usable data received");
if (this.onErrorCallback) {
this.onErrorCallback({
message: "No usable data in API response",
translationKey: "MODULE_ERROR_UNSPECIFIED"
});
}
return;
}
let weatherData;
switch (this.config.type) {
case "current":
weatherData = this.#generateCurrent(data);
break;
case "forecast":
case "daily":
weatherData = this.#generateDaily(data);
break;
case "hourly":
weatherData = this.#generateHourly(data);
break;
default:
Log.error(`[ukmetofficedatahub] Unknown weather type: ${this.config.type}`);
if (this.onErrorCallback) {
this.onErrorCallback({
message: `Unknown weather type: ${this.config.type}`,
translationKey: "MODULE_ERROR_UNSPECIFIED"
});
}
return;
}
if (weatherData && this.onDataCallback) {
this.onDataCallback(weatherData);
}
}
#generateCurrent (data) {
const timeSeries = data.features[0].properties.timeSeries;
const now = new Date();
// Find the hour that contains current time
for (const hour of timeSeries) {
const forecastTime = new Date(hour.time);
const oneHourLater = new Date(forecastTime.getTime() + 60 * 60 * 1000);
if (now >= forecastTime && now < oneHourLater) {
const current = {
date: forecastTime,
temperature: hour.screenTemperature || null,
minTemperature: hour.minScreenAirTemp || null,
maxTemperature: hour.maxScreenAirTemp || null,
windSpeed: hour.windSpeed10m || null,
windFromDirection: hour.windDirectionFrom10m || null,
weatherType: this.#convertWeatherType(hour.significantWeatherCode),
humidity: hour.screenRelativeHumidity || null,
rain: hour.totalPrecipAmount || 0,
snow: hour.totalSnowAmount || 0,
precipitationAmount: (hour.totalPrecipAmount || 0) + (hour.totalSnowAmount || 0),
precipitationProbability: hour.probOfPrecipitation || null,
feelsLikeTemp: hour.feelsLikeTemperature || null,
sunrise: null,
sunset: null
};
// Calculate sunrise/sunset using SunCalc
const { sunrise, sunset } = getSunTimes(now, this.config.lat, this.config.lon);
current.sunrise = sunrise;
current.sunset = sunset;
return current;
}
}
// Fallback to first hour if no match found
const firstHour = timeSeries[0];
const current = {
date: new Date(firstHour.time),
temperature: firstHour.screenTemperature || null,
windSpeed: firstHour.windSpeed10m || null,
windFromDirection: firstHour.windDirectionFrom10m || null,
weatherType: this.#convertWeatherType(firstHour.significantWeatherCode),
humidity: firstHour.screenRelativeHumidity || null,
rain: firstHour.totalPrecipAmount || 0,
snow: firstHour.totalSnowAmount || 0,
precipitationAmount: (firstHour.totalPrecipAmount || 0) + (firstHour.totalSnowAmount || 0),
precipitationProbability: firstHour.probOfPrecipitation || null,
feelsLikeTemp: firstHour.feelsLikeTemperature || null,
sunrise: null,
sunset: null
};
const { sunrise, sunset } = getSunTimes(now, this.config.lat, this.config.lon);
current.sunrise = sunrise;
current.sunset = sunset;
return current;
}
#generateDaily (data) {
const timeSeries = data.features[0].properties.timeSeries;
const days = [];
const today = new Date();
today.setHours(0, 0, 0, 0);
for (const day of timeSeries) {
const forecastDate = new Date(day.time);
forecastDate.setHours(0, 0, 0, 0);
// Only include today and future days
if (forecastDate >= today) {
days.push({
date: new Date(day.time),
minTemperature: day.nightMinScreenTemperature || null,
maxTemperature: day.dayMaxScreenTemperature || null,
temperature: day.dayMaxScreenTemperature || null,
windSpeed: day.midday10MWindSpeed || null,
windFromDirection: day.midday10MWindDirection || null,
weatherType: this.#convertWeatherType(day.daySignificantWeatherCode),
humidity: day.middayRelativeHumidity || null,
rain: day.dayProbabilityOfRain || 0,
snow: day.dayProbabilityOfSnow || 0,
precipitationAmount: 0,
precipitationProbability: day.dayProbabilityOfPrecipitation || null,
feelsLikeTemp: day.dayMaxFeelsLikeTemp || null
});
}
}
return days;
}
#generateHourly (data) {
const timeSeries = data.features[0].properties.timeSeries;
const hours = [];
for (const hour of timeSeries) {
// 3-hourly data uses maxScreenAirTemp/minScreenAirTemp, not screenTemperature
const temp = hour.screenTemperature !== undefined
? hour.screenTemperature
: (hour.maxScreenAirTemp !== undefined && hour.minScreenAirTemp !== undefined)
? (hour.maxScreenAirTemp + hour.minScreenAirTemp) / 2
: null;
hours.push({
date: new Date(hour.time),
temperature: temp,
windSpeed: hour.windSpeed10m || null,
windFromDirection: hour.windDirectionFrom10m || null,
weatherType: this.#convertWeatherType(hour.significantWeatherCode),
humidity: hour.screenRelativeHumidity || null,
rain: hour.totalPrecipAmount || 0,
snow: hour.totalSnowAmount || 0,
precipitationAmount: (hour.totalPrecipAmount || 0) + (hour.totalSnowAmount || 0),
precipitationProbability: hour.probOfPrecipitation || null,
feelsLikeTemp: hour.feelsLikeTemp || null
});
}
return hours;
}
/**
* Convert Met Office significant weather code to weathericons.css icon
* See: https://metoffice.apiconnect.ibmcloud.com/metoffice/production/node/264
* @param {number} weatherType - Met Office weather code
* @returns {string|null} Weathericons.css icon name or null
*/
#convertWeatherType (weatherType) {
const weatherTypes = {
0: "night-clear",
1: "day-sunny",
2: "night-alt-cloudy",
3: "day-cloudy",
5: "fog",
6: "fog",
7: "cloudy",
8: "cloud",
9: "night-sprinkle",
10: "day-sprinkle",
11: "raindrops",
12: "sprinkle",
13: "night-alt-showers",
14: "day-showers",
15: "rain",
16: "night-alt-sleet",
17: "day-sleet",
18: "sleet",
19: "night-alt-hail",
20: "day-hail",
21: "hail",
22: "night-alt-snow",
23: "day-snow",
24: "snow",
25: "night-alt-snow",
26: "day-snow",
27: "snow",
28: "night-alt-thunderstorm",
29: "day-thunderstorm",
30: "thunderstorm"
};
return weatherTypes[weatherType] || null;
}
start () {
if (this.fetcher) {
this.fetcher.startPeriodicFetch();
}
}
stop () {
if (this.fetcher) {
this.fetcher.clearTimer();
}
}
}
module.exports = UkMetOfficeDataHubProvider;
@@ -0,0 +1,490 @@
const Log = require("logger");
const { convertKmhToMs, cardinalToDegrees } = require("../provider-utils");
const HTTPFetcher = require("#http_fetcher");
const WEATHER_API_BASE = "https://api.weatherapi.com/v1";
class WeatherAPIProvider {
constructor (config) {
this.config = {
apiBase: WEATHER_API_BASE,
lat: 0,
lon: 0,
type: "current",
apiKey: "",
lang: "en",
maxEntries: 5,
maxNumberOfDays: 5,
updateInterval: 10 * 60 * 1000,
...config
};
this.locationName = null;
this.fetcher = null;
this.onDataCallback = null;
this.onErrorCallback = null;
}
initialize () {
this.#validateConfig();
this.#initializeFetcher();
}
setCallbacks (onData, onError) {
this.onDataCallback = onData;
this.onErrorCallback = onError;
}
start () {
if (this.fetcher) {
this.fetcher.startPeriodicFetch();
}
}
stop () {
if (this.fetcher) {
this.fetcher.clearTimer();
}
}
#validateConfig () {
this.config.type = `${this.config.type ?? ""}`.trim().toLowerCase();
if (this.config.type === "forecast") {
this.config.type = "daily";
}
if (!["hourly", "daily", "current"].includes(this.config.type)) {
throw new Error(`Unknown weather type: ${this.config.type}`);
}
if (!this.config.apiKey || `${this.config.apiKey}`.trim() === "") {
throw new Error("apiKey is required");
}
if (!Number.isFinite(this.config.lat) || !Number.isFinite(this.config.lon)) {
throw new Error("Latitude and longitude are required");
}
}
#initializeFetcher () {
const url = this.#getUrl();
this.fetcher = new HTTPFetcher(url, {
reloadInterval: this.config.updateInterval,
headers: { "Cache-Control": "no-cache" },
logContext: "weatherprovider.weatherapi"
});
this.fetcher.on("response", async (response) => {
try {
const data = await response.json();
this.#handleResponse(data);
} catch (error) {
Log.error("[weatherapi] Failed to parse JSON:", error);
if (this.onErrorCallback) {
this.onErrorCallback({
message: "Failed to parse API response",
translationKey: "MODULE_ERROR_UNSPECIFIED"
});
}
}
});
this.fetcher.on("error", (errorInfo) => {
if (this.onErrorCallback) {
this.onErrorCallback(errorInfo);
}
});
}
#handleResponse (data) {
let parsedData;
try {
parsedData = this.#parseResponse(data);
} catch (error) {
Log.error("[weatherapi] Invalid API response:", error);
if (this.onErrorCallback) {
this.onErrorCallback({
message: "Invalid API response",
translationKey: "MODULE_ERROR_UNSPECIFIED"
});
}
return;
}
try {
let weatherData;
switch (this.config.type) {
case "current":
weatherData = this.#generateCurrent(parsedData);
break;
case "daily":
weatherData = this.#generateDaily(parsedData);
break;
case "hourly":
weatherData = this.#generateHourly(parsedData);
break;
default:
throw new Error(`Unknown weather type: ${this.config.type}`);
}
if (this.onDataCallback && weatherData) {
this.onDataCallback(weatherData);
}
} catch (error) {
Log.error("[weatherapi] Error processing weather data:", error);
if (this.onErrorCallback) {
this.onErrorCallback({
message: error.message,
translationKey: "MODULE_ERROR_UNSPECIFIED"
});
}
}
}
#getQueryParameters () {
const maxEntries = Number.isFinite(this.config.maxEntries)
? Math.max(1, this.config.maxEntries)
: 5;
const requestedDays = Number.isFinite(this.config.maxNumberOfDays)
? Math.max(1, this.config.maxNumberOfDays)
: 5;
const hourlyDays = Math.max(1, Math.ceil(maxEntries / 24));
const days = this.config.type === "hourly"
? Math.min(14, Math.max(requestedDays, hourlyDays))
: this.config.type === "daily"
? Math.min(14, requestedDays)
: 1;
const params = {
q: `${this.config.lat},${this.config.lon}`,
days,
lang: this.config.lang,
key: this.config.apiKey
};
return Object.keys(params)
.filter((key) => params[key] !== undefined && params[key] !== null && `${params[key]}`.trim() !== "")
.map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`)
.join("&");
}
#getUrl () {
return `${this.config.apiBase}/forecast.json?${this.#getQueryParameters()}`;
}
#parseResponse (responseData) {
responseData.location ??= {};
responseData.current ??= {};
responseData.current.condition ??= {};
responseData.forecast ??= {};
responseData.forecast.forecastday ??= [];
responseData.forecast.forecastday = responseData.forecast.forecastday.map((forecastDay) => ({
...forecastDay,
astro: forecastDay.astro ?? {},
day: forecastDay.day ?? {},
hour: forecastDay.hour ?? []
}));
const locationParts = [
responseData.location.name,
responseData.location.region,
responseData.location.country
]
.map((value) => `${value}`.trim())
.filter((value) => value !== "");
if (locationParts.length > 0) {
this.locationName = locationParts.join(", ").trim();
}
if (
!responseData.location
|| !responseData.current
|| !responseData.forecast
|| !Array.isArray(responseData.forecast.forecastday)
) {
throw new Error("Invalid API response");
}
return responseData;
}
#parseSunDatetime (forecastDay, key) {
const timeValue = forecastDay?.astro?.[key];
if (!timeValue || !forecastDay?.date) {
return null;
}
const match = (/^\s*(\d{1,2}):(\d{2})\s*(AM|PM)\s*$/i).exec(timeValue);
if (!match) {
return null;
}
let hour = parseInt(match[1], 10);
const minute = parseInt(match[2], 10);
const period = match[3].toUpperCase();
if (period === "PM" && hour !== 12) hour += 12;
if (period === "AM" && hour === 12) hour = 0;
const date = new Date(`${forecastDay.date}T00:00:00`);
date.setHours(hour, minute, 0, 0);
return date;
}
#toNumber (value) {
const number = parseFloat(value);
return Number.isFinite(number) ? number : null;
}
#generateCurrent (data) {
const weather = data.forecast.forecastday[0] ?? {};
const current = data.current ?? {};
const currentWeather = {
date: current.last_updated_epoch ? new Date(current.last_updated_epoch * 1000) : new Date()
};
const humidity = this.#toNumber(current.humidity);
if (humidity !== null) currentWeather.humidity = humidity;
const temperature = this.#toNumber(current.temp_c);
if (temperature !== null) currentWeather.temperature = temperature;
const feelsLikeTemp = this.#toNumber(current.feelslike_c);
if (feelsLikeTemp !== null) currentWeather.feelsLikeTemp = feelsLikeTemp;
const windSpeed = this.#toNumber(current.wind_kph);
if (windSpeed !== null) currentWeather.windSpeed = convertKmhToMs(windSpeed);
const windFromDirection = this.#toNumber(current.wind_degree);
if (windFromDirection !== null) currentWeather.windFromDirection = windFromDirection;
if (current.condition?.code !== undefined) {
currentWeather.weatherType = this.#convertWeatherType(current.condition.code, current.is_day === 1);
}
const sunrise = this.#parseSunDatetime(weather, "sunrise");
const sunset = this.#parseSunDatetime(weather, "sunset");
if (sunrise) currentWeather.sunrise = sunrise;
if (sunset) currentWeather.sunset = sunset;
const minTemperature = this.#toNumber(weather.day?.mintemp_c);
if (minTemperature !== null) currentWeather.minTemperature = minTemperature;
const maxTemperature = this.#toNumber(weather.day?.maxtemp_c);
if (maxTemperature !== null) currentWeather.maxTemperature = maxTemperature;
const snow = this.#toNumber(current.snow_cm);
if (snow !== null) currentWeather.snow = snow * 10;
const rain = this.#toNumber(current.precip_mm);
if (rain !== null) currentWeather.rain = rain;
if (rain !== null || snow !== null) {
currentWeather.precipitationAmount = (rain ?? 0) + ((snow ?? 0) * 10);
}
return currentWeather;
}
#generateDaily (data) {
const days = [];
const forecastDays = data.forecast.forecastday ?? [];
for (const forecastDay of forecastDays) {
const weather = {};
const dayDate = forecastDay.date_epoch
? new Date(forecastDay.date_epoch * 1000)
: new Date(`${forecastDay.date}T00:00:00`);
const precipitationProbability = forecastDay.hour?.length > 0
? (forecastDay.hour.reduce((sum, hourData) => {
const rain = this.#toNumber(hourData.will_it_rain) ?? 0;
const snow = this.#toNumber(hourData.will_it_snow) ?? 0;
return sum + ((rain + snow) / 2);
}, 0) / forecastDay.hour.length) * 100
: null;
const avgWindDegree = forecastDay.hour?.length > 0
? forecastDay.hour.reduce((sum, hourData) => {
return sum + (this.#toNumber(hourData.wind_degree) ?? 0);
}, 0) / forecastDay.hour.length
: null;
weather.date = dayDate;
weather.minTemperature = this.#toNumber(forecastDay.day?.mintemp_c);
weather.maxTemperature = this.#toNumber(forecastDay.day?.maxtemp_c);
weather.weatherType = this.#convertWeatherType(forecastDay.day?.condition?.code, true);
const maxWind = this.#toNumber(forecastDay.day?.maxwind_kph);
if (maxWind !== null) weather.windSpeed = convertKmhToMs(maxWind);
if (avgWindDegree !== null) {
weather.windFromDirection = avgWindDegree;
}
const sunrise = this.#parseSunDatetime(forecastDay, "sunrise");
const sunset = this.#parseSunDatetime(forecastDay, "sunset");
if (sunrise) weather.sunrise = sunrise;
if (sunset) weather.sunset = sunset;
weather.temperature = this.#toNumber(forecastDay.day?.avgtemp_c);
weather.humidity = this.#toNumber(forecastDay.day?.avghumidity);
const snow = this.#toNumber(forecastDay.day?.totalsnow_cm);
if (snow !== null) weather.snow = snow * 10;
const rain = this.#toNumber(forecastDay.day?.totalprecip_mm);
if (rain !== null) weather.rain = rain;
if (rain !== null || snow !== null) {
weather.precipitationAmount = (rain ?? 0) + ((snow ?? 0) * 10);
}
if (precipitationProbability !== null) {
weather.precipitationProbability = precipitationProbability;
}
weather.uvIndex = this.#toNumber(forecastDay.day?.uv);
days.push(weather);
if (days.length >= this.config.maxEntries) {
break;
}
}
return days;
}
#generateHourly (data) {
const hours = [];
const nowStart = new Date();
nowStart.setMinutes(0, 0, 0);
nowStart.setHours(nowStart.getHours() + 1);
for (const forecastDay of data.forecast.forecastday ?? []) {
for (const hourData of forecastDay.hour ?? []) {
const date = hourData.time_epoch
? new Date(hourData.time_epoch * 1000)
: new Date(hourData.time);
if (date < nowStart) {
continue;
}
const weather = { date };
const sunrise = this.#parseSunDatetime(forecastDay, "sunrise");
const sunset = this.#parseSunDatetime(forecastDay, "sunset");
if (sunrise) weather.sunrise = sunrise;
if (sunset) weather.sunset = sunset;
weather.minTemperature = this.#toNumber(forecastDay.day?.mintemp_c);
weather.maxTemperature = this.#toNumber(forecastDay.day?.maxtemp_c);
weather.humidity = this.#toNumber(hourData.humidity);
const windSpeed = this.#toNumber(hourData.wind_kph);
if (windSpeed !== null) weather.windSpeed = convertKmhToMs(windSpeed);
const windDegree = this.#toNumber(hourData.wind_degree);
weather.windFromDirection = windDegree !== null
? windDegree
: cardinalToDegrees(hourData.wind_dir);
weather.weatherType = this.#convertWeatherType(hourData.condition?.code, hourData.is_day === 1);
const snow = this.#toNumber(hourData.snow_cm);
if (snow !== null) weather.snow = snow * 10;
weather.temperature = this.#toNumber(hourData.temp_c);
weather.precipitationAmount = this.#toNumber(hourData.precip_mm);
const willRain = this.#toNumber(hourData.will_it_rain) ?? 0;
const willSnow = this.#toNumber(hourData.will_it_snow) ?? 0;
weather.precipitationProbability = (willRain + willSnow) * 50;
weather.uvIndex = this.#toNumber(hourData.uv);
hours.push(weather);
if (hours.length >= this.config.maxEntries) {
break;
}
}
if (hours.length >= this.config.maxEntries) {
break;
}
}
return hours;
}
#convertWeatherType (weatherCode, isDayTime) {
const weatherConditions = {
1000: { day: "day-sunny", night: "night-clear" },
1003: { day: "day-cloudy", night: "night-alt-cloudy" },
1006: { day: "day-cloudy", night: "night-alt-cloudy" },
1009: { day: "day-sunny-overcast", night: "night-alt-partly-cloudy" },
1030: { day: "day-fog", night: "night-fog" },
1063: { day: "day-sprinkle", night: "night-sprinkle" },
1066: { day: "day-snow-wind", night: "night-snow-wind" },
1069: { day: "day-sleet", night: "night-sleet" },
1072: { day: "day-sprinkle", night: "night-sprinkle" },
1087: { day: "day-thunderstorm", night: "night-thunderstorm" },
1114: { day: "day-snow-wind", night: "night-snow-wind" },
1117: { day: "windy", night: "windy" },
1135: { day: "day-fog", night: "night-fog" },
1147: { day: "day-fog", night: "night-fog" },
1150: { day: "day-sprinkle", night: "night-sprinkle" },
1153: { day: "day-sprinkle", night: "night-sprinkle" },
1168: { day: "day-sprinkle", night: "night-sprinkle" },
1171: { day: "day-sprinkle", night: "night-sprinkle" },
1180: { day: "day-sprinkle", night: "night-sprinkle" },
1183: { day: "day-sprinkle", night: "night-sprinkle" },
1186: { day: "day-showers", night: "night-showers" },
1189: { day: "day-showers", night: "night-showers" },
1192: { day: "day-showers", night: "night-showers" },
1195: { day: "day-showers", night: "night-showers" },
1198: { day: "day-thunderstorm", night: "night-thunderstorm" },
1201: { day: "day-thunderstorm", night: "night-thunderstorm" },
1204: { day: "day-sprinkle", night: "night-sprinkle" },
1207: { day: "day-showers", night: "night-showers" },
1210: { day: "snowflake-cold", night: "snowflake-cold" },
1213: { day: "snowflake-cold", night: "snowflake-cold" },
1216: { day: "snowflake-cold", night: "snowflake-cold" },
1219: { day: "snowflake-cold", night: "snowflake-cold" },
1222: { day: "snowflake-cold", night: "snowflake-cold" },
1225: { day: "snowflake-cold", night: "snowflake-cold" },
1237: { day: "day-sleet", night: "night-sleet" },
1240: { day: "day-sprinkle", night: "night-sprinkle" },
1243: { day: "day-showers", night: "night-showers" },
1246: { day: "day-showers", night: "night-showers" },
1249: { day: "day-showers", night: "night-showers" },
1252: { day: "day-showers", night: "night-showers" },
1255: { day: "day-snow-wind", night: "night-snow-wind" },
1258: { day: "day-snow-wind", night: "night-snow-wind" },
1261: { day: "day-sleet", night: "night-sleet" },
1264: { day: "day-sleet", night: "night-sleet" },
1273: { day: "day-thunderstorm", night: "night-thunderstorm" },
1276: { day: "day-thunderstorm", night: "night-thunderstorm" },
1279: { day: "day-snow-thunderstorm", night: "night-snow-thunderstorm" },
1282: { day: "day-snow-thunderstorm", night: "night-snow-thunderstorm" }
};
if (!Object.prototype.hasOwnProperty.call(weatherConditions, weatherCode)) {
return "na";
}
return weatherConditions[weatherCode][isDayTime ? "day" : "night"];
}
}
module.exports = WeatherAPIProvider;
@@ -0,0 +1,292 @@
const Log = require("logger");
const HTTPFetcher = require("#http_fetcher");
/**
* Weatherbit weather provider
* See: https://www.weatherbit.io/
*/
class WeatherbitProvider {
constructor (config) {
this.config = {
apiBase: "https://api.weatherbit.io/v2.0",
apiKey: "",
lat: 0,
lon: 0,
type: "current",
updateInterval: 10 * 60 * 1000,
...config
};
this.fetcher = null;
this.onDataCallback = null;
this.onErrorCallback = null;
}
setCallbacks (onDataCallback, onErrorCallback) {
this.onDataCallback = onDataCallback;
this.onErrorCallback = onErrorCallback;
}
initialize () {
if (!this.config.apiKey || this.config.apiKey === "YOUR_API_KEY_HERE") {
Log.error("[weatherbit] No API key configured");
if (this.onErrorCallback) {
this.onErrorCallback({
message: "Weatherbit API key required. Get one at https://www.weatherbit.io/",
translationKey: "MODULE_ERROR_UNSPECIFIED"
});
}
return;
}
this.#initializeFetcher();
}
#initializeFetcher () {
const url = this.#getUrl();
this.fetcher = new HTTPFetcher(url, {
reloadInterval: this.config.updateInterval,
headers: {
Accept: "application/json"
},
logContext: "weatherprovider.weatherbit"
});
this.fetcher.on("response", async (response) => {
try {
const data = await response.json();
this.#handleResponse(data);
} catch (error) {
Log.error("[weatherbit] Parse error:", error);
if (this.onErrorCallback) {
this.onErrorCallback({
message: "Failed to parse API response",
translationKey: "MODULE_ERROR_UNSPECIFIED"
});
}
}
});
this.fetcher.on("error", (errorInfo) => {
if (this.onErrorCallback) {
this.onErrorCallback(errorInfo);
}
});
}
#getUrl () {
const endpoint = this.#getWeatherEndpoint();
return `${this.config.apiBase}${endpoint}?lat=${this.config.lat}&lon=${this.config.lon}&units=M&key=${this.config.apiKey}`;
}
#getWeatherEndpoint () {
switch (this.config.type) {
case "hourly":
return "/forecast/hourly";
case "daily":
case "forecast":
return "/forecast/daily";
case "current":
default:
return "/current";
}
}
#handleResponse (data) {
if (!data || !data.data || data.data.length === 0) {
Log.error("[weatherbit] No usable data received");
if (this.onErrorCallback) {
this.onErrorCallback({
message: "No usable data in API response",
translationKey: "MODULE_ERROR_UNSPECIFIED"
});
}
return;
}
let weatherData = null;
switch (this.config.type) {
case "current":
weatherData = this.#generateCurrent(data);
break;
case "forecast":
case "daily":
weatherData = this.#generateDaily(data);
break;
case "hourly":
weatherData = this.#generateHourly(data);
break;
default:
Log.error(`[weatherbit] Unknown weather type: ${this.config.type}`);
break;
}
if (weatherData && this.onDataCallback) {
this.onDataCallback(weatherData);
}
}
#generateCurrent (data) {
if (!data.data[0] || typeof data.data[0].temp === "undefined") {
return null;
}
const current = data.data[0];
const weather = {
date: new Date(current.ts * 1000),
temperature: parseFloat(current.temp),
humidity: parseFloat(current.rh),
windSpeed: parseFloat(current.wind_spd),
windFromDirection: current.wind_dir || null,
weatherType: this.#convertWeatherType(current.weather.icon),
sunrise: null,
sunset: null
};
// Parse sunrise/sunset from HH:mm format (already in local time)
if (current.sunrise) {
const [hours, minutes] = current.sunrise.split(":");
const sunrise = new Date(current.ts * 1000);
sunrise.setHours(parseInt(hours), parseInt(minutes), 0, 0);
weather.sunrise = sunrise;
}
if (current.sunset) {
const [hours, minutes] = current.sunset.split(":");
const sunset = new Date(current.ts * 1000);
sunset.setHours(parseInt(hours), parseInt(minutes), 0, 0);
weather.sunset = sunset;
}
return weather;
}
#generateDaily (data) {
const days = [];
for (const forecast of data.data) {
days.push({
date: new Date(forecast.datetime),
minTemperature: forecast.min_temp !== undefined ? parseFloat(forecast.min_temp) : null,
maxTemperature: forecast.max_temp !== undefined ? parseFloat(forecast.max_temp) : null,
precipitationAmount: forecast.precip !== undefined ? parseFloat(forecast.precip) : 0,
precipitationProbability: forecast.pop !== undefined ? parseFloat(forecast.pop) : null,
weatherType: this.#convertWeatherType(forecast.weather.icon)
});
}
return days;
}
#generateHourly (data) {
const hours = [];
for (const forecast of data.data) {
hours.push({
date: new Date(forecast.timestamp_local),
temperature: forecast.temp !== undefined ? parseFloat(forecast.temp) : null,
precipitationAmount: forecast.precip !== undefined ? parseFloat(forecast.precip) : 0,
precipitationProbability: forecast.pop !== undefined ? parseFloat(forecast.pop) : null,
windSpeed: forecast.wind_spd !== undefined ? parseFloat(forecast.wind_spd) : null,
windFromDirection: forecast.wind_dir || null,
weatherType: this.#convertWeatherType(forecast.weather.icon)
});
}
return hours;
}
/**
* Convert Weatherbit icon codes to weathericons.css icons
* See: https://www.weatherbit.io/api/codes
* @param {string} weatherType - Weatherbit icon code
* @returns {string|null} Weathericons.css icon name or null
*/
#convertWeatherType (weatherType) {
const weatherTypes = {
t01d: "day-thunderstorm",
t01n: "night-alt-thunderstorm",
t02d: "day-thunderstorm",
t02n: "night-alt-thunderstorm",
t03d: "thunderstorm",
t03n: "thunderstorm",
t04d: "day-thunderstorm",
t04n: "night-alt-thunderstorm",
t05d: "day-sleet-storm",
t05n: "night-alt-sleet-storm",
d01d: "day-sprinkle",
d01n: "night-alt-sprinkle",
d02d: "day-sprinkle",
d02n: "night-alt-sprinkle",
d03d: "day-showers",
d03n: "night-alt-showers",
r01d: "day-showers",
r01n: "night-alt-showers",
r02d: "day-rain",
r02n: "night-alt-rain",
r03d: "day-rain",
r03n: "night-alt-rain",
r04d: "day-sprinkle",
r04n: "night-alt-sprinkle",
r05d: "day-showers",
r05n: "night-alt-showers",
r06d: "day-showers",
r06n: "night-alt-showers",
f01d: "day-sleet",
f01n: "night-alt-sleet",
s01d: "day-snow",
s01n: "night-alt-snow",
s02d: "day-snow-wind",
s02n: "night-alt-snow-wind",
s03d: "snowflake-cold",
s03n: "snowflake-cold",
s04d: "day-rain-mix",
s04n: "night-alt-rain-mix",
s05d: "day-sleet",
s05n: "night-alt-sleet",
s06d: "day-snow",
s06n: "night-alt-snow",
a01d: "day-haze",
a01n: "dust",
a02d: "smoke",
a02n: "smoke",
a03d: "day-haze",
a03n: "dust",
a04d: "dust",
a04n: "dust",
a05d: "day-fog",
a05n: "night-fog",
a06d: "fog",
a06n: "fog",
c01d: "day-sunny",
c01n: "night-clear",
c02d: "day-sunny-overcast",
c02n: "night-alt-partly-cloudy",
c03d: "day-cloudy",
c03n: "night-alt-cloudy",
c04d: "cloudy",
c04n: "cloudy",
u00d: "rain-mix",
u00n: "rain-mix"
};
return weatherTypes[weatherType] || null;
}
start () {
if (this.fetcher) {
this.fetcher.startPeriodicFetch();
}
}
stop () {
if (this.fetcher) {
this.fetcher.clearTimer();
}
}
}
module.exports = WeatherbitProvider;
@@ -0,0 +1,302 @@
const Log = require("logger");
const { convertKmhToMs } = require("../provider-utils");
const HTTPFetcher = require("#http_fetcher");
/**
* WeatherFlow weather provider
* This class is a provider for WeatherFlow personal weather stations.
* Note that the WeatherFlow API does not provide snowfall.
*/
class WeatherFlowProvider {
/**
* @param {object} config - Provider configuration
*/
constructor (config) {
this.config = config;
this.fetcher = null;
this.onDataCallback = null;
this.onErrorCallback = null;
}
/**
* Set the callbacks for data and errors
* @param {(data: object) => void} onDataCallback - Called when new data is available
* @param {(error: object) => void} onErrorCallback - Called when an error occurs
*/
setCallbacks (onDataCallback, onErrorCallback) {
this.onDataCallback = onDataCallback;
this.onErrorCallback = onErrorCallback;
}
/**
* Initialize the provider
*/
initialize () {
if (!this.config.token || this.config.token === "YOUR_API_TOKEN_HERE") {
Log.error("[weatherflow] No API token configured. Get one at https://tempestwx.com/");
if (this.onErrorCallback) {
this.onErrorCallback({
message: "WeatherFlow API token required. Get one at https://tempestwx.com/",
translationKey: "MODULE_ERROR_UNSPECIFIED"
});
}
return;
}
if (!this.config.stationid) {
Log.error("[weatherflow] No station ID configured");
if (this.onErrorCallback) {
this.onErrorCallback({
message: "WeatherFlow station ID required",
translationKey: "MODULE_ERROR_UNSPECIFIED"
});
}
return;
}
this.#initializeFetcher();
}
/**
* Initialize the HTTP fetcher
*/
#initializeFetcher () {
const url = this.#getUrl();
this.fetcher = new HTTPFetcher(url, {
reloadInterval: this.config.updateInterval,
headers: {
"Cache-Control": "no-cache",
Accept: "application/json"
},
logContext: "weatherprovider.weatherflow"
});
this.fetcher.on("response", async (response) => {
if (response.status === 304) return;
try {
const data = await response.json();
const processed = this.#processData(data);
this.onDataCallback(processed);
} catch (error) {
Log.error("[weatherflow] Failed to parse JSON:", error);
}
});
this.fetcher.on("error", (errorInfo) => {
// HTTPFetcher already logged the error with logContext
if (this.onErrorCallback) {
this.onErrorCallback(errorInfo);
}
});
}
/**
* Generate the URL for API requests
* @returns {string} The API URL
*/
#getUrl () {
const base = this.config.apiBase || "https://swd.weatherflow.com/swd/rest/";
return `${base}better_forecast?station_id=${this.config.stationid}&units_temp=c&units_wind=kph&units_pressure=mb&units_precip=mm&units_distance=km&token=${this.config.token}`;
}
/**
* Process the raw API data
* @param {object} data - Raw API response
* @returns {object} Processed weather data
*/
#processData (data) {
try {
let weatherData;
if (this.config.type === "current") {
weatherData = this.#generateCurrent(data);
} else if (this.config.type === "hourly") {
weatherData = this.#generateHourly(data);
} else {
weatherData = this.#generateDaily(data);
}
return weatherData;
} catch (error) {
Log.error("[weatherflow] Data processing error:", error);
if (this.onErrorCallback) {
this.onErrorCallback({
message: "Failed to process weather data",
translationKey: "MODULE_ERROR_UNSPECIFIED"
});
}
return null;
}
}
/**
* Generate current weather data
* @param {object} data - API response data
* @returns {object} Current weather object
*/
#generateCurrent (data) {
if (!data || !data.current_conditions || !data.forecast || !Array.isArray(data.forecast.daily) || data.forecast.daily.length === 0) {
Log.error("[weatherflow] Invalid current weather data structure");
return null;
}
const current = data.current_conditions;
const daily = data.forecast.daily[0];
const weather = {
date: new Date(),
humidity: current.relative_humidity ?? null,
temperature: current.air_temperature ?? null,
feelsLikeTemp: current.feels_like ?? null,
windSpeed: current.wind_avg != null ? convertKmhToMs(current.wind_avg) : null,
windFromDirection: current.wind_direction ?? null,
weatherType: this.#convertWeatherType(current.icon),
precipitationAmount: current.precip_accum_local_day ?? null,
precipitationUnits: "mm",
precipitationProbability: current.precip_probability ?? null,
uvIndex: current.uv || null,
sunrise: daily.sunrise ? new Date(daily.sunrise * 1000) : null,
sunset: daily.sunset ? new Date(daily.sunset * 1000) : null
};
return weather;
}
/**
* Generate forecast data
* @param {object} data - API response data
* @returns {Array} Array of forecast objects
*/
#generateDaily (data) {
if (!data || !data.forecast || !Array.isArray(data.forecast.daily) || !Array.isArray(data.forecast.hourly)) {
Log.error("[weatherflow] Invalid forecast data structure");
return [];
}
const days = [];
for (const forecast of data.forecast.daily) {
const weather = {
date: new Date(forecast.day_start_local * 1000),
minTemperature: forecast.air_temp_low ?? null,
maxTemperature: forecast.air_temp_high ?? null,
precipitationProbability: forecast.precip_probability ?? null,
weatherType: this.#convertWeatherType(forecast.icon),
precipitationAmount: 0.0,
precipitationUnits: "mm",
uvIndex: 0
};
// Build UV and precipitation from hourly data
for (const hour of data.forecast.hourly) {
const hourDate = new Date(hour.time * 1000);
const forecastDate = new Date(forecast.day_start_local * 1000);
// Compare year, month, and day to ensure correct matching across month boundaries
if (hourDate.getFullYear() === forecastDate.getFullYear()
&& hourDate.getMonth() === forecastDate.getMonth()
&& hourDate.getDate() === forecastDate.getDate()) {
weather.uvIndex = Math.max(weather.uvIndex, hour.uv ?? 0);
weather.precipitationAmount += hour.precip ?? 0;
} else if (hourDate > forecastDate) {
// Check if we've moved to the next day
const diffMs = hourDate - forecastDate;
if (diffMs >= 86400000) break; // 24 hours in ms
}
}
days.push(weather);
}
return days;
}
/**
* Generate hourly forecast data
* @param {object} data - API response data
* @returns {Array} Array of hourly forecast objects
*/
#generateHourly (data) {
if (!data || !data.forecast || !Array.isArray(data.forecast.hourly)) {
Log.error("[weatherflow] Invalid hourly data structure");
return [];
}
const hours = [];
for (const hour of data.forecast.hourly) {
const weather = {
date: new Date(hour.time * 1000),
temperature: hour.air_temperature ?? null,
feelsLikeTemp: hour.feels_like ?? null,
humidity: hour.relative_humidity ?? null,
windSpeed: hour.wind_avg != null ? convertKmhToMs(hour.wind_avg) : null,
windFromDirection: hour.wind_direction ?? null,
weatherType: this.#convertWeatherType(hour.icon),
precipitationProbability: hour.precip_probability ?? null,
precipitationAmount: hour.precip ?? 0,
precipitationUnits: "mm",
uvIndex: hour.uv || null
};
hours.push(weather);
// WeatherFlow provides 10 days of hourly data, trim to 48 hours
if (hours.length >= 48) break;
}
return hours;
}
/**
* Convert weather icon type
* @param {string} weatherType - WeatherFlow icon code
* @returns {string} Weather icon CSS class
*/
#convertWeatherType (weatherType) {
const weatherTypes = {
"clear-day": "day-sunny",
"clear-night": "night-clear",
cloudy: "cloudy",
foggy: "fog",
"partly-cloudy-day": "day-cloudy",
"partly-cloudy-night": "night-alt-cloudy",
"possibly-rainy-day": "day-rain",
"possibly-rainy-night": "night-alt-rain",
"possibly-sleet-day": "day-sleet",
"possibly-sleet-night": "night-alt-sleet",
"possibly-snow-day": "day-snow",
"possibly-snow-night": "night-alt-snow",
"possibly-thunderstorm-day": "day-thunderstorm",
"possibly-thunderstorm-night": "night-alt-thunderstorm",
rainy: "rain",
sleet: "sleet",
snow: "snow",
thunderstorm: "thunderstorm",
windy: "strong-wind"
};
return weatherTypes[weatherType] || null;
}
/**
* Start fetching data
*/
start () {
if (this.fetcher) {
this.fetcher.startPeriodicFetch();
}
}
/**
* Stop fetching data
*/
stop () {
if (this.fetcher) {
this.fetcher.clearTimer();
}
}
}
module.exports = WeatherFlowProvider;
@@ -0,0 +1,417 @@
const Log = require("logger");
const { getSunTimes, isDayTime, getDateString, convertKmhToMs, cardinalToDegrees } = require("../provider-utils");
const HTTPFetcher = require("#http_fetcher");
/**
* Server-side weather provider for Weather.gov (US National Weather Service)
* Note: Only works for US locations, no API key required
* https://weather-gov.github.io/api/general-faqs
*/
class WeatherGovProvider {
constructor (config) {
this.config = {
apiBase: "https://api.weather.gov/points/",
lat: 0,
lon: 0,
type: "current",
updateInterval: 10 * 60 * 1000,
...config
};
this.fetcher = null;
this.onDataCallback = null;
this.onErrorCallback = null;
this.locationName = null;
this.initRetryCount = 0;
this.initRetryTimer = null;
// Weather.gov specific URLs (fetched during initialization)
this.forecastURL = null;
this.forecastHourlyURL = null;
this.forecastGridDataURL = null;
this.observationStationsURL = null;
this.stationObsURL = null;
}
async initialize () {
// Add small random delay to prevent all instances from starting simultaneously
// This reduces parallel DNS lookups which can cause EAI_AGAIN errors
const staggerDelay = Math.random() * 3000; // 0-3 seconds
await new Promise((resolve) => setTimeout(resolve, staggerDelay));
try {
await this.#fetchWeatherGovURLs();
this.#initializeFetcher();
this.initRetryCount = 0; // Reset on success
} catch (error) {
const errorInfo = this.#categorizeError(error);
Log.error(`[weathergov] Initialization failed: ${errorInfo.message}`);
// Retry on temporary errors (DNS, timeout, network)
if (errorInfo.isRetryable && this.initRetryCount < 5) {
this.initRetryCount++;
const delay = HTTPFetcher.calculateBackoffDelay(this.initRetryCount);
Log.info(`[weathergov] Will retry initialization in ${Math.round(delay / 1000)}s (attempt ${this.initRetryCount}/5)`);
this.initRetryTimer = setTimeout(() => this.initialize(), delay);
} else if (this.onErrorCallback) {
this.onErrorCallback({
message: errorInfo.message,
translationKey: "MODULE_ERROR_UNSPECIFIED"
});
}
}
}
#categorizeError (error) {
const cause = error.cause || error;
const code = cause.code || "";
if (code === "EAI_AGAIN" || code === "ENOTFOUND") {
return {
message: "DNS lookup failed for api.weather.gov - check your internet connection",
isRetryable: true
};
}
if (code === "ETIMEDOUT" || code === "ECONNREFUSED" || code === "ECONNRESET") {
return {
message: `Network error: ${code} - api.weather.gov may be temporarily unavailable`,
isRetryable: true
};
}
if (error.name === "AbortError") {
return {
message: "Request timeout - api.weather.gov is responding slowly",
isRetryable: true
};
}
return {
message: error.message || "Unknown error",
isRetryable: false
};
}
setCallbacks (onData, onError) {
this.onDataCallback = onData;
this.onErrorCallback = onError;
}
start () {
if (this.fetcher) {
this.fetcher.startPeriodicFetch();
}
}
stop () {
if (this.fetcher) {
this.fetcher.clearTimer();
}
if (this.initRetryTimer) {
clearTimeout(this.initRetryTimer);
this.initRetryTimer = null;
}
}
async #fetchWeatherGovURLs () {
// Step 1: Get grid point data
const pointsUrl = `${this.config.apiBase}${this.config.lat},${this.config.lon}`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 120000); // 120 second timeout - DNS can be slow
try {
const pointsResponse = await fetch(pointsUrl, {
signal: controller.signal,
headers: {
"User-Agent": "MagicMirror",
Accept: "application/geo+json"
}
});
if (!pointsResponse.ok) {
throw new Error(`Failed to fetch grid point: HTTP ${pointsResponse.status}`);
}
const pointsData = await pointsResponse.json();
if (!pointsData || !pointsData.properties) {
throw new Error("Invalid grid point data");
}
// Extract location name
const relLoc = pointsData.properties.relativeLocation?.properties;
if (relLoc) {
this.locationName = `${relLoc.city}, ${relLoc.state}`;
}
// Store forecast URLs
this.forecastURL = `${pointsData.properties.forecast}?units=si`;
this.forecastHourlyURL = `${pointsData.properties.forecastHourly}?units=si`;
this.forecastGridDataURL = pointsData.properties.forecastGridData;
this.observationStationsURL = pointsData.properties.observationStations;
// Step 2: Get observation station URL
const stationsResponse = await fetch(this.observationStationsURL, {
signal: controller.signal,
headers: {
"User-Agent": "MagicMirror",
Accept: "application/geo+json"
}
});
if (!stationsResponse.ok) {
throw new Error(`Failed to fetch observation stations: HTTP ${stationsResponse.status}`);
}
const stationsData = await stationsResponse.json();
if (!stationsData || !stationsData.features || stationsData.features.length === 0) {
throw new Error("No observation stations found");
}
this.stationObsURL = `${stationsData.features[0].id}/observations/latest`;
Log.log(`[weathergov] Initialized for ${this.locationName}`);
} finally {
clearTimeout(timeoutId);
}
}
#initializeFetcher () {
let url;
switch (this.config.type) {
case "current":
url = this.stationObsURL;
break;
case "forecast":
case "daily":
url = this.forecastURL;
break;
case "hourly":
url = this.forecastHourlyURL;
break;
default:
url = this.stationObsURL;
}
this.fetcher = new HTTPFetcher(url, {
reloadInterval: this.config.updateInterval,
timeout: 60000, // 60 seconds - weather.gov can be slow
headers: {
"User-Agent": "MagicMirror",
Accept: "application/geo+json",
"Cache-Control": "no-cache"
},
logContext: "weatherprovider.weathergov"
});
this.fetcher.on("response", async (response) => {
if (response.status === 304) return;
try {
const data = await response.json();
this.#handleResponse(data);
} catch (error) {
Log.error("[weathergov] Failed to parse JSON:", error);
if (this.onErrorCallback) {
this.onErrorCallback({
message: "Failed to parse API response",
translationKey: "MODULE_ERROR_UNSPECIFIED"
});
}
}
});
this.fetcher.on("error", (errorInfo) => {
if (this.onErrorCallback) {
this.onErrorCallback(errorInfo);
}
});
}
#handleResponse (data) {
try {
let weatherData;
switch (this.config.type) {
case "current":
if (!data.properties) {
throw new Error("Invalid current weather data");
}
weatherData = this.#generateWeatherObjectFromCurrentWeather(data.properties);
break;
case "forecast":
case "daily":
if (!data.properties || !data.properties.periods) {
throw new Error("Invalid forecast data");
}
weatherData = this.#generateWeatherObjectsFromForecast(data.properties.periods);
break;
case "hourly":
if (!data.properties || !data.properties.periods) {
throw new Error("Invalid hourly data");
}
weatherData = this.#generateWeatherObjectsFromHourly(data.properties.periods);
break;
default:
throw new Error(`Unknown weather type: ${this.config.type}`);
}
if (this.onDataCallback) {
this.onDataCallback(weatherData);
}
} catch (error) {
Log.error("[weathergov] Error processing weather data:", error);
if (this.onErrorCallback) {
this.onErrorCallback({
message: error.message,
translationKey: "MODULE_ERROR_UNSPECIFIED"
});
}
}
}
#generateWeatherObjectFromCurrentWeather (currentWeatherData) {
const current = {};
current.date = new Date(currentWeatherData.timestamp);
current.temperature = currentWeatherData.temperature.value;
current.windSpeed = currentWeatherData.windSpeed.value; // Observations are already in m/s
current.windFromDirection = currentWeatherData.windDirection.value;
current.minTemperature = currentWeatherData.minTemperatureLast24Hours?.value;
current.maxTemperature = currentWeatherData.maxTemperatureLast24Hours?.value;
current.humidity = Math.round(currentWeatherData.relativeHumidity.value);
current.precipitationAmount = currentWeatherData.precipitationLastHour?.value ?? currentWeatherData.precipitationLast3Hours?.value;
// Feels like temperature
if (currentWeatherData.heatIndex.value !== null) {
current.feelsLikeTemp = currentWeatherData.heatIndex.value;
} else if (currentWeatherData.windChill.value !== null) {
current.feelsLikeTemp = currentWeatherData.windChill.value;
} else {
current.feelsLikeTemp = currentWeatherData.temperature.value;
}
// Calculate sunrise/sunset (not provided by weather.gov)
const { sunrise, sunset } = getSunTimes(current.date, this.config.lat, this.config.lon);
current.sunrise = sunrise;
current.sunset = sunset;
// Determine if daytime
const isDay = isDayTime(current.date, current.sunrise, current.sunset);
current.weatherType = this.#convertWeatherType(currentWeatherData.textDescription, isDay);
return current;
}
#generateWeatherObjectsFromForecast (forecasts) {
const days = [];
let minTemp = [];
let maxTemp = [];
let date = "";
let weather = {};
for (const forecast of forecasts) {
const forecastDate = new Date(forecast.startTime);
const dateStr = getDateString(forecastDate);
if (date !== dateStr) {
// New day
if (date !== "") {
weather.minTemperature = Math.min(...minTemp);
weather.maxTemperature = Math.max(...maxTemp);
days.push(weather);
}
weather = {};
minTemp = [];
maxTemp = [];
date = dateStr;
weather.date = forecastDate;
weather.precipitationProbability = forecast.probabilityOfPrecipitation?.value ?? 0;
weather.weatherType = this.#convertWeatherType(forecast.shortForecast, forecast.isDaytime);
}
// Update weather type for daytime hours (8am-5pm)
const hour = forecastDate.getHours();
if (hour >= 8 && hour <= 17) {
weather.weatherType = this.#convertWeatherType(forecast.shortForecast, forecast.isDaytime);
}
minTemp.push(forecast.temperature);
maxTemp.push(forecast.temperature);
}
// Last day
if (date !== "") {
weather.minTemperature = Math.min(...minTemp);
weather.maxTemperature = Math.max(...maxTemp);
days.push(weather);
}
return days;
}
#generateWeatherObjectsFromHourly (forecasts) {
const hours = [];
for (const forecast of forecasts) {
const weather = {};
weather.date = new Date(forecast.startTime);
// Parse wind speed
const windSpeedStr = forecast.windSpeed;
let windSpeed = windSpeedStr;
if (windSpeedStr.includes(" ")) {
windSpeed = windSpeedStr.split(" ")[0];
}
weather.windSpeed = convertKmhToMs(parseFloat(windSpeed));
weather.windFromDirection = cardinalToDegrees(forecast.windDirection);
weather.temperature = forecast.temperature;
weather.precipitationProbability = forecast.probabilityOfPrecipitation?.value ?? 0;
weather.weatherType = this.#convertWeatherType(forecast.shortForecast, forecast.isDaytime);
hours.push(weather);
}
return hours;
}
#convertWeatherType (weatherType, isDaytime) {
// https://w1.weather.gov/xml/current_obs/weather.php
if (weatherType.includes("Cloudy") || weatherType.includes("Partly")) {
return isDaytime ? "day-cloudy" : "night-cloudy";
} else if (weatherType.includes("Overcast")) {
return isDaytime ? "cloudy" : "night-cloudy";
} else if (weatherType.includes("Freezing") || weatherType.includes("Ice")) {
return "rain-mix";
} else if (weatherType.includes("Snow")) {
return isDaytime ? "snow" : "night-snow";
} else if (weatherType.includes("Thunderstorm")) {
return isDaytime ? "thunderstorm" : "night-thunderstorm";
} else if (weatherType.includes("Showers")) {
return isDaytime ? "showers" : "night-showers";
} else if (weatherType.includes("Rain") || weatherType.includes("Drizzle")) {
return isDaytime ? "rain" : "night-rain";
} else if (weatherType.includes("Breezy") || weatherType.includes("Windy")) {
return isDaytime ? "cloudy-windy" : "night-alt-cloudy-windy";
} else if (weatherType.includes("Fair") || weatherType.includes("Clear") || weatherType.includes("Few") || weatherType.includes("Sunny")) {
return isDaytime ? "day-sunny" : "night-clear";
} else if (weatherType.includes("Dust") || weatherType.includes("Sand")) {
return "dust";
} else if (weatherType.includes("Fog")) {
return "fog";
} else if (weatherType.includes("Smoke")) {
return "smoke";
} else if (weatherType.includes("Haze")) {
return "day-haze";
}
return null;
}
}
module.exports = WeatherGovProvider;
+469
View File
@@ -0,0 +1,469 @@
const Log = require("logger");
const { formatTimezoneOffset, getDateString, validateCoordinates } = require("../provider-utils");
const HTTPFetcher = require("#http_fetcher");
/**
* Server-side weather provider for Yr.no (Norwegian Meteorological Institute)
* Terms of service: https://developer.yr.no/doc/TermsOfService/
*
* Note: Minimum update interval is 10 minutes (600000 ms) per API terms
*/
class YrProvider {
constructor (config) {
this.config = {
apiBase: "https://api.met.no/weatherapi",
forecastApiVersion: "2.0",
sunriseApiVersion: "3.0",
altitude: 0,
lat: 0,
lon: 0,
currentForecastHours: 1, // 1, 6 or 12
type: "current",
updateInterval: 10 * 60 * 1000, // 10 minutes minimum
...config
};
// Enforce 10 minute minimum per API terms
if (this.config.updateInterval < 600000) {
Log.warn("[yr] Minimum update interval is 10 minutes (600000 ms). Adjusting configuration.");
this.config.updateInterval = 600000;
}
this.fetcher = null;
this.onDataCallback = null;
this.onErrorCallback = null;
this.locationName = null;
// Cache for sunrise/sunset data
this.stellarData = null;
this.stellarDataDate = null;
// Cache for weather data (If-Modified-Since support)
this.weatherCache = {
data: null,
lastModified: null,
expires: null
};
}
async initialize () {
// Yr.no requires max 4 decimal places
validateCoordinates(this.config, 4);
await this.#fetchStellarData();
this.#initializeFetcher();
}
setCallbacks (onData, onError) {
this.onDataCallback = onData;
this.onErrorCallback = onError;
}
start () {
if (this.fetcher) {
this.fetcher.startPeriodicFetch();
}
}
stop () {
if (this.fetcher) {
this.fetcher.clearTimer();
}
}
async #fetchStellarData () {
const today = getDateString(new Date());
// Check if we already have today's data
if (this.stellarDataDate === today && this.stellarData) {
return;
}
const url = this.#getSunriseUrl();
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
const response = await fetch(url, {
headers: {
"User-Agent": "MagicMirror",
Accept: "application/json"
},
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
Log.warn(`[yr] Could not fetch stellar data: HTTP ${response.status}`);
this.stellarDataDate = today;
} else {
// Parse and store the stellar data
const data = await response.json();
// Transform single-day response into array format expected by #getStellarInfoForDate
if (data && data.properties) {
this.stellarData = [
{
date: data.when.interval[0], // ISO date string
sunrise: data.properties.sunrise,
sunset: data.properties.sunset
}
];
}
this.stellarDataDate = today;
}
} catch (error) {
Log.warn("[yr] Failed to fetch stellar data:", error);
}
}
#initializeFetcher () {
const url = this.#getForecastUrl();
const headers = {
"User-Agent": "MagicMirror",
Accept: "application/json"
};
// Add If-Modified-Since header if we have cached data
if (this.weatherCache.lastModified) {
headers["If-Modified-Since"] = this.weatherCache.lastModified;
}
this.fetcher = new HTTPFetcher(url, {
reloadInterval: this.config.updateInterval,
headers,
logContext: "weatherprovider.yr"
});
this.fetcher.on("response", async (response) => {
try {
// Handle 304 Not Modified - use cached data
if (response.status === 304) {
Log.log("[yr] Data not modified, using cache");
if (this.weatherCache.data) {
this.#handleResponse(this.weatherCache.data, true);
}
return;
}
const data = await response.json();
// Store cache headers
const lastModified = response.headers.get("Last-Modified");
const expires = response.headers.get("Expires");
if (lastModified) {
this.weatherCache.lastModified = lastModified;
}
if (expires) {
this.weatherCache.expires = expires;
}
this.weatherCache.data = data;
// Update headers for next request
if (lastModified && this.fetcher) {
this.fetcher.customHeaders["If-Modified-Since"] = lastModified;
}
this.#handleResponse(data, false);
} catch (error) {
Log.error("[yr] Failed to parse JSON:", error);
if (this.onErrorCallback) {
this.onErrorCallback({
message: "Failed to parse API response",
translationKey: "MODULE_ERROR_UNSPECIFIED"
});
}
}
});
this.fetcher.on("error", (errorInfo) => {
if (this.onErrorCallback) {
this.onErrorCallback(errorInfo);
}
});
}
async #handleResponse (data, fromCache = false) {
try {
if (!data.properties || !data.properties.timeseries) {
throw new Error("Invalid weather data");
}
// Refresh stellar data if needed (new day or using cached weather data)
if (fromCache) {
await this.#fetchStellarData();
}
let weatherData;
switch (this.config.type) {
case "current":
weatherData = this.#generateCurrentWeather(data);
break;
case "forecast":
case "daily":
weatherData = this.#generateForecast(data);
break;
case "hourly":
weatherData = this.#generateHourly(data);
break;
default:
throw new Error(`Unknown weather type: ${this.config.type}`);
}
if (this.onDataCallback) {
this.onDataCallback(weatherData);
}
} catch (error) {
Log.error("[yr] Error processing weather data:", error);
if (this.onErrorCallback) {
this.onErrorCallback({
message: error.message,
translationKey: "MODULE_ERROR_UNSPECIFIED"
});
}
}
}
#generateCurrentWeather (data) {
const now = new Date();
const timeseries = data.properties.timeseries;
// Find closest forecast in the past
let forecast = timeseries[0];
let closestDiff = Math.abs(now - new Date(forecast.time));
for (const entry of timeseries) {
const entryTime = new Date(entry.time);
const diff = now - entryTime;
if (diff > 0 && diff < closestDiff) {
closestDiff = diff;
forecast = entry;
}
}
const forecastXHours = this.#getForecastForXHours(forecast.data);
const stellarInfo = this.#getStellarInfoForDate(new Date(forecast.time));
const current = {};
current.date = new Date(forecast.time);
current.temperature = forecast.data.instant.details.air_temperature;
current.windSpeed = forecast.data.instant.details.wind_speed;
current.windFromDirection = forecast.data.instant.details.wind_from_direction;
current.humidity = forecast.data.instant.details.relative_humidity;
current.weatherType = this.#convertWeatherType(
forecastXHours.summary?.symbol_code,
stellarInfo ? this.#isDayTime(current.date, stellarInfo) : true
);
current.precipitationAmount = forecastXHours.details?.precipitation_amount;
current.precipitationProbability = forecastXHours.details?.probability_of_precipitation;
current.minTemperature = forecastXHours.details?.air_temperature_min;
current.maxTemperature = forecastXHours.details?.air_temperature_max;
if (stellarInfo) {
current.sunrise = new Date(stellarInfo.sunrise.time);
current.sunset = new Date(stellarInfo.sunset.time);
}
return current;
}
#generateForecast (data) {
const timeseries = data.properties.timeseries;
const dailyData = new Map();
// Collect all data points for each day
for (const entry of timeseries) {
const date = new Date(entry.time);
const dateStr = getDateString(date);
if (!dailyData.has(dateStr)) {
dailyData.set(dateStr, {
date: date,
temps: [],
precip: [],
precipProb: [],
symbols: []
});
}
const dayData = dailyData.get(dateStr);
// Collect temperature from instant data
if (entry.data.instant?.details?.air_temperature !== undefined) {
dayData.temps.push(entry.data.instant.details.air_temperature);
}
// Collect data from forecast periods (prefer longer periods to avoid double-counting)
const forecast = entry.data.next_12_hours || entry.data.next_6_hours || entry.data.next_1_hours;
if (forecast) {
if (forecast.details?.precipitation_amount !== undefined) {
dayData.precip.push(forecast.details.precipitation_amount);
}
if (forecast.details?.probability_of_precipitation !== undefined) {
dayData.precipProb.push(forecast.details.probability_of_precipitation);
}
if (forecast.summary?.symbol_code) {
dayData.symbols.push(forecast.summary.symbol_code);
}
}
}
// Convert collected data to forecast objects
const days = [];
for (const data of dailyData.values()) {
const stellarInfo = this.#getStellarInfoForDate(data.date);
const dayData = {
date: data.date,
minTemperature: data.temps.length > 0 ? Math.min(...data.temps) : null,
maxTemperature: data.temps.length > 0 ? Math.max(...data.temps) : null,
precipitationAmount: data.precip.length > 0 ? Math.max(...data.precip) : null,
precipitationProbability: data.precipProb.length > 0 ? Math.max(...data.precipProb) : null,
weatherType: data.symbols.length > 0 ? this.#convertWeatherType(data.symbols[0], true) : null
};
if (stellarInfo) {
dayData.sunrise = new Date(stellarInfo.sunrise.time);
dayData.sunset = new Date(stellarInfo.sunset.time);
}
days.push(dayData);
}
// Sort by date to ensure correct order
return days.sort((a, b) => a.date - b.date);
}
#generateHourly (data) {
const hours = [];
const timeseries = data.properties.timeseries;
for (const entry of timeseries) {
const forecast1h = entry.data.next_1_hours;
if (!forecast1h) continue;
const date = new Date(entry.time);
const stellarInfo = this.#getStellarInfoForDate(date);
const hourly = {
date: date,
temperature: entry.data.instant.details.air_temperature,
windSpeed: entry.data.instant.details.wind_speed,
windFromDirection: entry.data.instant.details.wind_from_direction,
humidity: entry.data.instant.details.relative_humidity,
precipitationAmount: forecast1h.details?.precipitation_amount,
precipitationProbability: forecast1h.details?.probability_of_precipitation,
weatherType: this.#convertWeatherType(
forecast1h.summary?.symbol_code,
stellarInfo ? this.#isDayTime(date, stellarInfo) : true
)
};
hours.push(hourly);
}
return hours;
}
#getForecastForXHours (data) {
const hours = this.config.currentForecastHours;
if (hours === 12 && data.next_12_hours) {
return data.next_12_hours;
} else if (hours === 6 && data.next_6_hours) {
return data.next_6_hours;
} else if (data.next_1_hours) {
return data.next_1_hours;
}
return data.next_6_hours || data.next_12_hours || data.next_1_hours || {};
}
#getStellarInfoForDate (date) {
if (!this.stellarData) return null;
const dateStr = getDateString(date);
for (const day of this.stellarData) {
const dayDate = day.date.split("T")[0];
if (dayDate === dateStr) {
return day;
}
}
return null;
}
#isDayTime (date, stellarInfo) {
if (!stellarInfo || !stellarInfo.sunrise || !stellarInfo.sunset) {
return true;
}
const sunrise = new Date(stellarInfo.sunrise.time);
const sunset = new Date(stellarInfo.sunset.time);
return date >= sunrise && date < sunset;
}
#convertWeatherType (symbolCode, isDayTime) {
if (!symbolCode) return null;
// Yr.no uses symbol codes like "clearsky_day", "partlycloudy_night", etc.
const symbol = symbolCode.replace(/_day|_night/g, "");
const mappings = {
clearsky: isDayTime ? "day-sunny" : "night-clear",
fair: isDayTime ? "day-sunny" : "night-clear",
partlycloudy: isDayTime ? "day-cloudy" : "night-cloudy",
cloudy: "cloudy",
fog: "fog",
lightrainshowers: isDayTime ? "day-showers" : "night-showers",
rainshowers: isDayTime ? "showers" : "night-showers",
heavyrainshowers: isDayTime ? "day-rain" : "night-rain",
lightrain: isDayTime ? "day-sprinkle" : "night-sprinkle",
rain: isDayTime ? "rain" : "night-rain",
heavyrain: isDayTime ? "rain" : "night-rain",
lightsleetshowers: isDayTime ? "day-sleet" : "night-sleet",
sleetshowers: isDayTime ? "sleet" : "night-sleet",
heavysleetshowers: isDayTime ? "sleet" : "night-sleet",
lightsleet: isDayTime ? "day-sleet" : "night-sleet",
sleet: "sleet",
heavysleet: "sleet",
lightsnowshowers: isDayTime ? "day-snow" : "night-snow",
snowshowers: isDayTime ? "snow" : "night-snow",
heavysnowshowers: isDayTime ? "snow" : "night-snow",
lightsnow: isDayTime ? "day-snow" : "night-snow",
snow: "snow",
heavysnow: "snow",
lightrainandthunder: isDayTime ? "day-thunderstorm" : "night-thunderstorm",
rainandthunder: isDayTime ? "thunderstorm" : "night-thunderstorm",
heavyrainandthunder: isDayTime ? "thunderstorm" : "night-thunderstorm",
lightsleetandthunder: isDayTime ? "day-sleet-storm" : "night-sleet-storm",
sleetandthunder: isDayTime ? "day-sleet-storm" : "night-sleet-storm",
heavysleetandthunder: isDayTime ? "day-sleet-storm" : "night-sleet-storm",
lightsnowandthunder: isDayTime ? "day-snow-thunderstorm" : "night-snow-thunderstorm",
snowandthunder: isDayTime ? "day-snow-thunderstorm" : "night-snow-thunderstorm",
heavysnowandthunder: isDayTime ? "day-snow-thunderstorm" : "night-snow-thunderstorm"
};
return mappings[symbol] || null;
}
#getForecastUrl () {
const { lat, lon, altitude } = this.config;
return `${this.config.apiBase}/locationforecast/${this.config.forecastApiVersion}/complete?altitude=${altitude}&lat=${lat}&lon=${lon}`;
}
#getSunriseUrl () {
const { lat, lon } = this.config;
const today = getDateString(new Date());
const offset = formatTimezoneOffset(-new Date().getTimezoneOffset());
return `${this.config.apiBase}/sunrise/${this.config.sunriseApiVersion}/sun?lat=${lat}&lon=${lon}&date=${today}&offset=${offset}`;
}
}
module.exports = YrProvider;
+49
View File
@@ -0,0 +1,49 @@
.weather .weathericon,
.weather .fa-home {
font-size: 75%;
}
.weather .humidity-icon {
padding-right: 4px;
}
.weather .humidity-padding {
padding-bottom: 6px;
}
.weather .day {
padding-left: 0;
padding-right: 25px;
}
.weather .weather-icon {
padding-right: 30px;
text-align: center;
}
.weather .min-temp {
padding-left: 20px;
padding-right: 0;
}
.weather .precipitation-amount,
.weather .precipitation-prob,
.weather .humidity-hourly,
.weather .uv-index {
padding-left: 20px;
padding-right: 0;
}
.weather tr.colored .min-temp {
color: #bcddff;
}
.weather tr.colored .max-temp {
color: #ff8e99;
}
.weather .type-temp {
display: flex;
align-items: baseline;
gap: 10px;
}
+419
View File
@@ -0,0 +1,419 @@
/* global WeatherUtils, WeatherObject, formatTime */
Module.register("weather", {
// Default module config.
defaults: {
weatherProvider: "openweathermap",
roundTemp: false,
type: "current", // current, forecast, daily (equivalent to forecast), hourly
lang: config.language,
units: config.units,
tempUnits: config.units,
windUnits: config.units,
timeFormat: config.timeFormat,
updateInterval: 10 * 60 * 1000, // every 10 minutes
animationSpeed: 1000,
showFeelsLike: true,
showHumidity: "none", // possible options for "current" weather are "none", "wind", "temp", "feelslike" or "below", for "hourly" weather "none" or "true"
hideZeroes: false, // hide zeroes (and empty columns) in hourly, currently only for precipitation
showIndoorHumidity: false,
showIndoorTemperature: false,
allowOverrideNotification: false,
showPeriod: true,
showPeriodUpper: false,
showPrecipitationAmount: false,
showPrecipitationProbability: false,
showUVIndex: false,
showSun: true,
showWindDirection: true,
showWindDirectionAsArrow: false,
degreeLabel: false,
decimalSymbol: ".",
maxNumberOfDays: 5,
maxEntries: 5,
ignoreToday: false,
fade: true,
fadePoint: 0.25, // Start on 1/4th of the list.
initialLoadDelay: 0, // 0 seconds delay
appendLocationNameToHeader: true,
calendarClass: "calendar",
tableClass: "small",
onlyTemp: false,
colored: false,
absoluteDates: false,
forecastDateFormat: "ddd", // format for forecast date display, e.g., "ddd" = Mon, "dddd" = Monday, "D MMM" = 18 Oct
hourlyForecastIncrements: 1,
themeDir: "",
themeCustomScripts: []
},
// Module properties (all providers run server-side)
instanceId: null,
fetchedLocationName: null,
currentWeatherObject: null,
weatherForecastArray: null,
weatherHourlyArray: null,
// Can be used by the provider to display location of event if nothing else is specified
firstEvent: null,
getThemeDir () {
const td = this.config.themeDir.replace(/\/+$/, "");
if (td.length > 0) {
return `${td}/`;
} else {
return "";
}
},
// Define required scripts.
getStyles () {
return ["font-awesome.css", "weather-icons.css", `${this.getThemeDir()}weather.css`];
},
// Return the scripts that are necessary for the weather module.
getScripts () {
// Only load client-side dependencies for rendering
// All providers run server-side via node_helper
const resArr = ["moment.js", "weatherutils.js", "weatherobject.js", "suncalc.js"];
this.config.themeCustomScripts.forEach((element) => {
resArr.push(`${this.getThemeDir()}${element}`);
});
return resArr;
},
// Override getHeader method.
getHeader () {
if (this.config.appendLocationNameToHeader) {
const locationName = this.fetchedLocationName || "";
if (this.data.header && locationName) return `${this.data.header} ${locationName}`;
else if (locationName) return locationName;
}
return this.data.header ? this.data.header : "";
},
// Start the weather module.
start () {
moment.locale(this.config.lang);
if (this.config.useKmh) {
Log.warn("[weather] Deprecation warning: Your are using the deprecated config values 'useKmh'. Please switch to windUnits!");
this.windUnits = "kmh";
} else if (this.config.useBeaufort) {
Log.warn("[weather] Deprecation warning: Your are using the deprecated config values 'useBeaufort'. Please switch to windUnits!");
this.windUnits = "beaufort";
}
if (typeof this.config.showHumidity === "boolean") {
Log.warn("[weather] Deprecation warning: Please consider updating showHumidity to the new style (config string).");
this.config.showHumidity = this.config.showHumidity ? "wind" : "none";
}
// All providers run server-side: use stable identifier so reconnects don't spawn duplicate HTTPFetchers
this.instanceId = this.identifier;
if (window.initWeatherTheme) window.initWeatherTheme(this);
Log.log(`[weather] Initializing server-side provider with instance ID: ${this.instanceId}`);
this.sendSocketNotification("INIT_WEATHER", {
instanceId: this.instanceId,
weatherProvider: this.config.weatherProvider,
...this.config
});
// Server-driven fetching - no client-side scheduling needed
// Add custom filters
this.addFilters();
},
// Cleanup on module hide/suspend
stop () {
if (this.instanceId) {
this.sendSocketNotification("STOP_WEATHER", {
instanceId: this.instanceId
});
}
},
// Override notification handler.
notificationReceived (notification, payload, sender) {
if (notification === "CALENDAR_EVENTS") {
const senderClasses = sender.data.classes.toLowerCase().split(" ");
if (senderClasses.indexOf(this.config.calendarClass.toLowerCase()) !== -1) {
this.firstEvent = null;
for (let event of payload) {
if (event.location || event.geo) {
this.firstEvent = event;
Log.debug("[weather] First upcoming event with location: ", event);
break;
}
}
}
} else if (notification === "INDOOR_TEMPERATURE") {
this.indoorTemperature = this.roundValue(payload);
this.updateDom(300);
} else if (notification === "INDOOR_HUMIDITY") {
this.indoorHumidity = this.roundValue(payload);
this.updateDom(300);
} else if (notification === "CURRENT_WEATHER_OVERRIDE" && this.config.allowOverrideNotification) {
// Override current weather with data from local sensors
if (this.currentWeatherObject) {
Object.assign(this.currentWeatherObject, payload);
this.updateDom(this.config.animationSpeed);
}
}
},
// Handle socket notifications from node_helper
socketNotificationReceived (notification, payload) {
if (payload.instanceId !== this.instanceId) {
return;
}
if (notification === "WEATHER_INITIALIZED") {
Log.log(`[weather] Provider initialized, location: ${payload.locationName}`);
this.fetchedLocationName = payload.locationName;
this.updateDom();
// Server-driven fetching - HTTPFetcher will send WEATHER_DATA automatically
} else if (notification === "WEATHER_DATA") {
this.handleWeatherData(payload);
} else if (notification === "WEATHER_ERROR") {
Log.error("[weather] Error from node_helper:", payload.error);
}
},
handleWeatherData (payload) {
const { type, data } = payload;
if (!data) {
return;
}
// Convert plain objects to WeatherObject instances
switch (type) {
case "current":
this.currentWeatherObject = this.createWeatherObject(data);
break;
case "forecast":
case "daily":
this.weatherForecastArray = data.map((d) => this.createWeatherObject(d));
break;
case "hourly":
this.weatherHourlyArray = data.map((d) => this.createWeatherObject(d));
break;
default:
Log.warn(`Unknown weather data type: ${type}`);
break;
}
this.updateAvailable();
},
createWeatherObject (data) {
const weather = new WeatherObject();
Object.assign(weather, {
...data,
// Convert to moment objects for template compatibility
date: data.date ? moment(data.date) : null,
sunrise: data.sunrise ? moment(data.sunrise) : null,
sunset: data.sunset ? moment(data.sunset) : null
});
return weather;
},
// Select the template depending on the display type.
getTemplate () {
switch (this.config.type.toLowerCase()) {
case "current":
return `${this.getThemeDir()}current.njk`;
case "hourly":
return `${this.getThemeDir()}hourly.njk`;
case "daily":
case "forecast":
return `${this.getThemeDir()}forecast.njk`;
//Make the invalid values use the "Loading..." from forecast
default:
return `${this.getThemeDir()}forecast.njk`;
}
},
// Add all the data to the template.
getTemplateData () {
const now = new Date();
// Filter out past entries, but keep the current hour (e.g. show 0:00 at 0:10).
// This ensures consistent behavior across all providers, regardless of whether
// a provider filters past entries itself.
const startOfHour = new Date(now);
startOfHour.setMinutes(0, 0, 0);
const upcomingHourlyData = this.weatherHourlyArray
?.filter((entry) => entry.date?.valueOf() >= startOfHour.getTime());
const hourlySourceData = upcomingHourlyData?.length ? upcomingHourlyData : this.weatherHourlyArray;
const increment = this.config.hourlyForecastIncrements;
const keepByConfiguredIncrement = (_entry, index) => {
// Keep the existing offset behavior of hourlyForecastIncrements.
return (index + 1) % increment === increment - 1;
};
const hourlyData = hourlySourceData?.filter(keepByConfiguredIncrement);
return {
config: this.config,
current: this.currentWeatherObject,
forecast: this.weatherForecastArray,
hourly: hourlyData,
indoor: {
humidity: this.indoorHumidity,
temperature: this.indoorTemperature
}
};
},
// What to do when the weather provider has new information available?
updateAvailable () {
Log.log("[weather] New weather information available.");
if (window.updateWeatherTheme) {
window.updateWeatherTheme(this);
} else {
this.updateDom(300);
}
const currentWeather = this.currentWeatherObject;
if (currentWeather) {
this.sendNotification("CURRENTWEATHER_TYPE", { type: currentWeather.weatherType?.replace("-", "_") });
}
const notificationPayload = {
currentWeather: this.config.units === "imperial"
? WeatherUtils.convertWeatherObjectToImperial(currentWeather?.simpleClone()) ?? null
: currentWeather?.simpleClone() ?? null,
forecastArray: this.config.units === "imperial"
? this.getForecastArray()?.map((ar) => WeatherUtils.convertWeatherObjectToImperial(ar.simpleClone())) ?? []
: this.getForecastArray()?.map((ar) => ar.simpleClone()) ?? [],
hourlyArray: this.config.units === "imperial"
? this.getHourlyArray()?.map((ar) => WeatherUtils.convertWeatherObjectToImperial(ar.simpleClone())) ?? []
: this.getHourlyArray()?.map((ar) => ar.simpleClone()) ?? [],
locationName: this.fetchedLocationName,
providerName: this.config.weatherProvider
};
this.sendNotification("WEATHER_UPDATED", notificationPayload);
},
getForecastArray () {
return this.weatherForecastArray;
},
getHourlyArray () {
return this.weatherHourlyArray;
},
// scheduleUpdate removed - all providers use server-driven fetching via HTTPFetcher
roundValue (temperature) {
if (temperature === null || temperature === undefined) {
return "";
}
const decimals = this.config.roundTemp ? 0 : 1;
const roundValue = parseFloat(temperature).toFixed(decimals);
if (roundValue === "NaN") {
return "";
}
return roundValue === "-0" ? 0 : roundValue;
},
addFilters () {
this.nunjucksEnvironment().addFilter(
"formatTime",
function (date) {
return formatTime(this.config, date);
}.bind(this)
);
this.nunjucksEnvironment().addFilter(
"unit",
function (value, type, valueUnit) {
let formattedValue;
if (type === "temperature") {
if (value === null || value === undefined) {
formattedValue = "";
} else {
formattedValue = `${this.roundValue(WeatherUtils.convertTemp(value, this.config.tempUnits))}°`;
if (this.config.degreeLabel) {
if (this.config.tempUnits === "metric") {
formattedValue += "C";
} else if (this.config.tempUnits === "imperial") {
formattedValue += "F";
} else {
formattedValue += "K";
}
}
}
} else if (type === "precip") {
if (value === null || isNaN(value)) {
formattedValue = "";
} else {
formattedValue = WeatherUtils.convertPrecipitationUnit(value, valueUnit, this.config.units);
}
} else if (type === "humidity") {
formattedValue = `${value}%`;
} else if (type === "wind") {
formattedValue = WeatherUtils.convertWind(value, this.config.windUnits);
}
return formattedValue;
}.bind(this)
);
this.nunjucksEnvironment().addFilter(
"roundValue",
function (value) {
return this.roundValue(value);
}.bind(this)
);
this.nunjucksEnvironment().addFilter(
"decimalSymbol",
function (value) {
return value.toString().replace(/\./g, this.config.decimalSymbol);
}.bind(this)
);
this.nunjucksEnvironment().addFilter(
"calcNumSteps",
function (forecast) {
return Math.min(forecast.length, this.config.maxNumberOfDays);
}.bind(this)
);
this.nunjucksEnvironment().addFilter(
"calcNumEntries",
function (dataArray) {
return Math.min(dataArray.length, this.config.maxEntries);
}.bind(this)
);
this.nunjucksEnvironment().addFilter(
"opacity",
function (currentStep, numSteps) {
if (this.config.fade && this.config.fadePoint < 1) {
if (this.config.fadePoint < 0) {
this.config.fadePoint = 0;
}
const startingPoint = numSteps * this.config.fadePoint;
const numFadesteps = numSteps - startingPoint;
if (currentStep >= startingPoint) {
return 1 - (currentStep - startingPoint) / numFadesteps;
} else {
return 1;
}
} else {
return 1;
}
}.bind(this)
);
}
});
+133
View File
@@ -0,0 +1,133 @@
/* global SunCalc, WeatherUtils */
/**
* @external Moment
*/
class WeatherObject {
/**
* Constructor for a WeatherObject
*/
constructor () {
this.date = null;
this.windSpeed = null;
this.windFromDirection = null;
this.sunrise = null;
this.sunset = null;
this.temperature = null;
this.minTemperature = null;
this.maxTemperature = null;
this.weatherType = null;
this.humidity = null;
this.precipitationAmount = null;
this.precipitationUnits = null;
this.precipitationProbability = null;
this.feelsLikeTemp = null;
}
cardinalWindDirection () {
if (this.windFromDirection > 11.25 && this.windFromDirection <= 33.75) {
return "NNE";
} else if (this.windFromDirection > 33.75 && this.windFromDirection <= 56.25) {
return "NE";
} else if (this.windFromDirection > 56.25 && this.windFromDirection <= 78.75) {
return "ENE";
} else if (this.windFromDirection > 78.75 && this.windFromDirection <= 101.25) {
return "E";
} else if (this.windFromDirection > 101.25 && this.windFromDirection <= 123.75) {
return "ESE";
} else if (this.windFromDirection > 123.75 && this.windFromDirection <= 146.25) {
return "SE";
} else if (this.windFromDirection > 146.25 && this.windFromDirection <= 168.75) {
return "SSE";
} else if (this.windFromDirection > 168.75 && this.windFromDirection <= 191.25) {
return "S";
} else if (this.windFromDirection > 191.25 && this.windFromDirection <= 213.75) {
return "SSW";
} else if (this.windFromDirection > 213.75 && this.windFromDirection <= 236.25) {
return "SW";
} else if (this.windFromDirection > 236.25 && this.windFromDirection <= 258.75) {
return "WSW";
} else if (this.windFromDirection > 258.75 && this.windFromDirection <= 281.25) {
return "W";
} else if (this.windFromDirection > 281.25 && this.windFromDirection <= 303.75) {
return "WNW";
} else if (this.windFromDirection > 303.75 && this.windFromDirection <= 326.25) {
return "NW";
} else if (this.windFromDirection > 326.25 && this.windFromDirection <= 348.75) {
return "NNW";
} else {
return "N";
}
}
/**
* Determines if the sun sets or rises next. Uses the current time and not
* the date from the weather-forecast.
* @param {Moment} date an optional date where you want to get the next
* action for. Useful only in tests, defaults to the current time.
* @returns {string|null} "sunset", "sunrise", or null if sun data unavailable
*/
nextSunAction (date = moment()) {
// Return null if sunrise/sunset data is unavailable
if (!this.sunrise || !this.sunset) {
return null;
}
return date.isBetween(this.sunrise, this.sunset) ? "sunset" : "sunrise";
}
feelsLike () {
if (this.feelsLikeTemp) {
return this.feelsLikeTemp;
}
return WeatherUtils.calculateFeelsLike(this.temperature, this.windSpeed, this.humidity);
}
/**
* Checks if the weatherObject is at dayTime.
* @returns {boolean} true if it is at dayTime
*/
isDayTime () {
// Default to daytime if sunrise/sunset data unavailable
if (!this.sunrise || !this.sunset) {
return true;
}
const now = !this.date ? moment() : this.date;
return now.isBetween(this.sunrise, this.sunset, undefined, "[]");
}
/**
* Update the sunrise / sunset time depending on the location. This can be
* used if your provider doesn't provide that data by itself. Then SunCalc
* is used here to calculate them according to the location.
* @param {number} lat latitude
* @param {number} lon longitude
*/
updateSunTime (lat, lon) {
const now = !this.date ? new Date() : this.date.toDate();
const times = SunCalc.getTimes(now, lat, lon);
this.sunrise = moment(times.sunrise);
this.sunset = moment(times.sunset);
}
/**
* Clone to simple object to prevent mutating and deprecation of legacy library.
*
* Before being handed to other modules, mutable values must be cloned safely.
* Especially 'moment' object is not immutable, so original 'date', 'sunrise', 'sunset' could be corrupted or changed by other modules.
* @returns {object} plained object clone of original weatherObject
*/
simpleClone () {
const toFlat = ["date", "sunrise", "sunset"];
let clone = { ...this };
for (const prop of toFlat) {
clone[prop] = clone?.[prop]?.valueOf() ?? clone?.[prop];
}
return clone;
}
}
/*************** DO NOT EDIT THE LINE BELOW ***************/
if (typeof module !== "undefined") {
module.exports = WeatherObject;
}
+203
View File
@@ -0,0 +1,203 @@
const WeatherUtils = {
/**
* Convert wind (from m/s) to beaufort scale
* @param {number} speedInMS the windspeed you want to convert
* @returns {number} the speed in beaufort
*/
beaufortWindSpeed (speedInMS) {
const windInKmh = this.convertWind(speedInMS, "kmh");
const speeds = [1, 5, 11, 19, 28, 38, 49, 61, 74, 88, 102, 117, 1000];
for (const [index, speed] of speeds.entries()) {
if (speed > windInKmh) {
return index;
}
}
return 12;
},
/**
* Convert a value in a given unit to a string with a converted
* value and a postfix matching the output unit system.
* @param {number} value - The value to convert.
* @param {string} valueUnit - The unit the values has. Default is mm.
* @param {string} outputUnit - The unit system (imperial/metric) the return value should have.
* @returns {string} - A string with tha value and a unit postfix.
*/
convertPrecipitationUnit (value, valueUnit, outputUnit) {
if (value === null || value === undefined || isNaN(value)) {
return "";
}
if (valueUnit === "%") return `${value.toFixed(0)} ${valueUnit}`;
let convertedValue = value;
let conversionUnit;
if (outputUnit === "imperial") {
convertedValue = this.convertPrecipitationToInch(value, valueUnit);
conversionUnit = "in";
} else {
conversionUnit = valueUnit ? valueUnit : "mm";
}
return `${convertedValue.toFixed(2)} ${conversionUnit}`;
},
/**
* Convert precipitation value into inch
* @param {number} value the precipitation value for convert
* @param {string} valueUnit can be 'mm' or 'cm'
* @returns {number} the converted precipitation value
*/
convertPrecipitationToInch (value, valueUnit) {
if (valueUnit && valueUnit.toLowerCase() === "cm") return value * 0.3937007874;
else return value * 0.03937007874;
},
/**
* Convert temp (from degrees C) into imperial or metric unit depending on
* your config
* @param {number} tempInC the temperature in Celsius you want to convert
* @param {string} unit can be 'imperial' or 'metric'
* @returns {number} the converted temperature
*/
convertTemp (tempInC, unit) {
return unit === "imperial" ? tempInC * 1.8 + 32 : tempInC;
},
/**
* Convert temp (from degrees C) into metric unit
* @param {number} tempInF the temperature in Fahrenheit you want to convert
* @returns {number} the converted temperature
*/
convertTempToMetric (tempInF) {
return ((tempInF - 32) * 5) / 9;
},
/**
* Convert wind speed into another unit.
* @param {number} windInMS the windspeed in meter/sec you want to convert
* @param {string} unit can be 'beaufort', 'kmh', 'knots, 'imperial' (mph)
* or 'metric' (mps)
* @returns {number} the converted windspeed
*/
convertWind (windInMS, unit) {
switch (unit) {
case "beaufort":
return this.beaufortWindSpeed(windInMS);
case "kmh":
return (windInMS * 3600) / 1000;
case "knots":
return windInMS * 1.943844;
case "imperial":
return windInMS * 2.2369362920544;
case "metric":
default:
return windInMS;
}
},
/*
* Convert the wind direction cardinal to value
*/
convertWindDirection (windDirection) {
const windCardinals = {
N: 0,
NNE: 22,
NE: 45,
ENE: 67,
E: 90,
ESE: 112,
SE: 135,
SSE: 157,
S: 180,
SSW: 202,
SW: 225,
WSW: 247,
W: 270,
WNW: 292,
NW: 315,
NNW: 337
};
return windCardinals.hasOwnProperty(windDirection) ? windCardinals[windDirection] : null;
},
convertWindToMetric (mph) {
return mph / 2.2369362920544;
},
convertWindToMs (kmh) {
return kmh * 0.27777777777778;
},
/**
* Taken from https://community.home-assistant.io/t/calculating-apparent-feels-like-temperature/370834/18
* @param {number} temperature temperature in degrees Celsius
* @param {number} windSpeed wind speed in meter/second
* @param {number} humidity relative humidity in percent
* @returns {number} the feels like temperature in degrees Celsius
*/
calculateFeelsLike (temperature, windSpeed, humidity) {
const windInMph = this.convertWind(windSpeed, "imperial");
const tempInF = this.convertTemp(temperature, "imperial");
let HI;
let WC = tempInF;
// Calculate wind chill for certain conditions
if (tempInF <= 70 && windInMph >= 3) {
WC = 35.74 + (0.6215 * tempInF) - 35.75 * Math.pow(windInMph, 0.16) + ((0.4275 * tempInF) * Math.pow(windInMph, 0.16));
}
// Steadman Heat Index Vorberechnung
const STEADMAN_HI = 0.5 * (tempInF + 61.0 + ((tempInF - 68.0) * 1.2) + (humidity * 0.094));
if (STEADMAN_HI >= 80) {
// Rothfusz-Komplex
const ROTHFUSZ_HI = -42.379 + 2.04901523 * tempInF + 10.14333127 * humidity - 0.22475541 * tempInF * humidity - 0.00683783 * tempInF * tempInF - 0.05481717 * humidity * humidity + 0.00122874 * tempInF * tempInF * humidity + 0.00085282 * tempInF * humidity * humidity - 0.00000199 * tempInF * tempInF * humidity * humidity;
HI = ROTHFUSZ_HI;
if (humidity < 13 && tempInF > 80 && tempInF < 112) {
const ADJUSTMENT = ((13 - humidity) / 4) * Math.pow(Math.abs(17 - (tempInF - 95)), 0.5) / 17; // sqrt Teil
HI = HI - ADJUSTMENT;
} else if (humidity > 85 && tempInF > 80 && tempInF < 87) {
const ADJUSTMENT = ((humidity - 85) / 10) * ((87 - tempInF) / 5);
HI = HI + ADJUSTMENT;
}
} else { HI = STEADMAN_HI; }
// Feuchte Lastberechnung FL
let FL;
if (tempInF < 50) { FL = WC; }
else if (tempInF >= 50 && tempInF < 70) { FL = ((70 - tempInF) / 20) * WC + ((tempInF - 50) / 20) * HI; }
else if (tempInF >= 70) { FL = HI; }
return this.convertTempToMetric(FL);
},
/**
* Converts the Weather Object's values into imperial unit
* @param {object} weatherObject the weather object
* @returns {object} the weather object with converted values to imperial
*/
convertWeatherObjectToImperial (weatherObject) {
if (!weatherObject || Object.keys(weatherObject).length === 0) return null;
let imperialWeatherObject = { ...weatherObject };
if ("feelsLikeTemp" in imperialWeatherObject) imperialWeatherObject.feelsLikeTemp = this.convertTemp(imperialWeatherObject.feelsLikeTemp, "imperial");
if ("maxTemperature" in imperialWeatherObject) imperialWeatherObject.maxTemperature = this.convertTemp(imperialWeatherObject.maxTemperature, "imperial");
if ("minTemperature" in imperialWeatherObject) imperialWeatherObject.minTemperature = this.convertTemp(imperialWeatherObject.minTemperature, "imperial");
if ("precipitationAmount" in imperialWeatherObject) imperialWeatherObject.precipitationAmount = this.convertPrecipitationToInch(imperialWeatherObject.precipitationAmount, imperialWeatherObject.precipitationUnits);
if ("temperature" in imperialWeatherObject) imperialWeatherObject.temperature = this.convertTemp(imperialWeatherObject.temperature, "imperial");
if ("windSpeed" in imperialWeatherObject) imperialWeatherObject.windSpeed = this.convertWind(imperialWeatherObject.windSpeed, "imperial");
return imperialWeatherObject;
}
};
if (typeof module !== "undefined") {
module.exports = WeatherUtils;
}