From fb1f4cacbe2eb6abba8bb4d78c8a8ad6e47cf443 Mon Sep 17 00:00:00 2001 From: javalikescript Date: Sat, 30 Nov 2024 10:10:52 +0100 Subject: [PATCH] Enhance OWM --- extensions/owm/adapter.lua | 101 ++++++++++++++++++ extensions/owm/manifest.json | 24 ++++- extensions/owm/owm.js | 99 +++++++++++++++++ extensions/owm/owm.lua | 124 ++++++++++++---------- extensions/owm/owm.xml | 28 +++++ extensions/web-base/www/app.css | 11 +- extensions/web-base/www/app/app.js | 20 +++- extensions/web-dashboard/web-dashboard.js | 13 +-- tools/owm.lua | 34 ++++++ 9 files changed, 380 insertions(+), 74 deletions(-) create mode 100644 extensions/owm/adapter.lua create mode 100644 extensions/owm/owm.js create mode 100644 extensions/owm/owm.xml create mode 100644 tools/owm.lua diff --git a/extensions/owm/adapter.lua b/extensions/owm/adapter.lua new file mode 100644 index 0000000..24b1b96 --- /dev/null +++ b/extensions/owm/adapter.lua @@ -0,0 +1,101 @@ +local List = require('jls.util.List') +local tables = require('jls.util.tables') + +local utils = require('lha.utils') + +-- temp_min temp_max +-- sys.sunrise: 1485720272 +-- sys.sunset: 1485766550 +-- city.name: "Paris" + +local FIELD_MAP = { + temperature = 'main/temp', + humidity = 'main/humidity', + pressure = 'main/pressure', + cloud = 'clouds/all', + windSpeed = 'wind/speed', + windDirection = 'wind/deg', + rain = 'rain/3h', -- Rain volume for last 3 hours +} + +local CUMULATIVE_FIELD_MAP = { + rain = true, +} + +local function adapt(w, d) + local a = {} + for k, p in pairs(FIELD_MAP) do + a[k] = tables.getPath(w, p, d) + end + a.date = utils.timeToString(w.dt) + return a +end + +local function adaptNil(w) + return adapt(w) +end + +local function sumFields(a, w) + for k, v in pairs(w) do + if type(v) == 'number' then + a[k] = (a[k] or 0) + v + else + a[k] = v + end + end + return a +end + +local function range(days, from, to, time) + local t = time or os.time() + local d = os.date('*t', t + 86400 * (days or 0)) + d.min = 15 + d.sec = 0 + d.hour = from or 0 + local ft = os.time(d) + d.hour = to or 23 + local tt = os.time(d) + return function(w) + return w.dt >= t and w.dt >= ft and w.dt < tt + end +end + +local function ranges(from, to, time) + local t = time or os.time() + local r3 = range(2, from, to, t) + local r4 = range(3, from, to, t) + local r5 = range(4, from, to, t) + return function(w) + return r3(w) or r4(w) or r5(w) + end +end + +local function aggregate(list) + local w = List.reduce(List.map(list, adaptNil), sumFields, adapt({}, 0)) + local a, n = {}, #list + for k, v in pairs(w) do + if type(v) == 'number' and not CUMULATIVE_FIELD_MAP[k] then + a[k] = (v * 100 // n) / 100 + else + a[k] = v + end + end + return a +end + +-- 5 day forecast includes weather forecast data with 3-hour step + +return { + computeCurrent = function(weather) + return adapt(weather) + end, + computeNextHours = function(forecast, time) + return aggregate(List.filter(forecast.list, range(0, 7, 19, time))) + end, + computeTomorrow = function(forecast, time) + return aggregate(List.filter(forecast.list, range(1, 7, 19, time))) + end, + computeNextDays = function(forecast, time) + return aggregate(List.filter(forecast.list, ranges(7, 19, time))) + end +} \ No newline at end of file diff --git a/extensions/owm/manifest.json b/extensions/owm/manifest.json index dfe401b..42c8ca3 100644 --- a/extensions/owm/manifest.json +++ b/extensions/owm/manifest.json @@ -18,9 +18,20 @@ "writeOnly": true, "required": true }, - "cityId": { - "title": "City ID", - "type": "string", + "latitude": { + "title": "Latitude", + "type": "number", + "default": 49.181, + "minimum": -90, + "maximum": 90, + "required": true + }, + "longitude": { + "title": "Longitude", + "type": "number", + "default": -0.370, + "minimum": -180, + "maximum": 180, "required": true }, "units": { @@ -33,7 +44,12 @@ "title": "Units of measurement", "type": "string" }, - "maxPollingDelay": { + "lang": { + "title": "language", + "type": "string", + "pattern": "^%a%a_?%a*$" + }, + "maxPollingDelay": { "title": "Minimum Call Interval in seconds", "type": "integer", "default": 600, diff --git a/extensions/owm/owm.js b/extensions/owm/owm.js new file mode 100644 index 0000000..2dd3042 --- /dev/null +++ b/extensions/owm/owm.js @@ -0,0 +1,99 @@ +define(['./owm.xml'], function(owmTemplate) { + + var unitAlias = app.getUnitAliases(); + var directionLabels = ['-', 'N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']; + var directions = ['\u21BB', '\u2191', '\u2197', '\u2192', '\u2198', '\u2193', '\u2199', '\u2190', '\u2196']; + + function compare(a, b) { + return a === b ? 0 : (a > b ? 1 : -1); + } + function compareTimes(a, b) { + return compare(a.time, b.time); + } + function formatIcon(props) { + if (props.rain) { + if (props.rain > 10) { + return 'cloud-showers-heavy'; + } + return props.cloud < 66 ? 'cloud-sun-rain' : 'cloud-rain'; + } else if (props.cloud > 33) { + return props.cloud < 66 ? 'cloud-sun' : 'cloud'; + } + return 'sun'; + } + function formatDirection(direction, speed, label) { + var i = 0; + if (typeof speed !== 'number' || speed > 0) { + i = Math.round(direction / 45) % 8 + 1; + } + if (label) { + return directionLabels[i]; + } + return directions[i]; + } + + var owmVue = new Vue({ + template: owmTemplate, + data: { + empty: true, + unit: {}, + times: [] + }, + methods: { + onDataChange: function() { + Promise.all([ + app.getThings(), + app.getPropertiesByThingId() + ]).then(apply(this, function(things, properties) { + this.refresh(things, properties); + })); + }, + onShow: function() { + this.onDataChange(); + }, + formatDirection: function(d) { + return formatDirection(d); + }, + extractUnits: function(thing) { + for (var name in thing.properties) { + var unit = thing.properties[name].unit; + if (unit) { + this.unit[name] = unitAlias[unit] || unit; + } + } + }, + refresh: function(things, properties) { + var now = Date.now(); + var times = []; + for (var i = 0; i < things.length; i++) { + var thing = things[i]; + if (thing.extensionId === 'owm') { + if (this.empty) { + this.empty = false; + this.extractUnits(thing); + //console.info('units:', this.unit); + } + var props = properties[thing.thingId]; + if (props) { + var t = props.date ? new Date(props.date).getTime() : 0; + var h = t > now ? Math.floor((t - now) / 3600000) : 0; + var item = Object.assign({ + title: thing.title, + label: h < 24 ? (h + 'h'): (Math.floor(h / 24) + 'd'), + time: t, + faIcon: 'fa-' + formatIcon(props) + }, props); + times.push(item); + } + } + } + times.sort(compareTimes); + //console.info('times:', times); + this.times = times; + } + } + }); + + addPageComponent(owmVue, 'fa-umbrella'); + +}); diff --git a/extensions/owm/owm.lua b/extensions/owm/owm.lua index 88b3181..b0a4c45 100644 --- a/extensions/owm/owm.lua +++ b/extensions/owm/owm.lua @@ -7,10 +7,10 @@ local Url = require('jls.net.Url') local Thing = require('lha.Thing') local utils = require('lha.utils') --- Helper classes and functions +local adapter = extension:require('adapter') -local function createWeatherThing(title) - local thing = Thing:new(title or 'Weather', 'Weather Data', { +local function createWeatherThing(title, description) + local thing = Thing:new(title or 'Weather', description or 'Weather Data', { Thing.CAPABILITIES.TemperatureSensor, Thing.CAPABILITIES.HumiditySensor, Thing.CAPABILITIES.BarometricPressureSensor, @@ -55,6 +55,15 @@ local function createWeatherThing(title) readOnly = true, unit = 'degree' }) + thing:addProperty('date', { + ['@type'] = "DateTimeProperty", + configuration = true, + description = "The date of the data", + readOnly = true, + title = "Date", + type = "string", + unit = "date time" + }) return thing end @@ -62,41 +71,31 @@ local function updateWeatherThing(thing, w) if not (thing and type(w) == 'table') then return end - if w.main then - -- temp_min temp_max - thing:updatePropertyValue('temperature', w.main.temp) - thing:updatePropertyValue('humidity', w.main.humidity) - thing:updatePropertyValue('pressure', w.main.pressure) - end - if w.clouds then - thing:updatePropertyValue('cloud', w.clouds.all) - end - if w.wind then - thing:updatePropertyValue('windSpeed', w.wind.speed) - thing:updatePropertyValue('windDirection', w.wind.deg) + for k, v in pairs(w) do + thing:updatePropertyValue(k, v) end - if w.rain and w.rain['3h'] then - thing:updatePropertyValue('rain', w.rain['3h']) - end - -- sys.sunrise: 1485720272 - -- sys.sunset: 1485766550 - -- city.name: "Paris" end --- End Helper classes and functions - local THINGS_BY_KEY = { - current = createWeatherThing("Weather Now"), - nextHours = createWeatherThing("Weather Next Hours"), - tomorrow = createWeatherThing("Weather Tomorrow"), - nextDays = createWeatherThing("Weather Next Days") + current = createWeatherThing('Weather Now'), + nextHours = createWeatherThing('Weather Next Hours'), + tomorrow = createWeatherThing('Weather Tomorrow'), + nextDays = createWeatherThing('Weather Next Days') } local thingByKey = {} local configuration = extension:getConfiguration() extension:subscribeEvent('startup', function() - logger:info('startup OpenWeatherMap extension') - logger:info('OpenWeatherMap city id is "%s"', configuration.cityId) + logger:info('OpenWeatherMap at %s - %s', configuration.latitude, configuration.longitude) + extension:getEngine():onExtension('web-base', function(webBaseExtension) + webBaseExtension:registerAddonExtension(extension, true) + end) +end) + +extension:subscribeEvent('shutdown', function() + extension:getEngine():onExtension('web-base', function(webBaseExtension) + webBaseExtension:unregisterAddonExtension(extension) + end) end) extension:subscribeEvent('things', function() @@ -115,29 +114,46 @@ extension:subscribeEvent('things', function() end end) --- Do not send OWM requests more than 1 time per 10 minutes from one device/one API key -extension:subscribePollEvent(function() - logger:info('poll OpenWeatherMap extension') - local url = Url:new(configuration.apiUrl or 'http://api.openweathermap.org/data/2.5/') - local path = url:getFile() - local query = '?'..Url.mapToQuery({ - id = configuration.cityId or '', - units = configuration.units or 'metric', - APPID = configuration.apiKey or '' - }) - local client = HttpClient:new(url) - return client:fetch(path..'weather'..query):next(utils.rejectIfNotOk):next(utils.getJson):next(function(data) - updateWeatherThing(thingByKey.current, data) - return client:fetch(path..'forecast'..query) - end):next(utils.rejectIfNotOk):next(utils.getJson):next(function(data) - if data and data.list and data.cnt and data.cnt > 7 then - updateWeatherThing(thingByKey.nextHours, data.list[1]) - updateWeatherThing(thingByKey.tomorrow, data.list[7]) - updateWeatherThing(thingByKey.nextDays, data.list[data.cnt - 1]) - end - end):catch(function(reason) - logger:warn('fail to get OWM, due to "%s"', reason) - end):finally(function() - client:close() +local function updateForecastThing(data, time) + updateWeatherThing(thingByKey.tomorrow, adapter.computeTomorrow(data, time)) + updateWeatherThing(thingByKey.nextHours, adapter.computeNextHours(data, time)) + updateWeatherThing(thingByKey.nextDays, adapter.computeNextDays(data, time)) +end + +local demo = false +if demo then + extension:subscribeEvent('poll', function() + local json = require('jls.util.json') + local File = require('jls.io.File') + local data = json.parse(File:new('work-misc/weather.json'):readAll()) + updateWeatherThing(thingByKey.current, adapter.computeCurrent(data)) + data = json.parse(File:new('work-misc/forecast.json'):readAll()) + local time = data.list[1].dt - 3600 + updateForecastThing(data, time) end) -end, configuration.maxPollingDelay) +else + -- Do not send OWM requests more than 1 time per 10 minutes from one device/one API key + extension:subscribePollEvent(function() + logger:info('poll OpenWeatherMap extension') + local url = Url:new(configuration.apiUrl or 'http://api.openweathermap.org/data/2.5/') + local path = url:getFile() + local query = '?'..Url.mapToQuery({ + lat = configuration.latitude, + lon = configuration.longitude, + units = configuration.units or 'metric', + lang = configuration.lang, + appid = configuration.apiKey + }) + local client = HttpClient:new(url) + return client:fetch(path..'weather'..query):next(utils.rejectIfNotOk):next(utils.getJson):next(function(data) + updateWeatherThing(thingByKey.current, adapter.computeCurrent(data)) + return client:fetch(path..'forecast'..query) + end):next(utils.rejectIfNotOk):next(utils.getJson):next(function(data) + updateForecastThing(data, os.time()) + end):catch(function(reason) + logger:warn('fail to get OWM, due to "%s"', reason) + end):finally(function() + client:close() + end) + end, configuration.maxPollingDelay) +end diff --git a/extensions/owm/owm.xml b/extensions/owm/owm.xml new file mode 100644 index 0000000..ced9e05 --- /dev/null +++ b/extensions/owm/owm.xml @@ -0,0 +1,28 @@ + +
+
+
+ +
+
+
+
diff --git a/extensions/web-base/www/app.css b/extensions/web-base/www/app.css index 76740c3..11c7ad1 100644 --- a/extensions/web-base/www/app.css +++ b/extensions/web-base/www/app.css @@ -330,12 +330,13 @@ article.tiles, .tile-container { padding: 1rem; width: calc(100% - 2rem); } +.unit { + font-size: 0.8em; +} .tile-row { width: 100%; } -.tile { - height: 18rem; - width: 18rem; +.tile, .tile-free { background: transparent; border: 1px solid transparent; border-radius: 0.5rem; @@ -347,6 +348,10 @@ article.tiles, .tile-container { overflow: hidden; cursor: pointer; } +.tile { + height: 18rem; + width: 18rem; +} .tile:hover { background-color: rgba(0,0,0,0.01); } diff --git a/extensions/web-base/www/app/app.js b/extensions/web-base/www/app/app.js index ff42cb5..dff7b66 100644 --- a/extensions/web-base/www/app/app.js +++ b/extensions/web-base/www/app/app.js @@ -15,6 +15,21 @@ var fetchInitNoCache = { cache: 'no-store' }; +var unitAlias = { + "ampere": "A", + "degree": "°", + "degree celsius": "°C", + "hectopascal": "hPa", + "hertz": "Hz", + "kelvin": "K", + "lux": "lx", + "meter/sec": "m/s", + "percent": "%", + "volt": "V", + "voltampere": "VA", + "watt": "W" +}; + /************************************************************ * Main application ************************************************************/ @@ -125,7 +140,7 @@ var app = new Vue({ } } } - this.callPage(this.page, 'onDataChange'); + this.callPage(this.page, 'onDataChange', message.data); } break; case 'logs': @@ -172,6 +187,9 @@ var app = new Vue({ }); }); }, + getUnitAliases: function() { + return unitAlias; + }, getThings: function() { return this.fetchWithCache('/engine/things', function(things) { if (Array.isArray(things)) { diff --git a/extensions/web-dashboard/web-dashboard.js b/extensions/web-dashboard/web-dashboard.js index d64a527..465df6a 100644 --- a/extensions/web-dashboard/web-dashboard.js +++ b/extensions/web-dashboard/web-dashboard.js @@ -10,18 +10,7 @@ define(['./web-dashboard.xml'], function(dashboardTemplate) { "AlarmSensor": "AlarmProperty" }; - var unitAlias = { - "ampere": "A", - "degree celsius": "°C", - "hectopascal": "hPa", - "hertz": "Hz", - "kelvin": "K", - "lux": "lx", - "percent": "%", - "volt": "V", - "voltampere": "VA", - "watt": "W" - }; + var unitAlias = app.getUnitAliases(); var unitByType = { "BarometricPressureProperty": "hectopascal", diff --git a/tools/owm.lua b/tools/owm.lua new file mode 100644 index 0000000..9553a7b --- /dev/null +++ b/tools/owm.lua @@ -0,0 +1,34 @@ + +local json = require('jls.util.json') +local File = require('jls.io.File') +local Map = require('jls.util.Map') + +local utils = require('lha.utils') +local adapter = require('extensions.owm.adapter') + +local data = json.parse(File:new(arg[1]):readAll()) + +local function printWeather(w, l) + if l then + print(l) + end + for k, v in Map.spairs(w) do + print(string.format(' %s: %s', k, v)) + end +end + +if data.list then + local time = data.list[1].dt - 3600 + print('', 'date', 'temp', 'rain', 'cloud', 'wind') + for i, w in ipairs(data.list) do + local d = utils.timeToString(w.dt) + print(i, d, w.main and w.main.temp or 0, w.rain and w.rain['3h'] or 0, w.clouds and w.clouds.all or 0, w.wind and w.wind.speed or 0) + end + print() + print('time', utils.timeToString(time)) + printWeather(adapter.computeNextHours(data, time), 'nextHours') + printWeather(adapter.computeTomorrow(data, time), 'tomorrow') + printWeather(adapter.computeNextDays(data, time), 'nextDays') +else + printWeather(adapter.computeCurrent(data), 'current') +end