1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
|
-- 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
|