Files
MagicMirror/defaultmodules/weather/providers/weatherflow.js
Kristjan ESPERANTO de3f57f8af weather: fix UV index display and add WeatherFlow precipitation (#4108)
While looking at the WeatherFlow provider (to evaluate #4107), I noticed
a few things that weren't quite right.

1. **UV index was broken for most providers in forecast/hourly views.**
The templates read `uv_index`, but only the WeatherAPI provider actually
wrote that key. All other providers (OpenWeatherMap, WeatherFlow,
PirateWeather, etc.) use `uvIndex` - so UV was silently never displayed
for them. This went unnoticed because `showUVIndex` defaults to `false`
and there were no test assertions for it. Standardized everything on
`uvIndex` and added test coverage.

2. **WeatherFlow didn't map precipitation for current weather.** The API
provides `precip_accum_local_day` and `precip_probability`, but they
weren't passed through. While adding them I also noticed the template
used truthiness checks, which hid valid zero values. Fixed both.

3. **`||` vs `??` in WeatherFlow provider.** Several numeric fields used
`|| null`, replacing valid `0` with `null`. Switched to `??` for
correctness.
2026-04-19 12:50:20 +02:00

302 lines
8.4 KiB
JavaScript

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) => {
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;