From bcbde507eb857a67a25fe415a133fd7e639a6bdf Mon Sep 17 00:00:00 2001 From: streetturtle Date: Sun, 12 Jul 2020 17:36:28 -0400 Subject: [weather] fix #161 + redesign with breaking change --- weather-widget/weather.lua | 581 +++++++++++++++++++++++++++++++-------------- 1 file changed, 406 insertions(+), 175 deletions(-) (limited to 'weather-widget/weather.lua') diff --git a/weather-widget/weather.lua b/weather-widget/weather.lua index 5aaf426..4fcdba7 100644 --- a/weather-widget/weather.lua +++ b/weather-widget/weather.lua @@ -3,225 +3,456 @@ -- https://openweathermap.org/ -- -- @author Pavel Makhov --- @copyright 2018 Pavel Makhov +-- @copyright 2020 Pavel Makhov ------------------------------------------------- - -local socket = require("socket") -local http = require("socket.http") -local ltn12 = require("ltn12") +local awful = require("awful") +local watch = require("awful.widget.watch") local json = require("json") local naughty = require("naughty") local wibox = require("wibox") local gears = require("gears") +local beautiful = require("beautiful") -local path_to_icons = "/usr/share/icons/Arc/status/symbolic/" +local HOME_DIR = os.getenv("HOME") +local WIDGET_DIR = HOME_DIR .. '/.config/awesome/awesome-wm-widgets/weather-widget' +local GET_FORECAST_CMD = [[bash -c "curl -s --show-error -X GET '%s'"]] + +local function show_warning(message) + naughty.notify { + preset = naughty.config.presets.critical, + title = 'Weather Widget', + text = message + } +end local weather_widget = {} +local warning_shown = false +local notification +local tooltip = awful.tooltip { + mode = 'outside', + preferred_positions = {'bottom'} +} + +local weather_popup = awful.popup { + ontop = true, + visible = false, + shape = gears.shape.rounded_rect, + border_width = 1, + border_color = beautiful.bg_focus, + maximum_width = 400, + offset = {y = 5}, + widget = {} +} + +--- Maps openWeatherMap icon name to file name w/o extension +local icon_map = { + ["01d"] = "clear-sky", + ["02d"] = "few-clouds", + ["03d"] = "scattered-clouds", + ["04d"] = "broken-clouds", + ["09d"] = "shower-rain", + ["10d"] = "rain", + ["11d"] = "thunderstorm", + ["13d"] = "snow", + ["50d"] = "mist", + ["01n"] = "clear-sky-night", + ["02n"] = "few-clouds-night", + ["03n"] = "scattered-clouds-night", + ["04n"] = "broken-clouds-night", + ["09n"] = "shower-rain-night", + ["10n"] = "rain-night", + ["11n"] = "thunderstorm-night", + ["13n"] = "snow-night", + ["50n"] = "mist-night" +} + +--- Return wind direction as a string +local function to_direction(degrees) + -- Ref: https://www.campbellsci.eu/blog/convert-wind-directions + if degrees == nil then return "Unknown dir" end + local directions = { + "N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", + "WSW", "W", "WNW", "NW", "NNW", "N" + } + return directions[math.floor((degrees % 360) / 22.5) + 1] +end + +--- Convert degrees Celsius to Fahrenheit +local function celsius_to_fahrenheit(c) return c * 9 / 5 + 32 end + +-- Convert degrees Fahrenheit to Celsius +local function fahrenheit_to_celsius(f) return (f - 32) * 5 / 9 end + +local function gen_temperature_str(temp, fmt_str, show_other_units, units) + local temp_str = string.format(fmt_str, temp) + local s = temp_str .. '°' .. (units == 'metric' and 'C' or 'F') + + if (show_other_units) then + local temp_conv, units_conv + if (units == 'metric') then + temp_conv = celsius_to_fahrenheit(temp) + units_conv = 'F' + else + temp_conv = fahrenheit_to_celsius(temp) + units_conv = 'C' + end + + local temp_conv_str = string.format(fmt_str, temp_conv) + s = s .. ' ' .. '(' .. temp_conv_str .. '°' .. units_conv .. ')' + end + return s +end + +local function uvi_index_color(uvi) + local color + if uvi >= 0 and uvi < 3 then color = '#A3BE8C' + elseif uvi >= 3 and uvi < 6 then color = '#EBCB8B' + elseif uvi >= 6 and uvi < 8 then color = '#D08770' + elseif uvi >= 8 and uvi < 11 then color = '#BF616A' + elseif uvi >= 11 then color = '#B48EAD' + end + + return '' .. uvi .. '' +end local function worker(args) local args = args or {} - local font = args.font or 'Play 9' - local city = args.city or 'Montreal,ca' - local api_key = args.api_key or naughty.notify{preset = naughty.config.presets.critical, text = 'OpenweatherMap API key is not set'} + --- Validate required parameters + if args.coordinates == nil or args.api_key == nil then + show_warning('Required parameters are not set: ' .. + (args.coordinates == nil and 'coordinates' or '') .. + (args.api_key == nil and ', api_key ' or '')) + return + end + + local coordinates = args.coordinates + local api_key = args.api_key + local font_name = args.font_name or beautiful.font:gsub("%s%d+$", "") local units = args.units or 'metric' + local time_format_12h = args.time_format_12h local both_units_widget = args.both_units_widget or false - local both_units_popup = args.both_units_popup or false - local position = args.notification_position or "top_right" + local show_hourly_forecast = args.show_hourly_forecast + local show_daily_forecast = args.show_daily_forecast + local icon_pack_name = args.icons or 'weather-underground-icons' + local icons_extension = args.icons_extension or '.png' - local weather_api_url = ( - 'https://api.openweathermap.org/data/2.5/weather' - .. '?q=' .. city - .. '&appid=' .. api_key - .. '&units=' .. units - ) + local owm_one_cal_api = + ('https://api.openweathermap.org/data/2.5/onecall' .. + '?lat=' .. coordinates[1] .. '&lon=' .. coordinates[2] .. '&appid=' .. api_key .. + '&units=' .. units .. '&exclude=minutely' .. + (show_hourly_forecast == false and ',hourly' or '') .. + (show_daily_forecast == false and ',daily' or '')) - local icon_widget = wibox.widget { + weather_widget = wibox.widget { + { + { + id = 'icon', + resize = true, + widget = wibox.widget.imagebox + }, + valign = 'center', + widget = wibox.container.place, + }, { - id = "icon", - resize = false, - widget = wibox.widget.imagebox, + id = 'txt', + widget = wibox.widget.textbox }, - layout = wibox.container.margin(_, 0, 0, 3), + layout = wibox.layout.fixed.horizontal, set_image = function(self, path) - self.icon.image = path + self:get_children_by_id('icon')[1].image = path end, + set_text = function(self, text) + self:get_children_by_id('txt')[1].text = text + end, + is_ok = function(self, is_ok) + if is_ok then + self:get_children_by_id('icon')[1]:set_opacity(1) + self:get_children_by_id('icon')[1]:emit_signal('widget:redraw_needed') + else + self:get_children_by_id('icon')[1]:set_opacity(0.2) + self:get_children_by_id('icon')[1]:emit_signal('widget:redraw_needed') + end + end } - local temp_widget = wibox.widget { - font = font, - widget = wibox.widget.textbox, + local current_weather_widget = wibox.widget { + { + { + { + id = 'icon', + resize = true, + forced_width = 128, + forced_height = 128, + widget = wibox.widget.imagebox + }, + align = 'center', + widget = wibox.container.place + }, + { + id = 'description', + font = font_name .. ' 10', + align = 'center', + widget = wibox.widget.textbox + }, + forced_width = 128, + layout = wibox.layout.align.vertical + }, + { + { + { + id = 'temp', + font = font_name .. ' 48', + widget = wibox.widget.textbox + }, + { + id = 'feels_like_temp', + align = 'center', + font = font_name .. ' 9', + widget = wibox.widget.textbox + }, + layout = wibox.layout.fixed.vertical + }, + { + { + id = 'wind', + font = font_name .. ' 9', + widget = wibox.widget.textbox + }, + { + id = 'humidity', + font = font_name .. ' 9', + widget = wibox.widget.textbox + }, + { + id = 'uv', + font = font_name .. ' 9', + widget = wibox.widget.textbox + }, + expand = 'inside', + layout = wibox.layout.align.vertical + }, + spacing = 16, + forced_width = 150, + layout = wibox.layout.fixed.vertical + }, + forced_width = 300, + layout = wibox.layout.flex.horizontal, + update = function(self, weather) + self:get_children_by_id('icon')[1]:set_image(WIDGET_DIR .. '/icons/' .. icon_pack_name .. '/' .. icon_map[weather.weather[1].icon] .. icons_extension) + self:get_children_by_id('temp')[1]:set_text(gen_temperature_str(weather.temp, '%.0f', false, units)) + self:get_children_by_id('feels_like_temp')[1]:set_text('Feels like ' .. gen_temperature_str(weather.feels_like, '%.0f', false, units)) + self:get_children_by_id('description')[1]:set_text(weather.weather[1].description) + self:get_children_by_id('wind')[1]:set_markup('Wind: ' .. weather.wind_speed .. 'm/s (' .. to_direction(weather.wind_deg) .. ')') + self:get_children_by_id('humidity')[1]:set_markup('Humidity: ' .. weather.humidity .. '%') + self:get_children_by_id('uv')[1]:set_markup('UV: ' .. uvi_index_color(weather.uvi)) + end } - weather_widget = wibox.widget { - icon_widget, - temp_widget, - layout = wibox.layout.fixed.horizontal, - } - --- Maps openWeatherMap icons to Arc icons - local icon_map = { - ["01d"] = "weather-clear-symbolic.svg", - ["02d"] = "weather-few-clouds-symbolic.svg", - ["03d"] = "weather-clouds-symbolic.svg", - ["04d"] = "weather-overcast-symbolic.svg", - ["09d"] = "weather-showers-scattered-symbolic.svg", - ["10d"] = "weather-showers-symbolic.svg", - ["11d"] = "weather-storm-symbolic.svg", - ["13d"] = "weather-snow-symbolic.svg", - ["50d"] = "weather-fog-symbolic.svg", - ["01n"] = "weather-clear-night-symbolic.svg", - ["02n"] = "weather-few-clouds-night-symbolic.svg", - ["03n"] = "weather-clouds-night-symbolic.svg", - ["04n"] = "weather-overcast-symbolic.svg", - ["09n"] = "weather-showers-scattered-symbolic.svg", - ["10n"] = "weather-showers-symbolic.svg", - ["11n"] = "weather-storm-symbolic.svg", - ["13n"] = "weather-snow-symbolic.svg", - ["50n"] = "weather-fog-symbolic.svg" + local daily_forecast_widget = { + forced_width = 300, + layout = wibox.layout.flex.horizontal, + update = function(self, forecast, timezone_offset) + local count = #self + for i = 0, count do self[i]=nil end + for i, day in ipairs(forecast) do + if i > 5 then break end + local day_forecast = wibox.widget { + { + text = os.date('%a', tonumber(day.dt) + tonumber(timezone_offset)), + align = 'center', + font = font_name .. ' 9', + widget = wibox.widget.textbox + }, + { + { + { + image = WIDGET_DIR .. '/icons/' .. icon_pack_name .. '/' .. icon_map[day.weather[1].icon] .. icons_extension, + resize = true, + forced_width = 48, + forced_height = 48, + widget = wibox.widget.imagebox + }, + align = 'center', + layout = wibox.container.place + }, + { + text = day.weather[1].description, + font = font_name .. ' 8', + align = 'center', + forced_height = 50, + widget = wibox.widget.textbox + }, + layout = wibox.layout.fixed.vertical + }, + { + { + text = gen_temperature_str(day.temp.day, '%.0f', false, units), + align = 'center', + font = font_name .. ' 9', + widget = wibox.widget.textbox + }, + { + text = gen_temperature_str(day.temp.night, '%.0f', false, units), + align = 'center', + font = font_name .. ' 9', + widget = wibox.widget.textbox + }, + layout = wibox.layout.fixed.vertical + }, + spacing = 8, + layout = wibox.layout.fixed.vertical + } + table.insert(self, day_forecast) + end + end } - --- Return wind direction as a string. - local function to_direction(degrees) - -- Ref: https://www.campbellsci.eu/blog/convert-wind-directions - if degrees == nil then - return "Unknown dir" + local hourly_forecast_graph = wibox.widget { + step_width = 12, + color = '#EBCB8B', + background_color = beautiful.bg_normal, + forced_height = 100, + forced_width = 300, + widget = wibox.widget.graph, + set_max_value = function(self, new_max_value) + self.max_value = new_max_value + end, + set_min_value = function(self, new_min_value) + self.min_value = new_min_value end - local directions = { - "N", - "NNE", - "NE", - "ENE", - "E", - "ESE", - "SE", - "SSE", - "S", - "SSW", - "SW", - "WSW", - "W", - "WNW", - "NW", - "NNW", - "N", - } - return directions[math.floor((degrees % 360) / 22.5) + 1] - end + } - -- Convert degrees Celsius to Fahrenheit - local function celsius_to_fahrenheit(c) - return c*9/5+32 - end + local hourly_forecast_widget = { + layout = wibox.layout.fixed.vertical, + update = function(self, hourly) + local hours_below = { + id = 'hours', + layout = wibox.layout.flex.horizontal + } + local temp_below = { + id = 'temp', + forced_width = 300, + layout = wibox.layout.flex.horizontal + } - -- Convert degrees Fahrenheit to Celsius - local function fahrenheit_to_celsius(f) - return (f-32)*5/9 - end - - local weather_timer = gears.timer({ timeout = 60 }) - local resp + local max_temp = -1000 + local min_temp = 1000 + local values = {} + for i, hour in ipairs(hourly) do + if i > 25 then break end + values[i] = hour.temp + if max_temp < hour.temp then max_temp = hour.temp end + if min_temp > hour.temp then min_temp = hour.temp end + if (i - 1) % 5 == 0 then + table.insert(hours_below, wibox.widget { + text = os.date(time_format_12h and '%I%p' or '%H:00', tonumber(hour.dt)), + align = 'center', + font = font_name .. ' 9', + widget = wibox.widget.textbox + }) + table.insert(temp_below, wibox.widget { + markup = '' .. string.format('%.0f', hour.temp) .. '°' .. '', + align = 'center', + font = font_name .. ' 9', + widget = wibox.widget.textbox + }) + end + end + hourly_forecast_graph:set_max_value(max_temp) + hourly_forecast_graph:set_min_value(min_temp * 0.7) -- move graph a bit up + for i, value in ipairs(values) do + hourly_forecast_graph:add_value(value) + end - local function gen_temperature_str(temp, fmt_str, show_other_units) - local temp_str = string.format(fmt_str, temp) - local s = temp_str .. '°' .. (units == 'metric' and 'C' or 'F') + local count = #self + for i = 0, count do self[i]=nil end - if (show_other_units) then - local temp_conv, units_conv - if (units == 'metric') then - temp_conv = celsius_to_fahrenheit(temp) - units_conv = 'F' - else - temp_conv = fahrenheit_to_celsius(temp) - units_conv = 'C' - end - local temp_conv_str = string.format(fmt_str, temp_conv) - s = s .. ' ' .. '('.. temp_conv_str .. '°' .. units_conv .. ')' + table.insert(self, temp_below) + table.insert(self, wibox.widget{ + { + hourly_forecast_graph, + reflection = {horizontal = true}, + widget = wibox.container.mirror + }, + { + temp_below, + valign = 'bottom', + widget = wibox.container.place + }, + id = 'graph', + layout = wibox.layout.stack + }) + table.insert(self, hours_below) end - return s - end + } - local function error_display(resp_json) - weather_timer.timeout = math.min(15 * 60, weather_timer.timeout * 2) - weather_timer:again() - local err_resp = json.decode(resp_json) - naughty.notify{ - title = 'Weather Widget Error', - text = err_resp.message, - preset = naughty.config.presets.critical, - } - end + local function update_widget(widget, stdout, stderr) + if stderr ~= '' then + if not warning_shown then + show_warning(stderr) + warning_shown = true + widget:is_ok(false) + tooltip:add_to_object(widget) - weather_timer:connect_signal("timeout", function () - local resp_json = {} - local res, status = http.request{ - url=weather_api_url, - sink=ltn12.sink.table(resp_json), - -- ref: - -- http://w3.impa.br/~diego/software/luasocket/old/luasocket-2.0/http.html - create=function() - -- ref: https://stackoverflow.com/a/6021774/595220 - local req_sock = socket.tcp() - -- 't' — overall timeout - req_sock:settimeout(0.2, 't') - -- 'b' — block timeout - req_sock:settimeout(0.001, 'b') - return req_sock + widget:connect_signal('mouse::enter', function() tooltip.text = stderr end) end + return + end + + warning_shown = false + tooltip:remove_from_object(widget) + widget:is_ok(true) + + local result = json.decode(stdout) + + widget:set_image(WIDGET_DIR .. '/icons/' .. icon_pack_name .. '/' .. icon_map[result.current.weather[1].icon] .. icons_extension) + widget:set_text(gen_temperature_str(result.current.temp, '%.0f', both_units_widget, units)) + + current_weather_widget:update(result.current) + + local final_widget = { + current_weather_widget, + spacing = 16, + layout = wibox.layout.fixed.vertical } - if (resp_json ~= nil) then - resp_json = table.concat(resp_json) + + if show_hourly_forecast then + hourly_forecast_widget:update(result.hourly) + table.insert(final_widget, hourly_forecast_widget) end - if (status ~= 200 and resp_json ~= nil and resp_json ~= '') then - if (not pcall(error_display, resp_json)) then - naughty.notify{ - title = 'Weather Widget Error', - text = 'Cannot parse answer', - preset = naughty.config.presets.critical, - } - end - elseif (resp_json ~= nil and resp_json ~= '') then - resp = json.decode(resp_json) - icon_widget.image = path_to_icons .. icon_map[resp.weather[1].icon] - temp_widget:set_text(gen_temperature_str(resp.main.temp, '%.0f', both_units_widget)) - weather_timer.timeout = 60 - weather_timer:again() + if show_daily_forecast then + daily_forecast_widget:update(result.daily, result.timezone_offset) + table.insert(final_widget, daily_forecast_widget) end - end) - weather_timer:start() - weather_timer:emit_signal("timeout") - - --- Notification with weather information. Popups when mouse hovers over the icon - local notification - weather_widget:connect_signal("mouse::enter", function() - notification = naughty.notify{ - icon = path_to_icons .. icon_map[resp.weather[1].icon], - icon_size=20, - text = - '' .. resp.weather[1].main .. ' (' .. resp.weather[1].description .. ')
' .. - 'Humidity: ' .. resp.main.humidity .. '%
' .. - 'Temperature: ' .. gen_temperature_str(resp.main.temp, '%.1f', - both_units_popup) .. '
' .. - 'Pressure: ' .. resp.main.pressure .. 'hPa
' .. - 'Clouds: ' .. resp.clouds.all .. '%
' .. - 'Wind: ' .. resp.wind.speed .. 'm/s (' .. to_direction(resp.wind.deg) .. ')', - timeout = 5, hover_timeout = 10, - position = position, - screen = mouse.screen, - width = (both_units_popup == true and 210 or 200) - } - end) - weather_widget:connect_signal("mouse::leave", function() - naughty.destroy(notification) - end) + weather_popup:setup({ + { + final_widget, + margins = 10, + widget = wibox.container.margin + }, + bg = beautiful.bg_normal, + widget = wibox.container.background + }) + end + + weather_widget:buttons(awful.util.table.join(awful.button({}, 1, function() + if weather_popup.visible then + weather_popup.visible = not weather_popup.visible + else + weather_popup:move_next_to(mouse.current_widget_geometry) + end + end))) + + -- watch('cat /home/pmakhov/.config/awesome/awesome-wm-widgets/weather-widget/weather.json', 5, update_widget, weather_widget) + watch(string.format(GET_FORECAST_CMD, owm_one_cal_api), 5, update_widget, weather_widget) return weather_widget end -return setmetatable(weather_widget, { __call = function(_, ...) - return worker(...) -end }) +return setmetatable(weather_widget, {__call = function(_, ...) return worker(...) end}) -- cgit v1.2.3