diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-18 14:28:34 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-18 14:28:34 +0200 |
| commit | 48e1a59fd091abdc409d7c30f5e33bdf53ea7cbd (patch) | |
| tree | 191f0207a611e46ca918ed838d9ec6335c89d4ea /lib | |
| parent | 0d8ad4d94c7aa96bdb1becac0c0f9e43ffa77dd0 (diff) | |
| download | conky-theme-udt-48e1a59fd091abdc409d7c30f5e33bdf53ea7cbd.tar.gz conky-theme-udt-48e1a59fd091abdc409d7c30f5e33bdf53ea7cbd.zip | |
feat(weather): parse an Open-Meteo cache instead of OWM JSON
WMO codes to glyphs and words, moon phase, UV and AQI roles, and a
parser for the tab-separated cache the fetcher will write. Groundwork
for the Mirage-derived forecast cards.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/weather.lua | 309 |
1 files changed, 165 insertions, 144 deletions
diff --git a/lib/weather.lua b/lib/weather.lua index 8ecd273..aea630e 100644 --- a/lib/weather.lua +++ b/lib/weather.lua @@ -1,98 +1,146 @@ --- OpenWeatherMap domain knowledge and cache parsing. +-- Open-Meteo 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. +-- 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}', - 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}', + 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}', } --- 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 +-- 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 M.ICON.unknown + return I.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 +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 --- 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 +local function round(v) return math.floor(v + 0.5) end + +-- A temperature as the cards print it. Missing is '--', never '0': zero is a +-- legitimate reading. The '-0' a value just under zero would round to is +-- folded into '0'. +function M.deg(v) + if type(v) ~= 'number' then return '--' end + local r = round(v) + if r == 0 then r = 0 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 + return string.format('%d', round(v)) .. (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) + if type(s) ~= 'number' or s < 0 then return '--' end + return string.format('%dh %02dm', math.floor(s / 3600), math.floor(s % 3600 / 60)) +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 + return utf8.char(0xE38D + round(phase * 28) % 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, 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. +-- 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) @@ -101,109 +149,82 @@ function M.compass(deg) 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. +-- 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 - -- 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. +-- 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 cached OWM response. +-- Parse the cache: tab-separated records, first field the record type. -- --- 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. +-- 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 - - -- 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)) + 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) return tonumber(f[i]) 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 - - 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. +-- 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 |
