-- Open-Meteo domain knowledge and cache parsing. -- -- Separate from lib/data.lua: that reads /proc and /sys, this reads the cache -- bin/weather-fetch.sh flattens Open-Meteo's responses into. Same testable -- shape though, every function takes a string or numbers and returns a value, -- so the tests need no filesystem and no network. -- -- The forecast cards built on this (hourly, week, details, sky) are adapted -- from the Conky-Mirage theme by alexsson-xexpanderx: -- https://github.com/alexsson-xexpanderx/Conky-themes/tree/master/Conky-Mirage -- -- 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 = {} M.CACHE = (os.getenv('XDG_CACHE_HOME') or ((os.getenv('HOME') or '') .. '/.cache')) .. '/udt/weather.txt' -- 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}', rain_day = '\u{E308}', rain_night = '\u{E325}', snow = '\u{E31A}', fog = '\u{E313}', clear_day = '\u{E30D}', clear_night = '\u{E32B}', few_day = '\u{E302}', few_night = '\u{E379}', overcast = '\u{E312}', unknown = '\u{E374}', raindrop = '\u{E371}', } -- WMO weather code to glyph. The codes are sparse groups, not ranges, so a -- code in a gap is unknown rather than borrowed from a neighbour. -- `is_day` false picks the night face; nil (unknown) draws the day face, which -- is what is_day() falls back to as well. function M.icon(code, is_day) if type(code) ~= 'number' then return M.ICON.unknown end local d = is_day ~= false local I = M.ICON if code == 0 then return d and I.clear_day or I.clear_night elseif code == 1 or code == 2 then return d and I.few_day or I.few_night elseif code == 3 then return I.overcast elseif code == 45 or code == 48 then return I.fog elseif code >= 51 and code <= 57 then return d and I.drizzle_day or I.drizzle_night elseif (code >= 61 and code <= 67) or (code >= 80 and code <= 82) then return d and I.rain_day or I.rain_night elseif (code >= 71 and code <= 77) or code == 85 or code == 86 then return I.snow elseif code >= 95 and code <= 99 then return d and I.thunder_day or I.thunder_night end return I.unknown end local DESCRIBE = { [0] = 'Clear sky', [1] = 'Mainly clear', [2] = 'Partly cloudy', [3] = 'Overcast', [45] = 'Fog', [48] = 'Rime fog', [51] = 'Light drizzle', [53] = 'Drizzle', [55] = 'Dense drizzle', [56] = 'Freezing drizzle', [57] = 'Freezing drizzle', [61] = 'Light rain', [63] = 'Rain', [65] = 'Heavy rain', [66] = 'Freezing rain', [67] = 'Freezing rain', [71] = 'Light snow', [73] = 'Snow', [75] = 'Heavy snow', [77] = 'Snow grains', [80] = 'Rain showers', [81] = 'Rain showers', [82] = 'Violent showers', [85] = 'Snow showers', [86] = 'Snow showers', [95] = 'Thunderstorm', [96] = 'Thunderstorm, hail', [99] = 'Thunderstorm, hail', } -- WMO code in words. Reading a nil key from a table is not an error. function M.describe(code) return DESCRIBE[code] or 'Unknown' end -- Nearest integer, or nil when v is NaN, infinite or too big for an integer: -- %d and utf8.char raise on those, and a raise is a blank dashboard. local function round(v) return math.tointeger(math.floor(v + 0.5)) end -- A temperature as the cards print it. Missing is '--', never '0': zero is a -- legitimate reading. function M.deg(v) if type(v) ~= 'number' then return '--' end local r = round(v) if not r then return '--' end return string.format('%d\u{00B0}', r) end -- A rounded number with an optional suffix, or '--'. function M.num(v, suffix) if type(v) ~= 'number' then return '--' end local r = round(v) if not r then return '--' end return string.format('%d', r) .. (suffix or '') end -- The fetcher asks Open-Meteo for km/h or mph to match UNITS. function M.wind_unit(units) return units == 'imperial' and 'mph' or 'km/h' end -- Seconds as '10h 28m'. function M.duration(s) -- ponytail: anything over 1e9 s (about 31 years) is bad data, not a duration. if type(s) ~= 'number' or not (s >= 0 and s < 1e9) then return '--' end return string.format('%dh %02dm', math.floor(s / 3600), math.floor(s % 3600 / 60)) end -- Epoch as clock text. os.date raises on a float epoch (1.5) and on huge ones -- (1e17), and a raise is a blank dashboard, so those read '--' like missing. function M.clock(t, fmt) if type(t) ~= 'number' then return '--' end local ok, s = pcall(os.date, fmt or '%H:%M', t) return ok and s or '--' end -- Threshold role names, which index the palette (colors.ok and so on). -- UV: WHO bands, low under 3, very high from 8. function M.uv_role(uv) if type(uv) ~= 'number' then return nil end if uv >= 8 then return 'critical' elseif uv >= 3 then return 'warning' end return 'ok' end -- European AQI: good and fair up to 40, moderate and poor up to 80. function M.aqi_role(aqi) if type(aqi) ~= 'number' then return nil end if aqi > 80 then return 'critical' elseif aqi > 40 then return 'warning' end return 'ok' end -- Moon phase from the mean synodic month against a known new moon -- (2000-01-06 18:14 UTC). Returns phase 0..1 (0 new, 0.5 full) and the lit -- fraction 0..1. The mean month is off the true one by under a day, which a -- 28-step glyph cannot show anyway. local SYNODIC = 29.530588853 local NEW_MOON = 947182440 function M.moon(epoch) if type(epoch) ~= 'number' then return nil end local phase = ((epoch - NEW_MOON) / 86400 / SYNODIC) % 1 return phase, (1 - math.cos(2 * math.pi * phase)) / 2 end -- One of the 28 Nerd Font moon glyphs, U+E38D (new) to U+E3A8, in phase order. function M.moon_icon(phase) if type(phase) ~= 'number' then return M.ICON.unknown end local r = round(phase * 28.0) if not r then return M.ICON.unknown end return utf8.char(0xE38D + r % 28) 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. 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 -- Fraction of daylight elapsed, clamped to 0..1. -- -- The cache carries 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. 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 local t = (now - sunrise) / span if not (t >= 0) then return 0 elseif t > 1 then return 1 end return t end -- Whether the sun is up. Inclusive at both ends; unknown falls back to day. 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 cache: tab-separated records, first field the record type. -- -- units metric -- current epoch temp feels code is_day humidity wind wind_deg -- sun sunrise sunset -- air european_aqi -- hour epoch temp code precip_prob is_day uv (24 of them) -- day epoch code min max precip_prob (7 of them) -- -- A field Open-Meteo left null arrives as the word 'null', which tonumber -- turns into nil. Returns nil when current, sun or every hour is missing: -- a partial card is worse than an honest "no data". function M.parse(src) if type(src) ~= 'string' or src == '' then return nil end local w = { hours = {}, days = {} } for line in src:gmatch('[^\n]+') do local f = {} for field in (line .. '\t'):gmatch('([^\t]*)\t') do f[#f + 1] = field end local function n(i) local v = tonumber(f[i]) -- tonumber('1e999') is inf; NaN fails both comparisons. if v and v > -math.huge and v < math.huge then return v end end local kind = f[1] if kind == 'units' then w.units = f[2] elseif kind == 'current' then w.current = { dt = n(2), temp = n(3), feels = n(4), code = n(5), is_day = n(6) ~= 0, humidity = n(7), wind = n(8), wind_deg = n(9) } elseif kind == 'sun' then w.sun = { rise = n(2), set = n(3) } elseif kind == 'air' then w.air = { aqi = n(2) } elseif kind == 'hour' then w.hours[#w.hours + 1] = { t = n(2), temp = n(3), code = n(4), precip = n(5), is_day = n(6) ~= 0, uv = n(7) } elseif kind == 'day' then w.days[#w.days + 1] = { t = n(2), code = n(3), min = n(4), max = n(5), precip = n(6) } end end if not (w.current and w.current.temp and w.sun and w.sun.rise and w.sun.set and #w.hours > 0) then return nil end return w end -- Three missed fetches at the 15-minute interval. M.STALE_AFTER = 45 * 60 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: "12m", "3h", "2d". A negative age (clock skew) reads as fresh. function M.age_str(dt, now) if type(dt) ~= 'number' or type(now) ~= 'number' then return '--' end local s = now - dt if not (s > -math.huge and s < math.huge) then return '--' end 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