mirror of
https://github.com/MichMich/MagicMirror.git
synced 2025-06-27 03:39:55 +00:00
## [2.29.0] - 2024-10-01 Thanks to: @bugsounet, @dkallen78, @jargordon, @khassel, @KristjanESPERANTO, @MarcLandis, @rejas, @ryan-d-williams, @sdetweil, @skpanagiotis. > ⚠️ This release needs nodejs version `v20` or `v22`, minimum version is `v20.9.0` ### Added - [compliments] Added support for cron type date/time format entries mm hh DD MM dow (minutes/hours/days/months and day of week) see https://crontab.cronhub.io for construction (#3481) - [core] Check config at every start of MagicMirror² (#3450) - [core] Add spelling check (cspell): `npm run test:spelling` and handle spelling issues (#3544) - [core] removed `config.paths.vendor` (could not work because `vendor` is hardcoded in `index.html`), renamed `config.paths.modules` to `config.foreignModulesDir`, added variable `MM_CUSTOMCSS_FILE` which - if set - overrides `config.customCss`, added variable `MM_MODULES_DIR` which - if set - overrides `config.foreignModulesDir`, added test for `MM_MODULES_DIR` (#3530) - [core] elements are now removed from `index.html` when loading script or stylesheet files fails - [core] Added `MODULE_DOM_UPDATED` notification each time the DOM is re-rendered via `updateDom` (#3534) - [tests] added minimal needed node version to tests (currently v20.9.0) to avoid releases with wrong node version info - [tests] Added `node-libgpiod` library to electron-rebuild tests (#3563) ### Removed - [core] removed installer only files (#3492) - [core] removed raspberry object from systeminformation (#3505) - [linter] removed `eslint-plugin-import`, because it doesn't support ESLint v9. We will reenter it later when it does. - [tests] removed `onoff` library from electron-rebuild tests (#3563) ### Updated - [weather] Updated `apiVersion` default from 2.5 to 3.0 (#3424) - [core] Updated dependencies including stylistic-eslint - [core] nail down `node-ical` version to `0.18.0` with exception `allow-ghsas: GHSA-8hc4-vh64-cxmj` in `dep-review.yaml` (which should removed after next `node-ical` update) - [core] Updated SocketIO catch all to new API - [core] Allow custom modules positions by scanning index.html for the defined regions, instead of hard coded (PR #3518 fixes issue #3504) - [core] Detail optimizations in `config_check.js` - [core] Updated minimal needed node version in `package.json` (currently v20.9.0) (#3559) and except for v21 (no security updates) (#3561) - [linter] Switch to ESLint v9 and flat config and replace `eslint-plugin-unicorn` by `@eslint/js` - [core] fix discovering module positions twice after #3450 ### Fixed - Fixed `checks` badge in README.md - [weather] Fixed issue with the UK Met Office provider following a change in their API paths and header info. - [core] add check for node_helper loading for multiple instances of same module (#3502) - [weather] Fixed issue for respecting unit config on broadcasted notifications - [tests] Fixes calendar test by moving it from e2e to electron with fixed date (#3532) - [calendar] fixed sliceMultiDayEvents getting wrong count and displaying incorrect entries, Europe/Berlin (#3542) - [tests] ignore `js/positions.js` when linting (this file is created at runtime) - [calendar] fixed sliceMultiDayEvents showing previous day without config enabled --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: Michael Teeuw <michael@xonaymedia.nl> Co-authored-by: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Ross Younger <crazyscot@gmail.com> Co-authored-by: Veeck <github@veeck.de> Co-authored-by: Bugsounet - Cédric <github@bugsounet.fr> Co-authored-by: jkriegshauser <joshuakr@nvidia.com> Co-authored-by: illimarkangur <116028111+illimarkangur@users.noreply.github.com> Co-authored-by: sam detweiler <sdetweil@gmail.com> Co-authored-by: vppencilsharpener <tim.pray@gmail.com> Co-authored-by: veeck <michael.veeck@nebenan.de> Co-authored-by: Paranoid93 <6515818+Paranoid93@users.noreply.github.com> Co-authored-by: Brian O'Connor <btoconnor@users.noreply.github.com> Co-authored-by: WallysWellies <59727507+WallysWellies@users.noreply.github.com> Co-authored-by: Jason Stieber <jrstieber@gmail.com> Co-authored-by: jargordon <50050429+jargordon@users.noreply.github.com> Co-authored-by: Daniel <32464403+dkallen78@users.noreply.github.com> Co-authored-by: Ryan Williams <65094007+ryan-d-williams@users.noreply.github.com> Co-authored-by: Panagiotis Skias <panagiotis.skias@gmail.com> Co-authored-by: Marc Landis <dirk.rettschlag@gmail.com>
332 lines
12 KiB
JavaScript
332 lines
12 KiB
JavaScript
/* global WeatherProvider, WeatherObject */
|
|
|
|
/*
|
|
* This class is a provider for SMHI (Sweden only).
|
|
* Metric system is the only supported unit,
|
|
* see https://www.smhi.se/
|
|
*/
|
|
WeatherProvider.register("smhi", {
|
|
providerName: "SMHI",
|
|
|
|
// Set the default config properties that is specific to this provider
|
|
defaults: {
|
|
lat: 0, // Cant have more than 6 digits
|
|
lon: 0, // Cant have more than 6 digits
|
|
precipitationValue: "pmedian",
|
|
location: false
|
|
},
|
|
|
|
/**
|
|
* Implements method in interface for fetching current weather.
|
|
*/
|
|
fetchCurrentWeather () {
|
|
this.fetchData(this.getURL())
|
|
.then((data) => {
|
|
const closest = this.getClosestToCurrentTime(data.timeSeries);
|
|
const coordinates = this.resolveCoordinates(data);
|
|
const weatherObject = this.convertWeatherDataToObject(closest, coordinates);
|
|
this.setFetchedLocation(this.config.location || `(${coordinates.lat},${coordinates.lon})`);
|
|
this.setCurrentWeather(weatherObject);
|
|
})
|
|
.catch((error) => Log.error(`Could not load data: ${error.message}`))
|
|
.finally(() => this.updateAvailable());
|
|
},
|
|
|
|
/**
|
|
* Implements method in interface for fetching a multi-day forecast.
|
|
*/
|
|
fetchWeatherForecast () {
|
|
this.fetchData(this.getURL())
|
|
.then((data) => {
|
|
const coordinates = this.resolveCoordinates(data);
|
|
const weatherObjects = this.convertWeatherDataGroupedBy(data.timeSeries, coordinates);
|
|
this.setFetchedLocation(this.config.location || `(${coordinates.lat},${coordinates.lon})`);
|
|
this.setWeatherForecast(weatherObjects);
|
|
})
|
|
.catch((error) => Log.error(`Could not load data: ${error.message}`))
|
|
.finally(() => this.updateAvailable());
|
|
},
|
|
|
|
/**
|
|
* Implements method in interface for fetching hourly forecasts.
|
|
*/
|
|
fetchWeatherHourly () {
|
|
this.fetchData(this.getURL())
|
|
.then((data) => {
|
|
const coordinates = this.resolveCoordinates(data);
|
|
const weatherObjects = this.convertWeatherDataGroupedBy(data.timeSeries, coordinates, "hour");
|
|
this.setFetchedLocation(this.config.location || `(${coordinates.lat},${coordinates.lon})`);
|
|
this.setWeatherHourly(weatherObjects);
|
|
})
|
|
.catch((error) => Log.error(`Could not load data: ${error.message}`))
|
|
.finally(() => this.updateAvailable());
|
|
},
|
|
|
|
/**
|
|
* Overrides method for setting config with checks for the precipitationValue being unset or invalid
|
|
* @param {object} config The configuration object
|
|
*/
|
|
setConfig (config) {
|
|
this.config = config;
|
|
if (!config.precipitationValue || ["pmin", "pmean", "pmedian", "pmax"].indexOf(config.precipitationValue) === -1) {
|
|
Log.log(`invalid or not set: ${config.precipitationValue}`);
|
|
config.precipitationValue = this.defaults.precipitationValue;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Of all the times returned find out which one is closest to the current time, should be the first if the data isn't old.
|
|
* @param {object[]} times Array of time objects
|
|
* @returns {object} The weatherdata closest to the current time
|
|
*/
|
|
getClosestToCurrentTime (times) {
|
|
let now = moment();
|
|
let minDiff = undefined;
|
|
for (const time of times) {
|
|
let diff = Math.abs(moment(time.validTime).diff(now));
|
|
if (!minDiff || diff < Math.abs(moment(minDiff.validTime).diff(now))) {
|
|
minDiff = time;
|
|
}
|
|
}
|
|
return minDiff;
|
|
},
|
|
|
|
/**
|
|
* Get the forecast url for the configured coordinates
|
|
* @returns {string} the url for the specified coordinates
|
|
*/
|
|
getURL () {
|
|
const formatter = new Intl.NumberFormat("en-US", {
|
|
minimumFractionDigits: 6,
|
|
maximumFractionDigits: 6
|
|
});
|
|
const lon = formatter.format(this.config.lon);
|
|
const lat = formatter.format(this.config.lat);
|
|
return `https://opendata-download-metfcst.smhi.se/api/category/pmp3g/version/2/geotype/point/lon/${lon}/lat/${lat}/data.json`;
|
|
},
|
|
|
|
/**
|
|
* Calculates the apparent temperature based on known atmospheric data.
|
|
* @param {object} weatherData Weatherdata to use for the calculation
|
|
* @returns {number} The apparent temperature
|
|
*/
|
|
calculateApparentTemperature (weatherData) {
|
|
const Ta = this.paramValue(weatherData, "t");
|
|
const rh = this.paramValue(weatherData, "r");
|
|
const ws = this.paramValue(weatherData, "ws");
|
|
const p = (rh / 100) * 6.105 * Math.E * ((17.27 * Ta) / (237.7 + Ta));
|
|
|
|
return Ta + 0.33 * p - 0.7 * ws - 4;
|
|
},
|
|
|
|
/**
|
|
* Converts the returned data into a WeatherObject with required properties set for both current weather and forecast.
|
|
* The returned units is always in metric system.
|
|
* Requires coordinates to determine if its daytime or nighttime to know which icon to use and also to set sunrise and sunset.
|
|
* @param {object} weatherData Weatherdata to convert
|
|
* @param {object} coordinates Coordinates of the locations of the weather
|
|
* @returns {WeatherObject} The converted weatherdata at the specified location
|
|
*/
|
|
convertWeatherDataToObject (weatherData, coordinates) {
|
|
let currentWeather = new WeatherObject();
|
|
|
|
currentWeather.date = moment(weatherData.validTime);
|
|
currentWeather.updateSunTime(coordinates.lat, coordinates.lon);
|
|
currentWeather.humidity = this.paramValue(weatherData, "r");
|
|
currentWeather.temperature = this.paramValue(weatherData, "t");
|
|
currentWeather.windSpeed = this.paramValue(weatherData, "ws");
|
|
currentWeather.windFromDirection = this.paramValue(weatherData, "wd");
|
|
currentWeather.weatherType = this.convertWeatherType(this.paramValue(weatherData, "Wsymb2"), currentWeather.isDayTime());
|
|
currentWeather.feelsLikeTemp = this.calculateApparentTemperature(weatherData);
|
|
|
|
/*
|
|
* Determine the precipitation amount and category and update the
|
|
* weatherObject with it, the valuetype to use can be configured or uses
|
|
* median as default.
|
|
*/
|
|
let precipitationValue = this.paramValue(weatherData, this.config.precipitationValue);
|
|
switch (this.paramValue(weatherData, "pcat")) {
|
|
// 0 = No precipitation
|
|
case 1: // Snow
|
|
currentWeather.snow += precipitationValue;
|
|
currentWeather.precipitationAmount += precipitationValue;
|
|
break;
|
|
case 2: // Snow and rain, treat it as 50/50 snow and rain
|
|
currentWeather.snow += precipitationValue / 2;
|
|
currentWeather.rain += precipitationValue / 2;
|
|
currentWeather.precipitationAmount += precipitationValue;
|
|
break;
|
|
case 3: // Rain
|
|
case 4: // Drizzle
|
|
case 5: // Freezing rain
|
|
case 6: // Freezing drizzle
|
|
currentWeather.rain += precipitationValue;
|
|
currentWeather.precipitationAmount += precipitationValue;
|
|
break;
|
|
}
|
|
|
|
return currentWeather;
|
|
},
|
|
|
|
/**
|
|
* Takes all the data points and converts it to one WeatherObject per day.
|
|
* @param {object[]} allWeatherData Array of weatherdata
|
|
* @param {object} coordinates Coordinates of the locations of the weather
|
|
* @param {string} groupBy The interval to use for grouping the data (day, hour)
|
|
* @returns {WeatherObject[]} Array of weatherobjects
|
|
*/
|
|
convertWeatherDataGroupedBy (allWeatherData, coordinates, groupBy = "day") {
|
|
let currentWeather;
|
|
let result = [];
|
|
|
|
let allWeatherObjects = this.fillInGaps(allWeatherData).map((weatherData) => this.convertWeatherDataToObject(weatherData, coordinates));
|
|
let dayWeatherTypes = [];
|
|
|
|
for (const weatherObject of allWeatherObjects) {
|
|
//If its the first object or if a day/hour change we need to reset the summary object
|
|
if (!currentWeather || !currentWeather.date.isSame(weatherObject.date, groupBy)) {
|
|
currentWeather = new WeatherObject();
|
|
dayWeatherTypes = [];
|
|
currentWeather.temperature = weatherObject.temperature;
|
|
currentWeather.date = weatherObject.date;
|
|
currentWeather.minTemperature = Infinity;
|
|
currentWeather.maxTemperature = -Infinity;
|
|
currentWeather.snow = 0;
|
|
currentWeather.rain = 0;
|
|
currentWeather.precipitationAmount = 0;
|
|
result.push(currentWeather);
|
|
}
|
|
|
|
//Keep track of what icons have been used for each hour of daytime and use the middle one for the forecast
|
|
if (weatherObject.isDayTime()) {
|
|
dayWeatherTypes.push(weatherObject.weatherType);
|
|
}
|
|
if (dayWeatherTypes.length > 0) {
|
|
currentWeather.weatherType = dayWeatherTypes[Math.floor(dayWeatherTypes.length / 2)];
|
|
} else {
|
|
currentWeather.weatherType = weatherObject.weatherType;
|
|
}
|
|
|
|
//All other properties is either a sum, min or max of each hour
|
|
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;
|
|
},
|
|
|
|
/**
|
|
* Resolve coordinates from the response data (probably preferably to use
|
|
* this if it's not matching the config values exactly)
|
|
* @param {object} data Response data from the weather service
|
|
* @returns {{lon, lat}} the lat/long coordinates of the data
|
|
*/
|
|
resolveCoordinates (data) {
|
|
return { lat: data.geometry.coordinates[0][1], lon: data.geometry.coordinates[0][0] };
|
|
},
|
|
|
|
/**
|
|
* The distance between the data points is increasing in the data the more distant the prediction is.
|
|
* Find these gaps and fill them with the previous hours data to make the data returned a complete set.
|
|
* @param {object[]} data Response data from the weather service
|
|
* @returns {object[]} Given data with filled gaps
|
|
*/
|
|
fillInGaps (data) {
|
|
let result = [];
|
|
for (let i = 1; i < data.length; i++) {
|
|
let to = moment(data[i].validTime);
|
|
let from = moment(data[i - 1].validTime);
|
|
let hours = moment.duration(to.diff(from)).asHours();
|
|
// For each hour add a datapoint but change the validTime
|
|
for (let j = 0; j < hours; j++) {
|
|
let current = Object.assign({}, data[i]);
|
|
current.validTime = from.clone().add(j, "hours").toISOString();
|
|
result.push(current);
|
|
}
|
|
}
|
|
return result;
|
|
},
|
|
|
|
/**
|
|
* Helper method to get a property from the returned data set.
|
|
* @param {object} currentWeatherData Weatherdata to get from
|
|
* @param {string} name The name of the property
|
|
* @returns {*} The value of the property in the weatherdata
|
|
*/
|
|
paramValue (currentWeatherData, name) {
|
|
return currentWeatherData.parameters.filter((p) => p.name === name).flatMap((p) => p.values)[0];
|
|
},
|
|
|
|
/**
|
|
* Map the icon value from SMHI to an icon that MagicMirror² understands.
|
|
* Uses different icons depending on if its daytime or nighttime.
|
|
* SMHI's description of what the numeric value means is the comment after the case.
|
|
* @param {number} input The SMHI icon value
|
|
* @param {boolean} isDayTime True if the icon should be for daytime, false for nighttime
|
|
* @returns {string} The icon name for the MagicMirror
|
|
*/
|
|
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:
|
|
return isDayTime ? "day-cloudy" : "night-cloudy"; // Variable cloudiness
|
|
case 4:
|
|
return isDayTime ? "day-cloudy" : "night-cloudy"; // Halfclear sky
|
|
case 5:
|
|
return "cloudy"; // Cloudy sky
|
|
case 6:
|
|
return "cloudy"; // Overcast
|
|
case 7:
|
|
return "fog"; // Fog
|
|
case 8:
|
|
return "showers"; // Light rain showers
|
|
case 9:
|
|
return "showers"; // Moderate rain showers
|
|
case 10:
|
|
return "showers"; // Heavy rain showers
|
|
case 11:
|
|
return "thunderstorm"; // Thunderstorm
|
|
case 12:
|
|
return "sleet"; // Light sleet showers
|
|
case 13:
|
|
return "sleet"; // Moderate sleet showers
|
|
case 14:
|
|
return "sleet"; // Heavy sleet showers
|
|
case 15:
|
|
return "snow"; // Light snow showers
|
|
case 16:
|
|
return "snow"; // Moderate snow showers
|
|
case 17:
|
|
return "snow"; // Heavy snow showers
|
|
case 18:
|
|
return "rain"; // Light rain
|
|
case 19:
|
|
return "rain"; // Moderate rain
|
|
case 20:
|
|
return "rain"; // Heavy rain
|
|
case 21:
|
|
return "thunderstorm"; // Thunder
|
|
case 22:
|
|
return "sleet"; // Light sleet
|
|
case 23:
|
|
return "sleet"; // Moderate sleet
|
|
case 24:
|
|
return "sleet"; // Heavy sleet
|
|
case 25:
|
|
return "snow"; // Light snowfall
|
|
case 26:
|
|
return "snow"; // Moderate snowfall
|
|
case 27:
|
|
return "snow"; // Heavy snowfall
|
|
default:
|
|
return "";
|
|
}
|
|
}
|
|
});
|