weather-widget.lua 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583
  1. -------------------------------------------------
  2. -- Weather Widget based on the OpenWeatherMap
  3. -- https://openweathermap.org/
  4. --
  5. -- @author Pavel Makhov
  6. -- @copyright 2020 Pavel Makhov
  7. -------------------------------------------------
  8. local awful = require("awful")
  9. local watch = require("awful.widget.watch")
  10. local json = require("json")
  11. local naughty = require("naughty")
  12. local wibox = require("wibox")
  13. local gears = require("gears")
  14. local beautiful = require("beautiful")
  15. local HOME_DIR = os.getenv("HOME")
  16. local WIDGET_DIR = HOME_DIR .. '/.config/awesome/awesome-wm-widgets/weather-widget'
  17. local GET_FORECAST_CMD = [[bash -c "curl -s --show-error -X GET '%s'"]]
  18. local SYS_LANG = os.getenv("LANG"):sub(1, 2)
  19. if SYS_LANG == "C" or SYS_LANG == "C." then
  20. -- C-locale is a common fallback for simple English
  21. SYS_LANG = "en"
  22. end
  23. -- default language is ENglish
  24. local LANG = gears.filesystem.file_readable(WIDGET_DIR .. "/" .. "locale/" ..
  25. SYS_LANG .. ".lua") and SYS_LANG or "en"
  26. local LCLE = require("awesome-wm-widgets.weather-widget.locale." .. LANG)
  27. local function show_warning(message)
  28. naughty.notify {
  29. preset = naughty.config.presets.critical,
  30. title = LCLE.warning_title,
  31. text = message
  32. }
  33. end
  34. if SYS_LANG ~= LANG then
  35. show_warning("Your language is not supported yet. Language set to English")
  36. end
  37. local weather_widget = {}
  38. local warning_shown = false
  39. local tooltip = awful.tooltip {
  40. mode = 'outside',
  41. preferred_positions = {'bottom'}
  42. }
  43. local weather_popup = awful.popup {
  44. ontop = true,
  45. visible = false,
  46. shape = gears.shape.rounded_rect,
  47. border_width = 1,
  48. border_color = beautiful.bg_focus,
  49. maximum_width = 400,
  50. offset = {y = 5},
  51. hide_on_right_click = true,
  52. widget = {}
  53. }
  54. --- Maps openWeatherMap icon name to file name w/o extension
  55. local icon_map = {
  56. ["01d"] = "clear-sky",
  57. ["02d"] = "few-clouds",
  58. ["03d"] = "scattered-clouds",
  59. ["04d"] = "broken-clouds",
  60. ["09d"] = "shower-rain",
  61. ["10d"] = "rain",
  62. ["11d"] = "thunderstorm",
  63. ["13d"] = "snow",
  64. ["50d"] = "mist",
  65. ["01n"] = "clear-sky-night",
  66. ["02n"] = "few-clouds-night",
  67. ["03n"] = "scattered-clouds-night",
  68. ["04n"] = "broken-clouds-night",
  69. ["09n"] = "shower-rain-night",
  70. ["10n"] = "rain-night",
  71. ["11n"] = "thunderstorm-night",
  72. ["13n"] = "snow-night",
  73. ["50n"] = "mist-night"
  74. }
  75. --- Return wind direction as a string
  76. local function to_direction(degrees)
  77. -- Ref: https://www.campbellsci.eu/blog/convert-wind-directions
  78. if degrees == nil then return "Unknown dir" end
  79. local directions = LCLE.directions
  80. return directions[math.floor((degrees % 360) / 22.5) + 1]
  81. end
  82. --- Convert degrees Celsius to Fahrenheit
  83. local function celsius_to_fahrenheit(c) return c * 9 / 5 + 32 end
  84. -- Convert degrees Fahrenheit to Celsius
  85. local function fahrenheit_to_celsius(f) return (f - 32) * 5 / 9 end
  86. local function gen_temperature_str(temp, fmt_str, show_other_units, units)
  87. local temp_str = string.format(fmt_str, temp)
  88. local s = temp_str .. '°' .. (units == 'metric' and 'C' or 'F')
  89. if (show_other_units) then
  90. local temp_conv, units_conv
  91. if (units == 'metric') then
  92. temp_conv = celsius_to_fahrenheit(temp)
  93. units_conv = 'F'
  94. else
  95. temp_conv = fahrenheit_to_celsius(temp)
  96. units_conv = 'C'
  97. end
  98. local temp_conv_str = string.format(fmt_str, temp_conv)
  99. s = s .. ' ' .. '(' .. temp_conv_str .. '°' .. units_conv .. ')'
  100. end
  101. return s
  102. end
  103. local function uvi_index_color(uvi)
  104. local color
  105. if uvi >= 0 and uvi < 3 then color = '#A3BE8C'
  106. elseif uvi >= 3 and uvi < 6 then color = '#EBCB8B'
  107. elseif uvi >= 6 and uvi < 8 then color = '#D08770'
  108. elseif uvi >= 8 and uvi < 11 then color = '#BF616A'
  109. elseif uvi >= 11 then color = '#B48EAD'
  110. end
  111. return '<span weight="bold" foreground="' .. color .. '">' .. uvi .. '</span>'
  112. end
  113. local function worker(user_args)
  114. local args = user_args or {}
  115. --- Validate required parameters
  116. if args.coordinates == nil or args.api_key == nil then
  117. show_warning(LCLE.parameter_warning ..
  118. (args.coordinates == nil and '<b>coordinates</b>' or '') ..
  119. (args.api_key == nil and ', <b>api_key</b> ' or ''))
  120. return
  121. end
  122. local coordinates = args.coordinates
  123. local api_key = args.api_key
  124. local font_name = args.font_name or beautiful.font:gsub("%s%d+$", "")
  125. local units = args.units or 'metric'
  126. local time_format_12h = args.time_format_12h
  127. local both_units_widget = args.both_units_widget or false
  128. local show_hourly_forecast = args.show_hourly_forecast
  129. local show_daily_forecast = args.show_daily_forecast
  130. local icon_pack_name = args.icons or 'weather-underground-icons'
  131. local icons_extension = args.icons_extension or '.png'
  132. local timeout = args.timeout or 120
  133. local ICONS_DIR = WIDGET_DIR .. '/icons/' .. icon_pack_name .. '/'
  134. local owm_one_cal_api =
  135. ('https://api.openweathermap.org/data/2.5/onecall' ..
  136. '?lat=' .. coordinates[1] .. '&lon=' .. coordinates[2] .. '&appid=' .. api_key ..
  137. '&units=' .. units .. '&exclude=minutely' ..
  138. (show_hourly_forecast == false and ',hourly' or '') ..
  139. (show_daily_forecast == false and ',daily' or '') ..
  140. '&lang=' .. LANG)
  141. weather_widget = wibox.widget {
  142. {
  143. {
  144. {
  145. {
  146. id = 'icon',
  147. resize = true,
  148. widget = wibox.widget.imagebox
  149. },
  150. valign = 'center',
  151. widget = wibox.container.place,
  152. },
  153. {
  154. id = 'txt',
  155. font = args.font_name .. ' 11',
  156. widget = wibox.widget.textbox
  157. },
  158. layout = wibox.layout.fixed.horizontal,
  159. },
  160. left = 4,
  161. right = 4,
  162. layout = wibox.container.margin
  163. },
  164. shape = function(cr, width, height)
  165. gears.shape.rounded_rect(cr, width, height, 4)
  166. end,
  167. widget = wibox.container.background,
  168. set_image = function(self, path)
  169. self:get_children_by_id('icon')[1].image = path
  170. end,
  171. set_text = function(self, text)
  172. self:get_children_by_id('txt')[1].text = text
  173. end,
  174. is_ok = function(self, is_ok)
  175. if is_ok then
  176. self:get_children_by_id('icon')[1]:set_opacity(1)
  177. self:get_children_by_id('icon')[1]:emit_signal('widget:redraw_needed')
  178. else
  179. self:get_children_by_id('icon')[1]:set_opacity(0.2)
  180. self:get_children_by_id('icon')[1]:emit_signal('widget:redraw_needed')
  181. end
  182. end
  183. }
  184. local current_weather_widget = wibox.widget {
  185. {
  186. {
  187. {
  188. id = 'icon',
  189. resize = true,
  190. forced_width = 128,
  191. forced_height = 128,
  192. widget = wibox.widget.imagebox
  193. },
  194. align = 'center',
  195. widget = wibox.container.place
  196. },
  197. {
  198. id = 'description',
  199. font = font_name .. ' 10',
  200. align = 'center',
  201. widget = wibox.widget.textbox
  202. },
  203. forced_width = 128,
  204. layout = wibox.layout.align.vertical
  205. },
  206. {
  207. {
  208. {
  209. id = 'temp',
  210. font = font_name .. ' 36',
  211. widget = wibox.widget.textbox
  212. },
  213. {
  214. id = 'feels_like_temp',
  215. align = 'center',
  216. font = font_name .. ' 9',
  217. widget = wibox.widget.textbox
  218. },
  219. layout = wibox.layout.fixed.vertical
  220. },
  221. {
  222. {
  223. id = 'wind',
  224. font = font_name .. ' 9',
  225. widget = wibox.widget.textbox
  226. },
  227. {
  228. id = 'humidity',
  229. font = font_name .. ' 9',
  230. widget = wibox.widget.textbox
  231. },
  232. {
  233. id = 'uv',
  234. font = font_name .. ' 9',
  235. widget = wibox.widget.textbox
  236. },
  237. expand = 'inside',
  238. layout = wibox.layout.align.vertical
  239. },
  240. spacing = 16,
  241. forced_width = 150,
  242. layout = wibox.layout.fixed.vertical
  243. },
  244. forced_width = 300,
  245. layout = wibox.layout.flex.horizontal,
  246. update = function(self, weather)
  247. self:get_children_by_id('icon')[1]:set_image(
  248. ICONS_DIR .. icon_map[weather.weather[1].icon] .. icons_extension)
  249. self:get_children_by_id('temp')[1]:set_text(gen_temperature_str(weather.temp, '%.0f', false, units))
  250. self:get_children_by_id('feels_like_temp')[1]:set_text(
  251. LCLE.feels_like .. gen_temperature_str(weather.feels_like, '%.0f', false, units))
  252. self:get_children_by_id('description')[1]:set_text(weather.weather[1].description)
  253. self:get_children_by_id('wind')[1]:set_markup(
  254. LCLE.wind .. '<b>' .. weather.wind_speed .. 'm/s (' .. to_direction(weather.wind_deg) .. ')</b>')
  255. self:get_children_by_id('humidity')[1]:set_markup(LCLE.humidity .. '<b>' .. weather.humidity .. '%</b>')
  256. self:get_children_by_id('uv')[1]:set_markup(LCLE.uv .. uvi_index_color(weather.uvi))
  257. end
  258. }
  259. local daily_forecast_widget = {
  260. forced_width = 300,
  261. layout = wibox.layout.flex.horizontal,
  262. update = function(self, forecast, timezone_offset)
  263. local count = #self
  264. for i = 0, count do self[i]=nil end
  265. for i, day in ipairs(forecast) do
  266. if i > 5 then break end
  267. local day_forecast = wibox.widget {
  268. {
  269. text = os.date('%a', tonumber(day.dt) + tonumber(timezone_offset)),
  270. align = 'center',
  271. font = font_name .. ' 9',
  272. widget = wibox.widget.textbox
  273. },
  274. {
  275. {
  276. {
  277. image = ICONS_DIR .. icon_map[day.weather[1].icon] .. icons_extension,
  278. resize = true,
  279. forced_width = 48,
  280. forced_height = 48,
  281. widget = wibox.widget.imagebox
  282. },
  283. align = 'center',
  284. layout = wibox.container.place
  285. },
  286. {
  287. text = day.weather[1].description,
  288. font = font_name .. ' 8',
  289. align = 'center',
  290. forced_height = 50,
  291. widget = wibox.widget.textbox
  292. },
  293. layout = wibox.layout.fixed.vertical
  294. },
  295. {
  296. {
  297. text = gen_temperature_str(day.temp.day, '%.0f', false, units),
  298. align = 'center',
  299. font = font_name .. ' 9',
  300. widget = wibox.widget.textbox
  301. },
  302. {
  303. text = gen_temperature_str(day.temp.night, '%.0f', false, units),
  304. align = 'center',
  305. font = font_name .. ' 9',
  306. widget = wibox.widget.textbox
  307. },
  308. layout = wibox.layout.fixed.vertical
  309. },
  310. spacing = 8,
  311. layout = wibox.layout.fixed.vertical
  312. }
  313. table.insert(self, day_forecast)
  314. end
  315. end
  316. }
  317. local hourly_forecast_graph = wibox.widget {
  318. step_width = 12,
  319. color = '#EBCB8B',
  320. background_color = beautiful.bg_normal,
  321. forced_height = 100,
  322. forced_width = 300,
  323. widget = wibox.widget.graph,
  324. set_max_value = function(self, new_max_value)
  325. self.max_value = new_max_value
  326. end,
  327. set_min_value = function(self, new_min_value)
  328. self.min_value = new_min_value
  329. end
  330. }
  331. local hourly_forecast_negative_graph = wibox.widget {
  332. step_width = 12,
  333. color = '#5E81AC',
  334. background_color = beautiful.bg_normal,
  335. forced_height = 100,
  336. forced_width = 300,
  337. widget = wibox.widget.graph,
  338. set_max_value = function(self, new_max_value)
  339. self.max_value = new_max_value
  340. end,
  341. set_min_value = function(self, new_min_value)
  342. self.min_value = new_min_value
  343. end
  344. }
  345. local hourly_forecast_widget = {
  346. layout = wibox.layout.fixed.vertical,
  347. update = function(self, hourly)
  348. local hours_below = {
  349. id = 'hours',
  350. forced_width = 300,
  351. layout = wibox.layout.flex.horizontal
  352. }
  353. local temp_below = {
  354. id = 'temp',
  355. forced_width = 300,
  356. layout = wibox.layout.flex.horizontal
  357. }
  358. local max_temp = -1000
  359. local min_temp = 1000
  360. local values = {}
  361. for i, hour in ipairs(hourly) do
  362. if i > 25 then break end
  363. values[i] = hour.temp
  364. if max_temp < hour.temp then max_temp = hour.temp end
  365. if min_temp > hour.temp then min_temp = hour.temp end
  366. if (i - 1) % 5 == 0 then
  367. table.insert(hours_below, wibox.widget {
  368. text = os.date(time_format_12h and '%I%p' or '%H:00', tonumber(hour.dt)),
  369. align = 'center',
  370. font = font_name .. ' 9',
  371. widget = wibox.widget.textbox
  372. })
  373. table.insert(temp_below, wibox.widget {
  374. markup = '<span foreground="'
  375. .. (tonumber(hour.temp) > 0 and '#2E3440' or '#ECEFF4') .. '">'
  376. .. string.format('%.0f', hour.temp) .. '°' .. '</span>',
  377. align = 'center',
  378. font = font_name .. ' 9',
  379. widget = wibox.widget.textbox
  380. })
  381. end
  382. end
  383. hourly_forecast_graph:set_max_value(math.max(max_temp, math.abs(min_temp)))
  384. hourly_forecast_graph:set_min_value(min_temp > 0 and min_temp * 0.7 or 0) -- move graph a bit up
  385. hourly_forecast_negative_graph:set_max_value(math.abs(min_temp))
  386. hourly_forecast_negative_graph:set_min_value(max_temp < 0 and math.abs(max_temp) * 0.7 or 0)
  387. for _, value in ipairs(values) do
  388. if value >= 0 then
  389. hourly_forecast_graph:add_value(value)
  390. hourly_forecast_negative_graph:add_value(0)
  391. else
  392. hourly_forecast_graph:add_value(0)
  393. hourly_forecast_negative_graph:add_value(math.abs(value))
  394. end
  395. end
  396. local count = #self
  397. for i = 0, count do self[i]=nil end
  398. -- all temperatures are positive
  399. if min_temp > 0 then
  400. table.insert(self, wibox.widget{
  401. {
  402. hourly_forecast_graph,
  403. reflection = {horizontal = true},
  404. widget = wibox.container.mirror
  405. },
  406. {
  407. temp_below,
  408. valign = 'bottom',
  409. widget = wibox.container.place
  410. },
  411. id = 'graph',
  412. layout = wibox.layout.stack
  413. })
  414. table.insert(self, hours_below)
  415. -- all temperatures are negative
  416. elseif max_temp < 0 then
  417. table.insert(self, hours_below)
  418. table.insert(self, wibox.widget{
  419. {
  420. hourly_forecast_negative_graph,
  421. reflection = {horizontal = true, vertical = true},
  422. widget = wibox.container.mirror
  423. },
  424. {
  425. temp_below,
  426. valign = 'top',
  427. widget = wibox.container.place
  428. },
  429. id = 'graph',
  430. layout = wibox.layout.stack
  431. })
  432. -- there are both negative and positive temperatures
  433. else
  434. table.insert(self, wibox.widget{
  435. {
  436. hourly_forecast_graph,
  437. reflection = {horizontal = true},
  438. widget = wibox.container.mirror
  439. },
  440. {
  441. temp_below,
  442. valign = 'bottom',
  443. widget = wibox.container.place
  444. },
  445. id = 'graph',
  446. layout = wibox.layout.stack
  447. })
  448. table.insert(self, wibox.widget{
  449. {
  450. hourly_forecast_negative_graph,
  451. reflection = {horizontal = true, vertical = true},
  452. widget = wibox.container.mirror
  453. },
  454. {
  455. hours_below,
  456. valign = 'top',
  457. widget = wibox.container.place
  458. },
  459. id = 'graph',
  460. layout = wibox.layout.stack
  461. })
  462. end
  463. end
  464. }
  465. local function update_widget(widget, stdout, stderr)
  466. if stderr ~= '' then
  467. if not warning_shown then
  468. if (stderr ~= 'curl: (52) Empty reply from server'
  469. and stderr ~= 'curl: (28) Failed to connect to api.openweathermap.org port 443: Connection timed out'
  470. and stderr:find('^curl: %(18%) transfer closed with %d+ bytes remaining to read$') ~= nil
  471. ) then
  472. show_warning(stderr)
  473. end
  474. warning_shown = true
  475. widget:is_ok(false)
  476. tooltip:add_to_object(widget)
  477. widget:connect_signal('mouse::enter', function() tooltip.text = stderr end)
  478. end
  479. return
  480. end
  481. warning_shown = false
  482. tooltip:remove_from_object(widget)
  483. widget:is_ok(true)
  484. local result = json.decode(stdout)
  485. widget:set_image(ICONS_DIR .. icon_map[result.current.weather[1].icon] .. icons_extension)
  486. widget:set_text(gen_temperature_str(result.current.temp, '%.0f', both_units_widget, units))
  487. current_weather_widget:update(result.current)
  488. local final_widget = {
  489. current_weather_widget,
  490. spacing = 16,
  491. layout = wibox.layout.fixed.vertical
  492. }
  493. if show_hourly_forecast then
  494. hourly_forecast_widget:update(result.hourly)
  495. table.insert(final_widget, hourly_forecast_widget)
  496. end
  497. if show_daily_forecast then
  498. daily_forecast_widget:update(result.daily, result.timezone_offset)
  499. table.insert(final_widget, daily_forecast_widget)
  500. end
  501. weather_popup:setup({
  502. {
  503. final_widget,
  504. margins = 10,
  505. widget = wibox.container.margin
  506. },
  507. bg = beautiful.bg_normal,
  508. widget = wibox.container.background
  509. })
  510. end
  511. weather_widget:buttons(gears.table.join(awful.button({}, 1, function()
  512. if weather_popup.visible then
  513. weather_widget:set_bg('#00000000')
  514. weather_popup.visible = not weather_popup.visible
  515. else
  516. weather_widget:set_bg(beautiful.bg_focus)
  517. weather_popup:move_next_to(mouse.current_widget_geometry)
  518. end
  519. end)))
  520. watch(
  521. string.format(GET_FORECAST_CMD, owm_one_cal_api),
  522. timeout, -- API limit is 1k req/day; day has 1440 min; every 2 min is good
  523. update_widget, weather_widget
  524. )
  525. -- -- Set fg and bg colors for weather_widget
  526. -- local weather_widget_clr = wibox.widget.background()
  527. -- weather_widget_clr:set_widget(weather_widget)
  528. -- weather_widget_clr:set_fg("#eb7bef")
  529. --
  530. -- return weather_widget_clr
  531. return weather_widget
  532. end
  533. return setmetatable(weather_widget, {__call = function(_, ...) return worker(...) end})