-- OpenWeatherMap domain knowledge and cache parsing. -- -- Separate from lib/data.lua: that reads /proc and /sys, this reads a cached -- HTTP response and carries its own tables. Same testable shape though, every -- function takes a string or numbers and returns a value, so the tests need no -- filesystem and no network. -- -- Nothing here raises. A Lua error in this project is a blank screen, so every -- parse path returns nil and lets the widget draw its "no data" state. local M = {} -- Nerd Font codepoints, all present in Inconsolata Nerd Font (card.FONT_MONO). -- Verified by rendering the glyphs and looking at them, not by fontconfig -- alone: a missing glyph draws as an invisible blank rather than an error. M.ICON = { thunder_day = '\u{E30F}', thunder_night = '\u{E32A}', drizzle_day = '\u{E306}', drizzle_night = '\u{E326}', drizzle_heavy_day = '\u{E308}', drizzle_heavy_night = '\u{E325}', rain_day = '\u{E308}', rain_night = '\u{E325}', snow = '\u{E31A}', fog = '\u{E313}', tornado = '\u{E351}', clear_day = '\u{E30D}', clear_night = '\u{E32B}', few_day = '\u{E302}', few_night = '\u{E379}', overcast = '\u{E312}', unknown = '\u{E374}', sunrise = '\u{E34C}', sunset = '\u{E34D}', } -- Condition id to icon, by upper bound. Ported from the polybar script's -- accumulated knowledge; the ranges are not round hundreds. -- `now`, `sunrise` and `sunset` are unix timestamps and pick the day or night -- face for the conditions that have both. function M.icon(id, now, sunrise, sunset) if type(id) ~= 'number' then return M.ICON.unknown end -- `now` is guarded alongside sunrise and sunset, not just them: comparing a -- nil now against a number raises, and a raise here is a blank dashboard. -- Missing any of the three means the sun is unknown, so the day face is -- drawn, which is what is_day() falls back to as well. local day = true if now and sunrise and sunset then day = (now >= sunrise and now <= sunset) end local function pick(d, n) return day and d or n end if id <= 232 then return pick(M.ICON.thunder_day, M.ICON.thunder_night) elseif id <= 311 then return pick(M.ICON.drizzle_day, M.ICON.drizzle_night) elseif id <= 321 then return pick(M.ICON.drizzle_heavy_day, M.ICON.drizzle_heavy_night) elseif id <= 531 then return pick(M.ICON.rain_day, M.ICON.rain_night) elseif id <= 622 then return M.ICON.snow elseif id <= 771 then return M.ICON.fog elseif id == 781 then return M.ICON.tornado elseif id == 800 then return pick(M.ICON.clear_day, M.ICON.clear_night) elseif id == 801 then return pick(M.ICON.few_day, M.ICON.few_night) elseif id <= 804 then return M.ICON.overcast end return M.ICON.unknown end -- OWM's metric units report wind in m/s. The card shows km/h. function M.kmh(ms) if type(ms) ~= 'number' then return nil end return ms * 3.6 end -- Beaufort force from km/h. Upper bounds carried over from the polybar -- script; a value equal to a bound stays in the lower force. -- -- The scale starts at 0 and ipairs starts at 1, so force N's upper bound is -- BEAUFORT[N + 1] and the loop index is already N + 1. Hence `force - 1` -- below. The scale tops out at 12, which is why the fall-through returns it. local BEAUFORT = { 1, 5, 11, 19, 28, 38, 49, 61, 74, 88, 102, 117 } function M.beaufort(kmh) if type(kmh) ~= 'number' then return nil end for force, bound in ipairs(BEAUFORT) do if kmh <= bound then return force - 1 end end return 12 end -- Eight compass points. The half-step offset is what makes north straddle -- zero: without it, 350 degrees would land in NW and 10 in NE, leaving north -- with only half its arc. -- -- Nothing calls compass() yet: the card draws arrow() instead. It is kept -- because it shares arrow()'s binning exactly, so testing both pins that -- shared logic from two angles, and swapping the card to read "S 9 km/h" -- instead of an arrow is then a one-word change rather than new code. local POINTS = { 'N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW' } function M.compass(deg) if type(deg) ~= 'number' then return nil end local i = math.floor(((deg % 360) + 22.5) / 45) % 8 return POINTS[i + 1] end -- Arrow glyphs, in the same order as POINTS but rotated half a turn: OWM -- reports the direction the wind comes FROM, and an arrow reads as the -- direction it goes TO. local ARROWS = { '\u{2193}', '\u{2199}', '\u{2190}', '\u{2196}', '\u{2191}', '\u{2197}', '\u{2192}', '\u{2198}' } function M.arrow(deg) if type(deg) ~= 'number' then return '' end local i = math.floor(((deg % 360) + 22.5) / 45) % 8 return ARROWS[i + 1] end -- Fraction of daylight elapsed, clamped to 0..1. -- -- OWM returns TODAY's sunrise and sunset, so before dawn this is negative and -- after dusk it is over 1. Clamping parks the dot at the corresponding end of -- the arc, which is the cheap correct-enough behaviour; multi-day astronomy -- buys nothing for a dot on a curve. function M.sun_t(now, sunrise, sunset) if type(now) ~= 'number' or type(sunrise) ~= 'number' or type(sunset) ~= 'number' then return nil end local span = sunset - sunrise if span <= 0 then return 0 end -- polar day/night or bad data: no division -- ponytail: a NaN timestamp slips through, since NaN is a number and every -- comparison against it is false, so both clamps below fall through and this -- returns NaN. Unreachable today: parse() matches %d+ for the sun times and -- `now` is os.time(). Cairo ignores a NaN coordinate, so the cost would be a -- missing dot, not a crash. Guard it here if a caller ever computes `now`. local t = (now - sunrise) / span if t < 0 then return 0 elseif t > 1 then return 1 end return t end -- Whether the sun is up. Inclusive at both ends, matching the polybar script's -- comparison, so the instant of sunrise draws the day face. function M.is_day(now, sunrise, sunset) if type(now) ~= 'number' or type(sunrise) ~= 'number' or type(sunset) ~= 'number' then return true end return now >= sunrise and now <= sunset end -- Parse the cached OWM response. -- -- Lua patterns, not a JSON library: the current-weather response is flat and -- its shape is known, so six scalars do not justify a dependency. Returns nil -- on anything unreadable, never an error. function M.parse(src) if type(src) ~= 'string' or src == '' then return nil end -- Scalars are matched inside their own object where the key would otherwise -- be ambiguous. "temp" appears as a prefix of "temp_min" and "temp_max", so -- it is anchored to the character that follows it. local function num(pat) return tonumber(src:match(pat)) end local main = src:match('"main"%s*:%s*(%b{})') or '' local wind = src:match('"wind"%s*:%s*(%b{})') or '' local sys = src:match('"sys"%s*:%s*(%b{})') or '' local cond = src:match('"weather"%s*:%s*%[%s*(%b{})') or '' local w = { id = tonumber(cond:match('"id"%s*:%s*(%-?%d+)')), description = cond:match('"description"%s*:%s*"([^"]*)"'), -- The exponent is accepted although OWM has never been seen to emit one: -- a pattern stopping at the 'e' would read 1.8e1 as 1.8 and draw 2 degrees -- instead of 18, silently and plausibly. Cheaper to accept than to detect. temp = tonumber(main:match('"temp"%s*:%s*(%-?[%d%.eE%+%-]+)')), feels_like = tonumber(main:match('"feels_like"%s*:%s*(%-?[%d%.eE%+%-]+)')), humidity = tonumber(main:match('"humidity"%s*:%s*(%d+)')), wind_speed = tonumber(wind:match('"speed"%s*:%s*([%d%.eE%+%-]+)')), wind_deg = tonumber(wind:match('"deg"%s*:%s*(%d+)')), sunrise = tonumber(sys:match('"sunrise"%s*:%s*(%d+)')), sunset = tonumber(sys:match('"sunset"%s*:%s*(%d+)')), city = src:match('"name"%s*:%s*"([^"]*)"'), dt = num('"dt"%s*:%s*(%d+)'), } -- Without these the card has nothing to draw, and a partial card is worse -- than an honest "no data". An API error body fails here too: it carries a -- cod and a message, no weather. if not (w.id and w.temp and w.sunrise and w.sunset) then return nil end return w end -- Three missed fetches at the 15-minute interval. M.STALE_AFTER = 45 * 60 -- `now` is guarded alongside `dt` although every caller passes os.time(), -- which cannot be nil: these were the only two functions in the module that -- did arithmetic on an unchecked argument, and the module's promise is that -- nothing raises. A raise here is a blank dashboard. function M.is_stale(dt, now) if type(dt) ~= 'number' or type(now) ~= 'number' then return true end return (now - dt) > M.STALE_AFTER end -- Short age for the marker beside the city: "12m", "3h", "2d". -- -- A negative age is clamped to zero rather than printed. An observation -- timestamped in the future means a clock skew somewhere, and "stale -17m" on -- the card reads as a broken widget; treating it as fresh is both tidier and -- truer, since weather from the future is not old. function M.age_str(dt, now) if type(dt) ~= 'number' or type(now) ~= 'number' then return '?' end local s = now - dt if s < 0 then s = 0 end if s < 3600 then return math.floor(s / 60) .. 'm' end if s < 86400 then return math.floor(s / 3600) .. 'h' end return math.floor(s / 86400) .. 'd' end return M