UNPKG

@danimal4326/homebridge-weather-plus

Version:

A comprehensive weather plugin for homekit with current observations, forecasts and history.

487 lines (455 loc) 14.1 kB
/*jshint esversion: 6,node: true,-W041: false */ "use strict"; const converter = require('../util/converter'), moment = require('moment-timezone'), axios = require('axios'); class OpenWeatherMapAPI { constructor(apiKey, language, locationId, locationGeo, locationCity, conditionDetail, log) { this.log = log; this.api = "3.0"; this.apiBaseURL = "https://api.openweathermap.org"; this.apiKey = apiKey; this.language = language; if (locationId) { this.log.error("Using a locationId is no longer supported by OpenWeatherMap. Please provide city name e.g. 'Berlin, DE' (locationCity) or geo cooridnates (locationGeo)"); } this.locationCity = locationCity; this.locationGeo = locationGeo; this.attribution = 'Powered by OpenWeatherMap'; this.reportCharacteristics = [ 'AirPressure', 'CloudCover', 'Condition', 'ConditionCategory', 'DewPoint', 'Humidity', 'ObservationTime', 'Rain1h', 'RainBool', 'SnowBool', 'SunriseTime', 'SunsetTime', 'Temperature', 'TemperatureApparent', 'UVIndex', 'WindDirection', 'WindSpeed' ]; this.forecastCharacteristics = [ 'AirPressure', 'CloudCover', 'Condition', 'ConditionCategory', 'DewPoint', 'ForecastDay', 'Humidity', 'RainBool', 'RainDay', 'SnowBool', 'SunriseTime', 'SunsetTime', 'TemperatureApparent', 'TemperatureMax', 'TemperatureMin', 'UVIndex', 'WindDirection', 'WindSpeed', 'RainChance' ]; this.forecastDays = 8; this.conditionDetail = conditionDetail; } update(forecastDays, callback) { this.log.debug("Updating weather with OpenWeatherMap"); let that = this; if (!this.locationGeo) { this.getLocationGeo((error, coordinates) => { if (!error) { that.locationGeo = [coordinates.lat, coordinates.lon]; that.update(forecastDays, callback); } else { that.log.error("Error getting locationGeo from %s", this.locationId ? this.locationId : this.locationCity); that.log.error(error); } }); } else { this.log.debug("Update weather"); let weather = {}; this.getWeatherData(this.getWeatherUrl(), (error, result) => { this.log.debug(result); if (!error && result["timezone"] !== undefined) { this.generateReport(weather, result, result["timezone"], callback); // Old api requires an extra call to get the forecast if (this.api === "2.5") { this.getWeatherData(this.apiBaseURL + "/data/2.5/forecast", (error, result) => { if (!error) { // Pass the entire "city" JSON array as it has both the timezone and sunrise & sunset values this.generateForecasts(weather, result["list"], result["city"], callback); } else { that.log.error("Error retrieving OpenWeatherMap Forecast from API 2.5"); that.log.error("Error result: " + result); that.log.error("Error message: " + error); callback(); } }); } else { this.generateForecasts(weather, result["daily"], result["timezone"], callback); } } else { if (error !== undefined && error.toString().includes("401")) { if (this.api === "3.0") { that.log.info("Could not retreive weather report with API 3.0, trying API 2.5 now ...") this.api = "2.5"; this.removeCharacteristic(this.reportCharacteristics, "UVIndex"); this.removeCharacteristic(this.reportCharacteristics, "DewPoint"); this.removeCharacteristic(this.forecastCharacteristics, "UVIndex"); this.removeCharacteristic(this.forecastCharacteristics, "DewPoint"); this.forecastDays = 5; this.update(forecastDays, callback); } else { that.log.error("Could not retreive weather report with neither API 3.0 or API 2.5. You may need to wait up to 30 minutes after creating your api key. If the error persist, check if you copied the api key correctly."); that.log.error("Error result: " + result); that.log.error("Error message: " + error); callback(); } } else { that.log.error("Error retrieving OpenWeatherMap report"); that.log.error("Error result: " + result); that.log.error("Error message: " + error); callback(); } } }); } } generateReport(weather, values, timezone, callback) { weather.report = {}; if (this.api === "2.5") { this.parseReportLegacy(weather.report, values); let timezoneShift = timezone / 60 weather.report.ObservationTime = moment.unix(values.dt).utcOffset(timezoneShift).format('HH:mm:ss'); } else { this.parseReportOneCall(weather.report, values["current"], timezone); weather.report.ObservationTime = moment.unix(values["current"].dt).tz(timezone).format('HH:mm:ss'); } if (weather.forecasts) { callback(null, weather); } } generateForecasts(weather, values, timezone, callback) { let forecasts = []; // API 2.5 does not send a summary for the forecast day, instead it sends a report for every 3 hours. // We need to combine 8 x 3hrs reports to get the forecast for 1 day. // Also for API 2.5, timezone parameter is actually the result "city" JSON array let legacyDays = []; if (this.api === "2.5") { for (let i = 0; i < values.length; i++) { if (i % 8 === 0) { legacyDays.push([]); } legacyDays[legacyDays.length - 1].push(values[i]); } values = legacyDays; } for (let i = 0; i < values.length; i++) { if (this.api === "2.5") { forecasts[forecasts.length] = this.parseForecastLegacy(values[i], timezone); } else { forecasts[forecasts.length] = this.parseForecastOneCall(values[i], timezone); } } weather.forecasts = forecasts; if (weather.report) { callback(null, weather); } } parseReportLegacy(report, values, isForecast = false) { report.AirPressure = parseInt(values.main.pressure); report.CloudCover = parseInt(values.clouds.all); report.Condition = values.weather[0].description; report.ConditionCategory = this.getConditionCategory(values.weather[0].id, this.conditionDetail); report.Humidity = parseInt(values.main.humidity); let detailedCondition = this.getConditionCategory(values.weather[0].id, true); report.RainBool = [5, 6, 9].includes(detailedCondition); report.SnowBool = [7, 8].includes(detailedCondition); report.SunriseTime = moment.unix(values.sys.sunrise).utcOffset(values.timezone / 60).format('HH:mm:ss'); report.SunsetTime = moment.unix(values.sys.sunset).utcOffset(values.timezone / 60).format('HH:mm:ss'); report.TemperatureApparent = typeof values.main.feels_like === 'object' ? parseInt(values.main.feels_like.day) : parseInt(values.main.feels_like); report.TemperatureMax = parseInt(values.main.temp_max); report.TemperatureMin = parseInt(values.main.temp_min); report.WindDirection = converter.getWindDirection(values.wind.deg); report.WindSpeed = parseFloat(values.wind.speed); if (isForecast) { report.RainDay = values.rain["24h"]; report.RainChance = parseFloat(values.pop) * 100; } else { report.Temperature = typeof values.main.temp === 'object' ? parseFloat(values.main.temp.day) : parseFloat(values.main.temp); let precip1h = values.rain === undefined || isNaN(parseFloat(values.rain['1h'])) ? 0 : parseFloat(values.rain['1h']); precip1h += values.snow === undefined || isNaN(parseFloat(values.snow['1h'])) ? 0 : parseFloat(values.snow['1h']); report.Rain1h = precip1h; } } /** * Combine the 8 reports for a forecast day into a meaningful summary for the day. * @param values 8 reports, each for 3 hours * @param timezoneShift shift in seconds from utc of location timezone * @returns {{}} forecast day */ parseForecastLegacy(values, city) { let forecast = {}; let combinedHourlyValues = { "dt": values[0].dt, "main": { "temp_max": Math.max(...values.map(v => v.main.temp_max)), "temp_min": Math.min(...values.map(v => v.main.temp_min)), "feels_like": Math.max(...values.map(v => v.main.feels_like)), "pressure": Math.max(...values.map(v => v.main.pressure)), "humidity": Math.max(...values.map(v => v.main.humidity)) }, "clouds": { "all": values.map(v => v.clouds.all).reduce((acc, v, i, a) => (acc + v / a.length), 0) }, "weather": values[4].weather, "sys": { "sunrise": city["sunrise"], "sunset": city["sunset"] }, "wind": { "speed": Math.max(...values.map(v => v.wind.speed)), "deg": values[4].wind.deg, "gust": Math.max(...values.map(v => v.wind.gust)) }, "rain": { "24h": values.map(v => v.rain === undefined || isNaN(parseFloat(v.rain['3h'])) ? 0 : parseFloat(v.rain['3h'])).reduce((a, b) => a + b) }, "pop": Math.max(...values.map(v => v.pop)), "timezone" : city["timezone"] } this.parseReportLegacy(forecast, combinedHourlyValues, true); forecast.ForecastDay = moment.unix(combinedHourlyValues.dt).utcOffset(city["timezone"] / 60).format('dddd'); return forecast; } parseReportOneCall(report, values, timezone, isForecast = false) { report.AirPressure = parseInt(values.pressure); report.CloudCover = parseInt(values.clouds); report.Condition = values.weather[0].description; report.ConditionCategory = this.getConditionCategory(values.weather[0].id, this.conditionDetail); report.DewPoint = parseInt(values.dew_point); report.Humidity = parseInt(values.humidity); let detailedCondition = this.getConditionCategory(values.weather[0].id, true); report.RainBool = [5, 6, 9].includes(detailedCondition); report.SnowBool = [7, 8].includes(detailedCondition); report.SunriseTime = moment.unix(values.sunrise).tz(timezone).format('HH:mm:ss'); report.SunsetTime = moment.unix(values.sunset).tz(timezone).format('HH:mm:ss'); report.TemperatureApparent = typeof values.feels_like === 'object' ? parseInt(values.feels_like.day) : parseInt(values.feels_like); report.UVIndex = parseInt(values.uvi); report.WindDirection = converter.getWindDirection(values.wind_deg); report.WindSpeed = parseFloat(values.wind_speed); if (isForecast) { let precipDay = isNaN(parseFloat(values.rain)) ? 0 : parseFloat(values.rain); precipDay += isNaN(parseFloat(values.snow)) ? 0 : parseFloat(values.snow); report.RainDay = precipDay; report.TemperatureMax = parseInt(values.temp.max); report.TemperatureMin = parseInt(values.temp.min); report.RainChance = parseFloat(values.pop) * 100; } else { let precip1h = values.rain === undefined || isNaN(parseFloat(values.rain['1h'])) ? 0 : parseFloat(values.rain['1h']); precip1h += values.snow === undefined || isNaN(parseFloat(values.snow['1h'])) ? 0 : parseFloat(values.snow['1h']); report.Rain1h = precip1h; report.Temperature = typeof values.temp === 'object' ? parseFloat(values.temp.day) : parseFloat(values.temp); } } parseForecastOneCall(values, timezone) { let forecast = {}; this.parseReportOneCall(forecast, values, timezone, true); forecast.ForecastDay = moment.unix(values.dt).tz(timezone).format('dddd'); return forecast; } getConditionCategory(code, detail = false) { // See https://openweathermap.org/weather-conditions if ([202, 212, 221, 232, 504, 531, 711, 762, 771, 781].includes(code)) { // Severe weather return detail ? 9 : 2; } else if (code >= 600 && code < 700) { // Snow return detail ? 8 : 3; } else if (code === 511) { // Hail return detail ? 7 : 3; } else if ([200, 201].includes(code) || code >= 311 && code < 600) { // Rain return detail ? 6 : 2; } else if ([230, 231].includes(code) || code >= 300 && code < 311) { // Drizzle return detail ? 5 : 2; } else if (code >= 700 && code < 800) { // Fog return detail ? 4 : 1; } else if ([210, 211].includes(code) || code === 804) { // Overcast return detail ? 3 : 1; } else if ([803, 802].includes(code)) { // Broken Clouds return detail ? 2 : 1; } else if (code === 801) { // Few Clouds return detail ? 1 : 0; } else if (code === 800) { // Clear return 0; } else { this.log.warn("Unknown OpenWeatherMap category " + code); return 0; } }; getWeatherData(url, callback) { this.log.debug("Getting weather data for location %s", this.locationGeo); const queryUri = url + "?units=metric&lang=" + this.language + "&lat=" + this.locationGeo[0] + "&lon=" + this.locationGeo[1] + "&appid=" + this.apiKey; axios.get(encodeURI(queryUri)) .then(response => { let parseError; let weather try { weather = response.data; } catch (e) { parseError = e; } callback(parseError, weather); }) .catch(requestError => { callback(requestError); }); } getLocationGeo(callback) { this.log.debug("Getting coordinates for %s", this.locationCity); const queryUri = this.apiBaseURL + "/geo/1.0/direct?q=" + this.locationCity.toLowerCase() + "&limit=1&appid=" + this.apiKey; axios.get(encodeURI(queryUri)) .then(response => { // Get locationGeo from weather report let coordinates; let parseError; try { let json = response.data; if (json.length >= 1) { coordinates = {"lat": json[0].lat, "lon": json[0].lon}; this.log.info("Found coordinates for %s: %s", this.locationCity, coordinates); } else { parseError = response; } } catch (e) { parseError = e; } callback(parseError, coordinates); }) .catch(requestError => { callback(requestError); }); }; getWeatherUrl() { this.log.debug("Using API %s", this.api); if (this.api === "2.5") { return this.apiBaseURL + "/data/2.5/weather"; } else { return this.apiBaseURL + "/data/3.0/onecall" } } removeCharacteristic(characteristics, item) { let index = characteristics.indexOf(item); if (index !== -1) { characteristics.splice(index, 1); } } } module.exports = { OpenWeatherMapAPI: OpenWeatherMapAPI };