MagicMirror/modules/default/weather/providers/smhi.js
Veeck 5ea8a3469a
Release 2.27.0 (#3410)
## [2.27.0] - 2024-04-01

Thanks to: @bugsounet, @crazyscot, @illimarkangur, @jkriegshauser,
@khassel, @KristjanESPERANTO, @Paranoid93, @rejas, @sdetweil and
@vppencilsharpener.

This release marks the first release without Michael Teeuw (@michmich).
A very special thanks to him for creating MagicMirror and leading the
project for so many years.

For more info, please read the following post: [A New Chapter for
MagicMirror: The Community Takes the
Lead](https://forum.magicmirror.builders/topic/18329/a-new-chapter-for-magicmirror-the-community-takes-the-lead).

### Added

- Output of system information to the console for troubleshooting (#3328
and #3337), ignore errors under aarch64 (#3349)
- [chore] Add `eslint-plugin-package-json` to lint the `package.json`
files (#3368)
- [weather] `showHumidity` config is now a string describing where to
show this element. Supported values: "wind", "temp", "feelslike",
"below", "none". (#3330)
- electron-rebuild test suite for electron and 3rd party modules
compatibility (#3392)
- Create MM² icon and attach it to electron process (#3407)

### Updated

- Update updatenotification (update_helper.js): Recode with pm2 library
(#3332)
- Removing lodash dependency by replacing merge by spread operator
(#3339)
- Use node prefix for build-in modules (#3340)
- Rework logging colors (#3350)
- Update pm2 to v5.3.1 with no allow-ghsas (#3364)
- [chore] Update husky and let lint-staged fix ESLint issues
- [chore] Update dependencies including electron to v29 (#3357) and
node-ical
- Update translations for estonian (#3371)
- Update electron to v29 and update other dependencies
- [calendar] fullDay events over several days now show the left days
from the first day on and 'today' on the last day
- Update layout of current weather indoor values

### Fixed

- Correct apibase of weathergov weatherprovider to match documentation
(#2926)
- Worked around several issues in the RRULE library that were causing
deleted calender events to still show, some
initial and recurring events to not show, and some event times to be off
an hour. (#3291)
- Skip changelog requirement when running tests for dependency updates
(#3320)
- Display precipitation probability when it is 0% instead of blank/empty
(#3345)
- [newsfeed] Suppress unsightly animation cases when there are 0 or 1
active news items (#3336)
- [newsfeed] Always compute the feed item URL using the same helper
function (#3336)
- Ignore all custom css files (#3359)
- [newsfeed] Fix newsfeed stall issue introduced by #3336 (#3361)
- Changed `log.debug` to `log.log` in `app.js` where logLevel is not set
because config is not loaded at this time (#3353)
- [calendar] deny fetch interval < 60000 and set 60000 in this case
(prevent fetch loop failed) (#3382)
- added message in case where config.js is missing the module.export
line PR #3383
- Fixed an issue where recurring events could extend past their
recurrence end date (#3393)
- Don't display any `npm WARN <....>` on install (#3399)
- Fixed move suncalc dependency to production from dev, as it is used by
clock module
- [compliments] Fix mirror not responding anymore when no compliments
are to be shown (#3385)

### Deleted

- Unneeded file headers (#3358)

---------

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: Karsten Hassel <hassel@gmx.de>
Co-authored-by: Ross Younger <crazyscot@gmail.com>
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: Paranoid93 <6515818+Paranoid93@users.noreply.github.com>
2024-04-01 22:03:20 +02:00

328 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 "";
}
}
});