-------------------------------------------------
-- Weather Widget based on the WeatherAPI
-- https://weatherapi.com/
--
-- @author Pavel Makhov
-- @copyright 2020 Pavel Makhov
-- @copyright 2024 André Jaenisch
-------------------------------------------------
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 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 SYS_LANG = os.getenv("LANG"):sub(1, 2)
if SYS_LANG == "C" or SYS_LANG == "C." then
-- C-locale is a common fallback for simple English
SYS_LANG = "en"
end
-- default language is ENglish
local LANG = gears.filesystem.file_readable(WIDGET_DIR .. "/" .. "locale/" ..
SYS_LANG .. ".lua") and SYS_LANG or "en"
local LCLE = require("awesome-wm-widgets.weather-widget.locale." .. LANG)
-- WeatherAPI supports only these according to https://www.weatherapi.com/docs/
-- ar, bn, bg, zh, zh_tw, cs, da, nl, fi, fr, de, el, hi, hu, it, ja, jv, ko,
-- zh_cmn, mr, pl, pt, pa, ro, ru, sr, si, sk, es, sv, ta, te, tr, uk, ur, vi,
-- zh_wuu, zh_hsn, zh_yue, zu
local function show_warning(message)
naughty.notify {
preset = naughty.config.presets.critical,
title = LCLE.warning_title,
text = message
}
end
if SYS_LANG ~= LANG then
show_warning("Your language is not supported yet. Language set to English")
end
local weather_widget = {}
local warning_shown = false
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},
hide_on_right_click = true,
widget = {}
}
--- Maps WeatherAPI condition code to file name w/o extension
--- See https://www.weatherapi.com/docs/#weather-icons
--- Day/Night is determined at time of mapping the weather to an icon
local icon_map = {
[1000] = "clear-sky",
[1003] = "few-clouds",
[1006] = "scattered-clouds",
[1009] = "scattered-clouds",
[1030] = "mist",
[1063] = "rain",
[1066] = "snow",
[1069] = "rain",
[1072] = "snow",
[1087] = "thunderstorm",
[1114] = "snow",
[1117] = "snow",
[1135] = "mist",
[1147] = "mist",
[1150] = "snow",
[1153] = "snow",
[1168] = "snow",
[1171] = "snow",
[1180] = "rain",
[1183] = "rain",
[1186] = "rain",
[1189] = "rain",
[1192] = "rain",
[1195] = "rain",
[1198] = "rain",
[1201] = "rain",
[1204] = "snow",
[1207] = "snow",
[1210] = "snow",
[1213] = "snow",
[1216] = "snow",
[1219] = "snow",
[1222] = "snow",
[1225] = "snow",
[1237] = "snow",
[1240] = "rain",
[1243] = "rain",
[1246] = "rain",
[1249] = "snow",
[1252] = "snow",
[1255] = "snow",
[1258] = "snow",
[1261] = "snow",
[1264] = "snow",
[1273] = "thunderstorm",
[1276] = "thunderstorm",
[1279] = "thunderstorm",
[1282] = "thunderstorm"
}
--- 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(user_args)
local args = user_args or {}
--- Validate required parameters
if args.coordinates == nil or args.api_key == nil then
show_warning(LCLE.parameter_warning ..
(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 icon_pack_name = args.icons or 'weather-underground-icons'
local icons_extension = args.icons_extension or '.png'
local show_forecast_on_hover = args.show_forecast_on_hover or false
local show_daily_forecast = args.show_daily_forecast or false
local show_hourly_forecast = args.show_hourly_forecast or false
local timeout = args.timeout or 120
local ICONS_DIR = WIDGET_DIR .. '/icons/' .. icon_pack_name .. '/'
-- Forecast endpoint includes current. I could map show_daily_forecast to days here.
-- Currently overfetching but only showing when opting in.
local weather_api =
('https://api.weatherapi.com/v1/forecast.json' ..
'?q=' .. coordinates[1] .. ',' .. coordinates[2] .. '&key=' .. api_key ..
'&units=' .. units .. '&lang=' .. LANG .. '&days=3')
weather_widget = wibox.widget {
{
{
{
{
id = 'icon',
resize = true,
widget = wibox.widget.imagebox
},
valign = 'center',
widget = wibox.container.place,
},
{
id = 'txt',
widget = wibox.widget.textbox
},
layout = wibox.layout.fixed.horizontal,
},
left = 4,
right = 4,
layout = wibox.container.margin
},
shape = function(cr, width, height)
gears.shape.rounded_rect(cr, width, height, 4)
end,
widget = wibox.container.background,
set_image = function(self, 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 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 .. ' 36',
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)
local day_night_extension = ""
if not weather.is_day then
day_night_extension = "-night"
end
self:get_children_by_id('icon')[1]:set_image(
ICONS_DIR .. icon_map[weather.condition.code] .. day_night_extension .. icons_extension)
self:get_children_by_id('temp')[1]:set_text(gen_temperature_str(weather.temp_c, '%.0f', false, units))
self:get_children_by_id('feels_like_temp')[1]:set_text(
LCLE.feels_like .. gen_temperature_str(weather.feelslike_c, '%.0f', false, units))
self:get_children_by_id('description')[1]:set_text(weather.condition.text)
self:get_children_by_id('wind')[1]:set_markup(
LCLE.wind .. '' .. weather.wind_kph .. 'km/h (' .. weather.wind_dir .. ')')
self:get_children_by_id('humidity')[1]:set_markup(LCLE.humidity .. '' .. weather.humidity .. '%')
self:get_children_by_id('uv')[1]:set_markup(LCLE.uv .. uvi_index_color(weather.uv))
end
}
local daily_forecast_widget = {
forced_width = 300,
layout = wibox.layout.flex.horizontal,
update = function(self, forecast)
local count = #self
for i = 0, count do self[i] = nil end
for i, day in ipairs(forecast) do
-- Free plan allows forecast for up to three days, each with hours
if i > 3 then break end
local day_night_extension = ""
if not day.is_day then
day_night_extension = "-night"
end
local day_forecast = wibox.widget {
{
text = os.date('%a', tonumber(day.date_epoch)),
align = 'center',
font = font_name .. ' 9',
widget = wibox.widget.textbox
},
{
{
{
image = ICONS_DIR
.. icon_map[day.day.condition.code]
.. day_night_extension
.. icons_extension,
resize = true,
forced_width = 48,
forced_height = 48,
widget = wibox.widget.imagebox
},
align = 'center',
layout = wibox.container.place
},
{
text = day.day.condition.text,
font = font_name .. ' 8',
align = 'center',
forced_height = 50,
widget = wibox.widget.textbox
},
layout = wibox.layout.fixed.vertical
},
{
{
text = gen_temperature_str(day.day.mintemp_c, '%.0f', false, units),
align = 'center',
font = font_name .. ' 9',
widget = wibox.widget.textbox
},
{
text = gen_temperature_str(day.day.maxtemp_c, '%.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
}
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 hourly_forecast_negative_graph = wibox.widget {
step_width = 12,
color = '#5E81AC',
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 hourly_forecast_widget = {
layout = wibox.layout.fixed.vertical,
update = function(self, hourly)
local hours_below = {
id = 'hours',
forced_width = 300,
layout = wibox.layout.flex.horizontal
}
local temp_below = {
id = 'temp',
forced_width = 300,
layout = wibox.layout.flex.horizontal
}
local max_temp = -1000
local min_temp = 1000
local values= {}
-- Yeah, this looks weird. I would expect to have to use ipairs
for i, hour in pairs(hourly) do
if i > 25 then
break
end
values[i] = hour.temp_c
if max_temp < hour.temp_c then
max_temp = hour.temp_c
end
if min_temp > hour.temp_c then
min_temp = hour.temp_c
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.time_epoch)),
align = 'center',
font = font_name .. ' 9',
widget = wibox.widget.textbox
})
table.insert(temp_below, wibox.widget {
markup = ''
.. string.format('%.0f', hour.temp_c) .. '°' .. '',
align = 'center',
font = font_name .. ' 9',
widget = wibox.widget.textbox
})
end
end
hourly_forecast_graph:set_max_value(math.max(max_temp, math.abs(min_temp)))
hourly_forecast_graph:set_min_value(min_temp > 0 and min_temp * 0.7 or 0) -- move graph a bit up
hourly_forecast_negative_graph:set_max_value(math.abs(min_temp))
hourly_forecast_negative_graph:set_min_value(max_temp < 0 and math.abs(max_temp) * 0.7 or 0)
for _, value in ipairs(values) do
if value >= 0 then
hourly_forecast_graph:add_value(value)
hourly_forecast_negative_graph:add_value(0)
else
hourly_forecast_graph:add_value(0)
hourly_forecast_negative_graph:add_value(math.abs(value))
end
end
local count = #self
for i = 0, count do
self[i] = nil
end
-- all temperatures are positive
if min_temp > 0 then
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)
-- all temperatures are negative
elseif max_temp < 0 then
table.insert(self, hours_below)
table.insert(self, wibox.widget {
{
hourly_forecast_negative_graph,
reflection = { horizontal = true, vertical = true },
widget = wibox.container.mirror
},
{
temp_below,
valign = 'top',
widget = wibox.container.place
},
id = 'graph',
layout = wibox.layout.stack
})
-- mixed temperatures
else
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, wibox.widget {
{
hourly_forecast_negative_graph,
reflection = { horizontal = true, vertical = true },
widget = wibox.container.mirror
},
{
temp_below,
valign = 'top',
widget = wibox.container.place
},
id = 'graph',
layout = wibox.layout.stack
})
end
end
}
local function update_widget(widget, stdout, stderr)
if stderr ~= '' then
if not warning_shown then
if (stderr ~= 'curl: (52) Empty reply from server'
and stderr ~= 'curl: (28) Failed to connect to api.weatherapi.com port 443: Connection timed out'
and stderr:find('^curl: %(18%) transfer closed with %d+ bytes remaining to read$') ~= nil
) then
show_warning(stderr)
end
warning_shown = true
widget:is_ok(false)
tooltip:add_to_object(widget)
widget:connect_signal('mouse::enter', function() tooltip.text = stderr end)
end
return
end
if string.match(stdout, '<') ~= nil then
if not warning_shown then
warning_shown = true
widget:is_ok(false)
tooltip:add_to_object(widget)
widget:connect_signal('mouse::enter', function() tooltip.text = stdout end)
end
return
end
warning_shown = false
tooltip:remove_from_object(widget)
widget:is_ok(true)
local result = json.decode(stdout)
local day_night_extension = ""
if not result.current.is_day then
day_night_extension = "-night"
end
widget:set_image(ICONS_DIR .. icon_map[result.current.condition.code] .. day_night_extension .. icons_extension)
-- TODO: if units isn't "metric", read temp_f instead
widget:set_text(gen_temperature_str(result.current.temp_c, '%.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 show_hourly_forecast then
hourly_forecast_widget:update(result.forecast.forecastday[1].hour)
table.insert(final_widget, hourly_forecast_widget)
end
if show_daily_forecast then
daily_forecast_widget:update(result.forecast.forecastday)
table.insert(final_widget, daily_forecast_widget)
end
weather_popup:setup({
{
final_widget,
margins = 10,
widget = wibox.container.margin
},
bg = beautiful.bg_normal,
widget = wibox.container.background
})
end
weather_widget:buttons(gears.table.join(awful.button({}, 1, function()
if weather_popup.visible then
weather_widget:set_bg('#00000000')
weather_popup.visible = not weather_popup.visible
else
weather_widget:set_bg(beautiful.bg_focus)
weather_popup:move_next_to(mouse.current_widget_geometry)
end
end)))
weather_widget:connect_signal("mouse::enter", function()
if show_forecast_on_hover then
weather_widget:set_bg(beautiful.bg_focus)
weather_popup:move_next_to(mouse.current_widget_geometry)
end
end)
weather_widget:connect_signal("mouse::leave", function()
if show_forecast_on_hover and weather_popup.visible then
weather_widget:set_bg('#00000000')
weather_popup.visible = not weather_popup.visible
end
end)
watch(
string.format(GET_FORECAST_CMD, weather_api),
timeout, -- API limit is 1k req/day; day has 1440 min; every 2 min is good
update_widget, weather_widget
)
return weather_widget
end
return setmetatable(weather_widget, {__call = function(_, ...) return worker(...) end})