UNPKG

@yachteye/signalk-weather-plugin

Version:
598 lines (597 loc) 30.4 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const _1 = require("."); const openweather_1 = __importDefault(require("./openweather")); const utils = require('@signalk/nmea0183-utilities'); /** * Update the SignalK graph with the latest weather data. */ class Updater { constructor(app, pluginId) { /** Path for the local current temperature. */ this.pathCurrentTemp = 'resources.weather.actual.current.temperature'; /** Path for the sunrise/sunset values of today, provided by the Makkah plugin. */ this.pathSunTimes = 'resources.astronomical.salahTimes'; /** OpenWeather source value for a SK Update. */ this.source = { label: 'OpenWeather', type: 'web' }; this.app = app; this.pluginId = pluginId; } /** * Handle the current weather information: condition, temperature, wind speed/direction, sunrise/sunset. * Also included: city name/ID, country-code, perceived temperature, air pressure, humidity, cloudiness, precipitation. * @param w Current weather information (metric units assumed). * @param pathStart Path to store the data, default is 'resources.weather.actual' for the ship's position. * @returns void */ handleCurrentWeatherResult(w, pathStart = 'resources.weather.actual') { const isLocal = pathStart.includes('.weather.actual'); const timestampISOString = new Date(w.dt * 1000).toISOString(); this.app.debug(`handleCurrentWeatherResult(): age of current weather data= ${(((Date.now() / 1000) - w.dt) / 60).toFixed(1)} minutes.`); const latestTimestamp = this.app.getSelfPath(this.pathCurrentTemp); if (isLocal && latestTimestamp !== undefined && latestTimestamp.timestamp === timestampISOString) { this.app.debug(`handleCurrentWeatherResult(): no new weather data available ${latestTimestamp}.`); return; } const delta = { context: 'vessels.self', updates: [], }; if (w.name && w.id !== undefined && w.sys && w.sys.country) { const update = { source: this.source, timestamp: timestampISOString, values: [ { path: `${pathStart}.cityName`, value: w.name }, { path: `${pathStart}.cityId`, value: w.id }, { path: `${pathStart}.countryCode`, value: w.sys.country }, ], }; delta.updates.push(update); } // For consistency we prefer to use the sun times of the Makkah calculation for the local weather. const sunTimes = this.app.getSelfPath(this.pathSunTimes); if (isLocal && sunTimes && sunTimes.value !== null && typeof sunTimes.value === 'object') { if ('sunrise' in sunTimes.value) { const update = { source: { label: `YachtEye - Makkah Pointer` }, timestamp: sunTimes.timestamp, values: [ { path: `${pathStart}.sunrise`, value: sunTimes.value.sunrise }, // @todo: remove in the future. { path: `${pathStart}.current.sunrise`, value: sunTimes.value.sunrise }, ], }; delta.updates.push(update); // this.app.debug(`Sunrise times: MP=${sunTimes.value.sunrise} / OW=${new Date(w.sys.sunrise * 1000).toISOString()}`); } if ('sunset' in sunTimes.value) { const update = { source: { label: `YachtEye - Makkah Pointer` }, timestamp: sunTimes.timestamp, values: [ { path: `${pathStart}.sunset`, value: sunTimes.value.sunset }, // @todo: remove in the future. { path: `${pathStart}.current.sunset`, value: sunTimes.value.sunset }, ], }; delta.updates.push(update); // this.app.debug(`Sunset times: MP=${sunTimes.value.sunset} / OW=${new Date(w.sys.sunset * 1000).toISOString()}`); } } else { // Use the sunrise/sunset values of Open Weather. const tzOffsetSeconds = this.app.getSelfPath('environment.time.timezoneOffset'); let tzMinutes = 0; if (isLocal && tzOffsetSeconds !== undefined && typeof tzOffsetSeconds.value === 'number') { tzMinutes = tzOffsetSeconds.value / 60; } else if (w.timezone !== undefined) { tzMinutes = w.timezone / 60; } if (w.sys.sunrise > 0 && w.sys.sunset > 0) { const owSunrise = new Date(w.sys.sunrise * 1000); const owSunset = new Date(w.sys.sunset * 1000); owSunrise.setUTCMinutes(owSunrise.getUTCMinutes() + tzMinutes); owSunset.setUTCMinutes(owSunset.getUTCMinutes() + tzMinutes); const update = { source: this.source, timestamp: timestampISOString, values: [ { path: `${pathStart}.current.sunrise`, value: `${owSunrise.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", timeZone: "UTC" })}` }, { path: `${pathStart}.current.sunset`, value: `${owSunset.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", timeZone: "UTC" })}` }, ], }; delta.updates.push(update); } } if (w.main.temp !== undefined) { const temperature = { source: this.source, timestamp: timestampISOString, values: [{ path: `${pathStart}.current.temperature`, value: (0, _1.roundToDecimalPlaces)(utils.transform(w.main.temp, 'C', 'K'), 2) }], }; delta.updates.push(temperature); } if (w.main.feels_like !== undefined) { const temperature = { source: this.source, timestamp: timestampISOString, values: [{ path: `${pathStart}.current.perceivedTemperature`, value: (0, _1.roundToDecimalPlaces)(utils.transform(w.main.feels_like, 'C', 'K'), 2) }], }; delta.updates.push(temperature); } // Not used by YE atm, but save it anyway. if (w.main.pressure !== undefined) { const update = { source: this.source, timestamp: timestampISOString, values: [{ path: `${pathStart}.current.airpressure`, value: hPaToPa(w.main.pressure) }], }; delta.updates.push(update); } // Not used by YE atm, but save it anyway. if (w.clouds && w.clouds.all !== undefined) { const update = { source: this.source, timestamp: timestampISOString, values: [{ path: `${pathStart}.current.cloudiness`, value: w.clouds.all / 100 }], }; delta.updates.push(update); } // Merge rain and snow. let precipitation1h = 0; precipitation1h += w.rain && w.rain['1h'] !== undefined ? w.rain['1h'] : 0; precipitation1h += w.snow && w.snow['1h'] !== undefined ? w.snow['1h'] : 0; let precipitation3h = 0; precipitation3h += w.rain && w.rain['3h'] !== undefined ? w.rain['3h'] : 0; precipitation3h += w.snow && w.snow['3h'] !== undefined ? w.snow['3h'] : 0; // Not used by YE atm. // const precipitation: ISignalKUpdate = { // source: this.source, // timestamp: timestampISOString, // values: [ // { path: `${pathStart}.current.precipitationLastHour`, value: precipitation1h }, // { path: `${pathStart}.current.precipitationLast3Hours`, value: precipitation3h }, // ], // }; // delta.updates.push(precipitation); // Not used by YE atm, but save it anyway. if (w.main.humidity !== undefined) { const update = { source: this.source, timestamp: timestampISOString, values: [{ path: `${pathStart}.current.humidity`, value: w.main.humidity / 100 }], }; delta.updates.push(update); } if (w.wind !== undefined && w.wind.speed !== undefined && w.wind.deg !== undefined) { const wind = { source: this.source, timestamp: timestampISOString, values: [ { path: `${pathStart}.current.windspeed`, value: w.wind.speed }, { path: `${pathStart}.current.winddirection`, value: (0, _1.roundToDecimalPlaces)(utils.transform(w.wind.deg, 'deg', 'rad'), 5) }, ], }; delta.updates.push(wind); } if (w.weather && w.weather.length >= 1) { const update = { source: this.source, timestamp: timestampISOString, values: [{ path: `${pathStart}.current.conditionId`, value: w.weather[0].id }], }; delta.updates.push(update); } this.app.handleMessage(this.pluginId, delta); } /** * Daily forecast: weather condition, temperature, min/max temperature and wind speed/direction. * @param fc Daily forecast data (standard units assumed). * @param pathStart Path to use, default is 'resources.weather.actual.forecast'. */ handleDailyForecastResult(fc, pathStart = 'resources.weather.actual.forecast') { this.app.debug(`handleDailyForecastResult(): location=${fc.city.name}, ${fc.city.country}, #days=${fc.cnt}.`); const timestampISOString = new Date().toISOString(); const delta = { context: 'vessels.self', updates: [], }; for (let idx = 0; idx < fc.cnt; idx++) { const day = fc.list[idx]; const fcDate = new Date(day.dt * 1000); const path = this.datePath(fcDate, pathStart); // const update: ISignalKUpdate = { // source: this.source, // values: [{ path: path + 'timeOfForecast', value: fcDate.toISOString() }], // }; // delta.updates.push(update); if (day.weather && day.weather.length >= 1) { const update = { source: this.source, timestamp: timestampISOString, values: [{ path: path + 'conditionId', value: day.weather[0].id }], }; delta.updates.push(update); } // if (full && day.temp.night !== undefined) { // const update: ISignalKUpdate = { // source: this.source, // values: [{ path: path + 'temperatureNight', value: day.temp.night }], // }; // delta.updates.push(update); // } if (day.temp.day !== undefined) { const update = { source: this.source, timestamp: timestampISOString, values: [{ path: path + 'temperature', value: day.temp.day }], }; delta.updates.push(update); } // if (full && day.temp.day !== undefined) { // const update: ISignalKUpdate = { // source: this.source, // values: [{ path: path + 'temperatureNoon', value: day.temp.day }], // }; // delta.updates.push(update); // } // if (full && day.temp.eve !== undefined) { // const update: ISignalKUpdate = { // source: this.source, // values: [{ path: path + 'temperatureEvening', value: day.temp.eve }], // }; // delta.updates.push(update); // } if (day.temp.min !== undefined && day.temp.max !== undefined) { const update = { source: this.source, timestamp: timestampISOString, values: [ { path: path + 'temperatureMin', value: day.temp.min }, { path: path + 'temperatureMax', value: day.temp.max }, ], }; delta.updates.push(update); } if (day.speed !== undefined && day.deg !== undefined) { const update = { source: this.source, timestamp: timestampISOString, values: [ { path: path + 'windspeed', value: day.speed }, { path: path + 'winddirection', value: (0, _1.roundToDecimalPlaces)(utils.transform(day.deg, 'deg', 'rad'), 5) }, ], }; delta.updates.push(update); } // Add morning, afternoon and evening temperatures for today and tomorrow. // We would also like to have the condition for morning/afternoon/evening, but that is not available. // if (idx === 0 || idx === 1) { // if (day.temp.morn !== undefined) { // const update: ISignalKUpdate = { // source: this.source, // values: [{ path: path + 'morning.temperature', value: day.temp.morn }], // Used by the iOS apps, but not critical. // }; // delta.updates.push(update); // } // if (day.temp.day !== undefined) { // const update: ISignalKUpdate = { // source: this.source, // values: [{ path: path + 'afternoon.temperature', value: day.temp.day }], // Used by the iOS apps, but not critical. // }; // delta.updates.push(update); // } // if (day.temp.eve !== undefined) { // const update: ISignalKUpdate = { // source: this.source, // values: [{ path: path + 'evening.temperature', value: day.temp.eve }], // Used by the iOS apps, but not critical. // }; // delta.updates.push(update); // } // } } this.app.handleMessage(this.pluginId, delta); } /** * Handle 3 Hour forecast data: we support temperature, weather condition and wind speed/direction. * @param fc Three-hourly forecast data (standard units assumed). * @param pathStart The first part of the path to use in the graph. * @param interpolateHourly True to interpolate hourly values (future). */ handleThreeHourForecastResult(fc, pathStart, interpolateHourly) { this.app.debug(`handleThreeHourForecastResult(): location=${fc.city.name}, ${fc.city.country}, #elements=${fc.cnt}.`); if (interpolateHourly === true) { throw new Error('handleThreeHourForecastResult(): Hourly interpolation is not yet supported.'); } const timestampISOString = new Date().toISOString(); const delta = { context: 'vessels.self', updates: [], }; for (let idx = 0; idx < fc.cnt; idx++) { const item = fc.list[idx]; const forecastHour = new Date(item.dt * 1000); if (item.main.temp !== undefined) { const path = this.makeHourPath(pathStart, forecastHour, 'temperature'); const update = { source: this.source, timestamp: timestampISOString, values: [{ path: path, value: item.main.temp }], }; delta.updates.push(update); } if (item.weather && item.weather.length >= 1) { const path = this.makeHourPath(pathStart, forecastHour, 'conditionId'); const update = { source: this.source, timestamp: timestampISOString, values: [{ path: path, value: item.weather[0].id }], }; delta.updates.push(update); } if (item.wind.deg !== undefined && item.wind.speed !== undefined) { const pathDirection = this.makeHourPath(pathStart, forecastHour, 'winddirection'); const pathSpeed = this.makeHourPath(pathStart, forecastHour, 'windspeed'); const update = { source: this.source, timestamp: timestampISOString, values: [ { path: pathSpeed, value: item.wind.speed }, { path: pathDirection, value: (0, _1.roundToDecimalPlaces)(utils.transform(item.wind.deg, 'deg', 'rad'), 5) } ], }; delta.updates.push(update); } // let precipitation3h = 0; // precipitation3h += item.rain && item.rain['3h'] !== undefined ? item.rain['3h'] : 0; // precipitation3h += item.snow && item.snow['3h'] !== undefined ? item.snow['3h'] : 0; // const path = this.makeHourPath(pathStart, forecastHour, 'precipitationLast3Hours'); // const precipitation: ISignalKUpdate = { // source: this.source, // timestamp: timestampISOString, // values: [ // { path: path, value: precipitation3h }, // ], // }; // delta.updates.push(precipitation); } // @ts-ignore if (interpolateHourly === true) { for (let idx = 1; idx < fc.cnt; idx++) { const previousItem = fc.list[idx - 1]; const item = fc.list[idx]; const forecastHour1 = new Date((previousItem.dt + 60 * 60) * 1000); const forecastHour2 = new Date((previousItem.dt + 2 * 60 * 60) * 1000); if (item.main.temp !== undefined && previousItem.main.temp !== undefined) { const temp1 = this.interpolateNumber(item.main.temp, previousItem.main.temp, 1, 2); const path1 = this.makeHourPath(pathStart, forecastHour1, 'temperature'); const update1 = { source: this.source, timestamp: timestampISOString, values: [{ path: path1, value: (0, _1.roundToDecimalPlaces)(temp1, 2) }], }; delta.updates.push(update1); const temp2 = this.interpolateNumber(item.main.temp, previousItem.main.temp, 2, 1); const path2 = this.makeHourPath(pathStart, forecastHour2, 'temperature'); const update2 = { source: this.source, timestamp: timestampISOString, values: [{ path: path2, value: (0, _1.roundToDecimalPlaces)(temp2, 2) }], }; delta.updates.push(update2); } if (item.weather && item.weather.length >= 1 && previousItem.weather && previousItem.weather.length >= 1) { const condition1 = this.interpolateWeatherCondition(previousItem.weather[0].id, item.weather[0].id, 2, 1); const path1 = this.makeHourPath(pathStart, forecastHour1, 'conditionId'); const update1 = { source: this.source, timestamp: timestampISOString, values: [{ path: path1, value: condition1 }], }; delta.updates.push(update1); const condition2 = this.interpolateWeatherCondition(previousItem.weather[0].id, item.weather[0].id, 1, 2); const path2 = this.makeHourPath(pathStart, forecastHour2, 'conditionId'); const update2 = { source: this.source, timestamp: timestampISOString, values: [{ path: path2, value: condition2 }], }; delta.updates.push(update2); } if (item.wind !== undefined && previousItem.wind !== undefined) { if (item.wind.speed !== undefined && previousItem.wind.speed !== undefined) { const speed1 = this.interpolateNumber(item.wind.speed, previousItem.wind.speed, 1, 2); const path1 = this.makeHourPath(pathStart, forecastHour1, 'windspeed'); const update1 = { source: this.source, timestamp: timestampISOString, values: [{ path: path1, value: (0, _1.roundToDecimalPlaces)(speed1, 2) }], }; delta.updates.push(update1); const speed2 = this.interpolateNumber(item.wind.speed, previousItem.wind.speed, 2, 1); const path2 = this.makeHourPath(pathStart, forecastHour2, 'windspeed'); const update2 = { source: this.source, timestamp: timestampISOString, values: [{ path: path2, value: (0, _1.roundToDecimalPlaces)(speed2, 2) }], }; delta.updates.push(update2); } if (item.wind.deg !== undefined && previousItem.wind.deg !== undefined) { const deg1 = this.interpolateAngle(item.wind.deg, previousItem.wind.deg, 1, 2); const path1 = this.makeHourPath(pathStart, forecastHour1, 'winddirection'); const update1 = { source: this.source, timestamp: timestampISOString, values: [{ path: path1, value: (0, _1.roundToDecimalPlaces)(utils.transform(deg1, 'deg', 'rad'), 5) }], }; delta.updates.push(update1); const deg2 = this.interpolateAngle(item.wind.deg, previousItem.wind.deg, 2, 1); const path2 = this.makeHourPath(pathStart, forecastHour2, 'winddirection'); const update2 = { source: this.source, timestamp: timestampISOString, values: [{ path: path2, value: (0, _1.roundToDecimalPlaces)(utils.transform(deg2, 'deg', 'rad'), 5) }], }; delta.updates.push(update2); this.app.debug(`Wind interpolation: ${previousItem.dt_txt} ${previousItem.wind.deg} ${deg1.toFixed(4)} ${deg2.toFixed(4)} ${item.wind.deg}`); } } // Interpolate precipitation. For now we just distribute the 3hr value equally over three hours. let precipitation3h = 0; precipitation3h += item.rain && item.rain['3h'] !== undefined ? item.rain['3h'] : 0; precipitation3h += item.snow && item.snow['3h'] !== undefined ? item.snow['3h'] : 0; const path1 = this.makeHourPath(pathStart, forecastHour1, 'precipitationLastHour'); const update1 = { source: this.source, values: [{ path: path1, value: (0, _1.roundToDecimalPlaces)(precipitation3h / 3, 2) }], }; delta.updates.push(update1); const path2 = this.makeHourPath(pathStart, forecastHour2, 'precipitationLastHour'); const update2 = { source: this.source, values: [{ path: path2, value: (0, _1.roundToDecimalPlaces)(precipitation3h / 3, 2) }], }; delta.updates.push(update2); const path3 = this.makeHourPath(pathStart, new Date(item.dt * 1000), 'precipitationLastHour'); const update3 = { source: this.source, values: [{ path: path3, value: (0, _1.roundToDecimalPlaces)(precipitation3h / 3, 2) }], }; delta.updates.push(update3); } } this.app.handleMessage(this.pluginId, delta); } /** * Contruct a Path as: Prefix + Year + Month + Day. E.g. 'resources.weather.actual.forecast.2025-01-23.'. * @param dt Date to use. * @param prefix Prefix to use. * @returns Path value. */ datePath(dt, prefix) { const path = `${prefix}.${dt.getUTCFullYear().toString(10)}-${(dt.getUTCMonth() + 1).toString(10).padStart(2, '0')}-${dt.getUTCDate().toString(10).padStart(2, '0')}.`; return path; } makeDatePath(prefix, date, postfix) { return `${this.datePath(date, prefix)}${postfix}`; } /** * Create a path with yyyy-mm-ddThh:mm, e.g. 'resources.weather.actual.forecast.2023-08-31T12:00.conditionId'. * @param prefix Prefix to use, e.g. 'resources.weather.actual.forecast'. * @param date The date to use (the UTC values are to be used). * @param postfix Postfix to use, e.g. 'conditionId'. * @returns The path. */ makeHourPath(prefix, date, postfix) { const monthString = (date.getUTCMonth() + 1).toString(10).padStart(2, '0'); const dayString = date.getUTCDate().toString(10).padStart(2, '0'); const hourString = date.getUTCHours().toString(10).padStart(2, '0'); const path = `${prefix}.${date.getUTCFullYear().toString(10)}-${monthString}-${dayString}T${hourString}:00.${postfix}`; return path; } /** * Interpolate a number value. * @param number1 Number value 1. * @param number2 Number value 2. * @param w1 Weight for Number value 1. * @param w2 Weight for Number value 2. * @returns Interpolated value. */ interpolateNumber(number1, number2, w1, w2) { const temp = (number1 * w1 + number2 * w2) / (w1 + w2); return temp; } /** * Interpolate angle values. * @param angle1 angle in degrees * @param angle2 angle in degrees * @param w1 Weight for angle1. * @param w2 Weight for angle2. * @returns Interpolated angle in degrees. */ interpolateAngle(angle1, angle2, w1, w2) { // this.app.debug(`interpolateAngle(): ${angle1} * ${w1}, ${angle2} * ${w2}`); angle1 *= Math.PI / 180; angle2 *= Math.PI / 180; const sin1 = Math.sin(angle1); const cos1 = Math.cos(angle1); const sin2 = Math.sin(angle2); const cos2 = Math.cos(angle2); const averageSine = (sin1 * w1 + sin2 * w2) / (w1 + w2); const averageCosine = (cos1 * w1 + cos2 * w2) / (w1 + w2); if (Math.abs(averageSine) < 0.1 && Math.abs(averageCosine) < 0.1) { this.app.debug(`Could not interpolate angles ${angle1} ${angle2}`); return 0; } const angle = Math.atan2(averageSine, averageCosine) * 180.0 / Math.PI; return angle < 0.0 ? angle + 360 : angle; } interpolateWeatherCondition(c1, c2, w1, w2) { if (c1 === c2) { return c1; } // Storm. if (openweather_1.default.thunderstormConditionValues.includes(c1) && openweather_1.default.thunderstormConditionValues.includes(c2)) { const c = Math.round((w1 * c1 + w2 * c2) / (w1 + w2)); if (openweather_1.default.thunderstormConditionValues.includes(c)) { return c; } return w1 > w2 ? c1 : c2; } // Drizzle. if (openweather_1.default.drizzleConditionValues.includes(c1) && openweather_1.default.drizzleConditionValues.includes(c2)) { const c = Math.round((w1 * c1 + w2 * c2) / (w1 + w2)); if (openweather_1.default.drizzleConditionValues.includes(c)) { return c; } return w1 > w2 ? c1 : c2; } // Rain. if (openweather_1.default.rainConditionValues.includes(c1) && openweather_1.default.rainConditionValues.includes(c2)) { const c = Math.round((w1 * c1 + w2 * c2) / (w1 + w2)); if (openweather_1.default.rainConditionValues.includes(c)) { return c; } return w1 > w2 ? c1 : c2; } // Snow. if (openweather_1.default.snowConditionValues.includes(c1) && openweather_1.default.snowConditionValues.includes(c2)) { const c = Math.round((w1 * c1 + w2 * c2) / (w1 + w2)); if (openweather_1.default.snowConditionValues.includes(c)) { return c; } return w1 > w2 ? c1 : c2; } // Atmosphere. if (openweather_1.default.atmosphereConditionValues.includes(c1) && openweather_1.default.atmosphereConditionValues.includes(c2)) { // Interpolation is not sensible for this condition. } // Clear and Clouds. if (openweather_1.default.clearAndCloudConditionValues.includes(c1) && openweather_1.default.clearAndCloudConditionValues.includes(c2)) { const c = Math.round((w1 * c1 + w2 * c2) / (w1 + w2)); if (openweather_1.default.clearAndCloudConditionValues.includes(c)) { return c; } return w1 > w2 ? c1 : c2; } return w1 > w2 ? c1 : c2; } } exports.default = Updater; /** * Convert hectoPascal to Pascal. * @param pressure pressure in hPa. * @returns */ const hPaToPa = (pressure) => { // @todo A PR for this functionality in utils is in progress. const pressurePascal = utils.transform(pressure, 'hPa', 'Pa'); if (pressurePascal === pressure) { return (pressure * 100.0); } return pressurePascal; };