From dd32d3a492abbd6e4018210ea3aa6abd800c426e Mon Sep 17 00:00:00 2001 From: CreepinJesus <66406317+XBCreepinJesus@users.noreply.github.com> Date: Sun, 28 Jun 2020 10:57:19 +0100 Subject: [PATCH] New Met Office provider This is a provider for the Met Office's new DataHub API (which will eventually replace their current DataPoint service). --- .../weather/providers/ukmetofficedatahub.js | 303 ++++++++++++++++++ 1 file changed, 303 insertions(+) create mode 100644 modules/default/weather/providers/ukmetofficedatahub.js diff --git a/modules/default/weather/providers/ukmetofficedatahub.js b/modules/default/weather/providers/ukmetofficedatahub.js new file mode 100644 index 00000000..82e45d42 --- /dev/null +++ b/modules/default/weather/providers/ukmetofficedatahub.js @@ -0,0 +1,303 @@ +/* Magic Mirror + * Module: Weather + * + * By Malcolm Oakes https://github.com/maloakes + * Existing Met Office provider edited for new MetOffice Data Hub by CreepinJesus http://github.com/XBCreepinJesus + * MIT Licensed. + * + * This class is a provider for UK Met Office Data Hub (the replacement for their Data Point services). + * For more information on Data Hub, see https://www.metoffice.gov.uk/services/data/datapoint/notifications/weather-datahub + * Data available: + * Hourly data for next 2 days ("hourly") - https://www.metoffice.gov.uk/binaries/content/assets/metofficegovuk/pdf/data/global-spot-data-hourly.pdf + * 3-hourly data for the next 7 days ("3hourly") - https://www.metoffice.gov.uk/binaries/content/assets/metofficegovuk/pdf/data/global-spot-data-3-hourly.pdf + * Daily data for the next 7 days ("daily") - https://www.metoffice.gov.uk/binaries/content/assets/metofficegovuk/pdf/data/global-spot-data-daily.pdf + */ + +/* NOTES + * This provider requires longitude/latitude coordinates, rather than a location ID (as with the previous Met Office provider) + * Provide the following in your config.js file: + * weatherProvider: "ukmetofficedatahub", + * apiBase: "https://api-metoffice.apiconnect.ibmcloud.com/metoffice/production/v0/forecasts/point/", + * apiKey: "[YOUR API KEY]", + * apiSecret: "[YOUR API SECRET]]", + * lat: [LATITUDE (DECIMAL)], + * long: [LONGITUDE (DECIMAL)], + * windUnits: "mps" | "kph" | "mph" (default) + * tempUnits: "imperial" | "metric" (default) + * + * At time of writing, free accounts are limited to 360 requests a day per service (hourly, 3hourly, daily); take this in mind when + * setting your update intervals. For reference, 360 requests per day is once every 4 minutes. + * + * Pay attention to the units of the supplied data from the Met Office - it is given in SI/metric units where applicable: + * - Temperatures are in degrees Celsius (°C) + * - Wind speeds are in metres per second (m/s) + * - Wind direction given in degrees (°) + * - Pressures are in Pascals (Pa) + * - Distances are in metres (m) + * - Probabilities and humidity are given as percentages (%) + * - Precipitation is measured in millimetres (mm) with rates per hour (mm/h) + * + * See the PDFs linked above for more information on the data their corresponding units. + */ + +WeatherProvider.register("ukmetofficedatahub", { + // Set the name of the provider. + providerName: "UK Met Office (DataHub)", + + // Build URL with query strings according to DataHub API (https://metoffice.apiconnect.ibmcloud.com/metoffice/production/api) + getUrl(forecastType) { + let queryStrings = "?"; + queryStrings += "latitude=" + this.config.lat; + queryStrings += "&longitude=" + this.config.long; + if (this.config.appendLocationNameToHeader) { + queryStrings += "&includeLocationName=" + true; + } + + // Return URL, making sure there is a trailing "/" in the base URL. + return this.config.apiBase + (this.config.apiBase.endsWith("/") ? "" : "/") + forecastType + queryStrings; + }, + + // Build the list of headers for the request + // For DataHub requests, the API key/secret are sent in the headers rather than as query strings. + // Headers defined according to Data Hub API (https://metoffice.apiconnect.ibmcloud.com/metoffice/production/api) + getHeaders() { + let headers = { + accept: "application/json", + "x-ibm-client-id": this.config.apiKey, + "x-ibm-client-secret": this.config.apiSecret, + }; + + return headers; + }, + + // Fetch data using supplied URL and request headers + async fetchWeather(url, headers) { + const response = await fetch(url, { headers: headers }); + + // Return JSON data + return response.json(); + }, + + // Fetch hourly forecast data (to use for current weather) + fetchCurrentWeather() { + this.fetchWeather(this.getUrl("hourly"), this.getHeaders()) + .then((data) => { + // Check data is useable + if (!data || !data.features || !data.features[0].properties || !data.features[0].properties.timeSeries || data.features[0].properties.timeSeries.length == 0) { + // Did not receive usable new data. + // Maybe this needs a better check? + Log.error("Possibly bad current/hourly data?"); + Log.info(data); + return; + } + + // Set location name + this.setFetchedLocation(`${data.features[0].properties.location.name}`); + + // Generate current weather data + const currentWeather = this.generateWeatherObjectFromCurrentWeather(data); + this.setCurrentWeather(currentWeather); + }) + + // Catch any error(s) + .catch((error) => Log.error("Could not load data: " + error.message)) + + // Let the module know there're new data available + .finally(() => this.updateAvailable()); + }, + + // Create a WeatherObject using current weather data (data for the current hour) + generateWeatherObjectFromCurrentWeather(currentWeatherData) { + const currentWeather = new WeatherObject(this.config.units, this.config.tempUnits, this.config.windUnits); + + // Extract the actual forecasts + let forecastDataHours = currentWeatherData.features[0].properties.timeSeries; + + // Define now + let nowUtc = moment.utc(); + + // Find hour that contains the current time + for (hour in forecastDataHours) { + let forecastTime = moment.utc(forecastDataHours[hour].time); + if (nowUtc.isSameOrAfter(forecastTime) && nowUtc.isBefore(moment(forecastTime.add(1, "h")))) { + currentWeather.date = forecastTime; + currentWeather.windSpeed = this.convertWindSpeed(forecastDataHours[hour].windSpeed10m); + currentWeather.windDirection = forecastDataHours[hour].windDirectionFrom10m; + currentWeather.temperature = this.convertTemp(forecastDataHours[hour].screenTemperature); + currentWeather.minTemperature = this.convertTemp(forecastDataHours[hour].minScreenAirTemp); + currentWeather.maxTemperature = this.convertTemp(forecastDataHours[hour].maxScreenAirTemp); + currentWeather.weatherType = this.convertWeatherType(forecastDataHours[hour].significantWeatherCode); + currentWeather.humidity = forecastDataHours[hour].screenRelativeHumidity; + currentWeather.rain = forecastDataHours[hour].totalPrecipAmount; + currentWeather.snow = forecastDataHours[hour].totalSnowAmount; + currentWeather.precipitation = forecastDataHours[hour].probOfPrecipitation; + currentWeather.feelsLikeTemp = this.convertTemp(forecastDataHours[hour].feelsLikeTemperature); + + // Pass on full details so they can be used in custom templates + // Note the units of the supplied data when using this (see top of file) + currentWeather.rawData = forecastDataHours[hour]; + } + } + + // Determine the sunrise/sunset times - (still) not supplied in UK Met Office data + // Passes {longitude, latitude, height} to calcAstroData + // Could just pass lat/long from this.config, but returned data from MO also contains elevation + let times = this.calcAstroData(currentWeatherData.features[0].geometry.coordinates); + currentWeather.sunrise = times[0]; + currentWeather.sunset = times[1]; + + return currentWeather; + }, + + // Fetch daily forecast data + fetchWeatherForecast() { + this.fetchWeather(this.getUrl("daily"), this.getHeaders()) + .then((data) => { + // Check data is useable + if (!data || !data.features || !data.features[0].properties || !data.features[0].properties.timeSeries || data.features[0].properties.timeSeries.length == 0) { + // Did not receive usable new data. + // Maybe this needs a better check? + Log.error("Possibly bad forecast data?"); + Log.info(data); + return; + } + + // Set location name + this.setFetchedLocation(`${data.features[0].properties.location.name}`); + + // Generate the forecast data + const forecast = this.generateWeatherObjectsFromForecast(data); + this.setWeatherForecast(forecast); + }) + + // Catch any error(s) + .catch((error) => Log.error("Could not load data: " + error.message)) + + // Let the module know there're new data available + .finally(() => this.updateAvailable()); + }, + + // Create a WeatherObject for each day using daily forecast data + generateWeatherObjectsFromForecast(forecasts) { + const dailyForecasts = []; + + // Extract the actual forecasts + let forecastDataDays = forecasts.features[0].properties.timeSeries; + + // Define today + let today = moment.utc().startOf("date"); + + // Go through each day in the forecasts + for (day in forecastDataDays) { + const forecastWeather = new WeatherObject(this.config.units, this.config.tempUnits, this.config.windUnits); + + // Get date of forecast + let forecastDate = moment.utc(forecastDataDays[day].time); + + // Check if forecast is for today or in the future (i.e., ignore yesterday's forecast) + if (forecastDate.isSameOrAfter(today)) { + forecastWeather.date = forecastDate; + forecastWeather.minTemperature = this.convertTemp(forecastDataDays[day].nightMinScreenTemperature); + forecastWeather.maxTemperature = this.convertTemp(forecastDataDays[day].dayMaxScreenTemperature); + + // Using daytime forecast values + forecastWeather.windSpeed = this.convertWindSpeed(forecastDataDays[day].midday10MWindSpeed); + forecastWeather.windDirection = forecastDataDays[day].midday10MWindDirection; + forecastWeather.weatherType = this.convertWeatherType(forecastDataDays[day].daySignificantWeatherCode); + forecastWeather.precipitation = forecastDataDays[day].dayProbabilityOfPrecipitation; + forecastWeather.temperature = forecastDataDays[day].dayMaxScreenTemperature; + forecastWeather.humidity = forecastDataDays[day].middayRelativeHumidity; + forecastWeather.rain = forecastDataDays[day].dayProbabilityOfRain; + forecastWeather.snow = forecastDataDays[day].dayProbabilityOfSnow; + forecastWeather.feelsLikeTemp = this.convertTemp(forecastDataDays[day].dayMaxFeelsLikeTemp); + + // Pass on full details so they can be used in custom templates + // Note the units of the supplied data when using this (see top of file) + + forecastWeather.rawData = forecastDataDays[day]; + + dailyForecasts.push(forecastWeather); + } + } + + return dailyForecasts; + }, + + // Set the fetched location name. + setFetchedLocation: function (name) { + this.fetchedLocationName = name; + }, + + // Calculate sunrise/sunset times + calcAstroData(location) { + const sunTimes = []; + + // Careful to pass values to SunCalc in correct order (latitude, longitude, elevation) + let times = SunCalc.getTimes(new Date(), location[1], location[0], location[2]); + sunTimes.push(moment(times.sunrise, "X")); + sunTimes.push(moment(times.sunset, "X")); + + return sunTimes; + }, + + // Convert temperatures to Fahrenheit (from degrees C), if required + convertTemp(tempInC) { + return this.config.tempUnits === "imperial" ? (tempInC * 9) / 5 + 32 : tempInC; + }, + + // Convert wind speed from metres per second + // To keep the supplied metres per second units, use "mps" + // To use kilometres per hour, use "kph" + // Else assumed imperial and the value is returned in miles per hour (a Met Office user is likely to be UK-based) + convertWindSpeed(windInMpS) { + if (this.config.windUnits == "mps") { + return windInMpS; + } + + if (this.config.windUnits == "kph" || this.config.windUnits == "metric") { + return windInMpS * 3.6; + } + + return windInMpS * 2.23694; + }, + + // Match the Met Office "significant weather code" to a weathericons.css icon + // Use: https://metoffice.apiconnect.ibmcloud.com/metoffice/production/node/264 + // and: https://erikflowers.github.io/weather-icons/ + 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.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null; + }, +});