mirror of
https://github.com/MichMich/MagicMirror.git
synced 2026-04-24 06:47:07 +00:00
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.
302 lines
8.4 KiB
JavaScript
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;
|