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
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
|
# Weather widget design
Date: 2026-09-17
Status: approved, not yet implemented
A single vertical card for the conky Lua dashboard: current conditions on top,
a sunrise-to-sunset arc at the bottom. Shaped after `idea1.png`, which splits
the same information across two cards; this merges them into one 1x2 cell, as
requested.
This builds on the v1 design
(`2026-09-16-conky-lua-dashboard-design.md`), whose Weather section specified
the data source and the secrets handling. That section stands. What follows
adds the card, the arc, and the decisions that section deferred.
## What it shows
Top to bottom, inside the padded rect `card.card()` returns:
| Band | Content |
|-----------|------------------------------------------------------|
| Header | condition glyph, then the temperature with the city beneath it |
| Condition | OWM's own `weather[0].description`, first letter capitalised |
| rule | hairline in `colors.rule` |
| Stats | `FEELS LIKE`, `HUMIDITY`, `WIND` (direction arrow + speed) |
| rule | hairline |
| Sun arc | curve, baseline, sun dot, sunrise and sunset times at the ends |
Three stat rows, as in `idea1.png`. Wind direction rides inside the existing
wind row as an arrow glyph before the speed rather than claiming a fourth row.
Sizes derive from the inner rect the way `clock.lua` derives its numerals, not
fixed pixels, so the card still composes on the 1920-wide monitor.
## Architecture
bin/weather-fetch.sh
reads ~/.config/udt/weather.env (KEY, CITY, COUNTRY, UNITS)
curl api.openweathermap.org/data/2.5/weather
writes ~/.cache/udt/weather.json (whole response)
conky.conf.in
conky.text = ${execi 900 ~/.config/conky/bin/weather-fetch.sh}
lib/weather.lua parses the cache, owns the domain tables
widgets/weather.lua draws the card
The dashboard never blocks on the network: the widget only ever reads a local
file, and the fetch happens on conky's own `execi` schedule.
### Why `${execi}` and not a systemd timer
Refresh is tied to the dashboard's lifetime. Nothing fetches while conky is
down, which is exactly when nobody is looking at the card, and there is no
second installation step or unit file to keep in sync. `install.sh` already
owns the rendered config.
**`conky.text` is empty in this config**, because Cairo output covers it, so
whether `execi` fires at all had to be checked rather than assumed. It does:
a probe config whose only text was an `execi` producing no output still ran the
command on schedule (verified 2026-09-17, conky 1.22 on this host, two
invocations over a 12s run at `execi 2`). The text block therefore changes from
`[[]]` to the single `execi` line, which stays invisible beneath the Cairo
layer.
### Why a separate `lib/weather.lua`
`lib/data.lua` parses `/proc` and `/sys`. Weather is a different source with
its own domain tables (condition ids, Beaufort, compass points) and its own
test file. It keeps data.lua's testable shape: functions take the file
*contents* as a string and return a table, so the tests need no filesystem.
### Cache writes are atomic
The fetch writes `weather.json.tmp` and renames it over the target. The widget
reads that file on an unrelated 2-second cadence, so a curl killed mid-write
would otherwise hand the parser a truncated response. Rename is atomic within a
filesystem; the temp file therefore lives in the same directory as the target.
### JSON parsing without a JSON library
Lua patterns over the handful of fields the card draws, matching scalars by key
(`"temp":([%d%.%-]+)`). OWM's current-weather response is flat and known.
Adding a dependency to read six numbers fails the ladder. This is why caching
the whole response costs nothing: the parser only looks at what it needs, and a
later field is already on disk.
The parser returns `nil` on anything it cannot read rather than raising, since
a Lua error in this project is a blank screen.
## The arc
The only non-trivial drawing in the widget.
A Bezier curve spanning the band's width via `cairo_curve_to`, with a baseline
beneath it. The sun's position along it:
t = (now - sunrise) / (sunset - sunrise) clamped to 0..1
The dot is placed by **evaluating the Bezier at `t`**, not by computing a point
on a circle. The curve is already the path; evaluating it keeps the dot on the
curve if the control points are ever adjusted, where a separately derived
circle would drift off it.
Behaviour outside daylight:
- Before sunrise `t` is negative, after sunset greater than 1. The clamp parks
the dot at the corresponding end.
- At night the dot takes `colors.label` rather than the bright fill, so a
parked dot does not read as "the sun is up".
OWM returns *today's* sunrise and sunset, so between midnight and sunrise the
numerator is negative. The clamp is the whole handling. Multi-day astronomy
buys nothing for a dot on an arc.
Sunrise and sunset times sit at the ends of the arc, each behind a Nerd Font
glyph (U+E34C sunrise, U+E34D sunset) rather than a bare time. Both glyphs were
rendered from `Inconsolata Nerd Font` and inspected before being chosen: they
are a sun with an up arrow and a sun with a down arrow, visually distinct at
the size used.
## Fonts
No new font. `Inconsolata Nerd Font`, already `card.FONT_MONO`, carries the
Weather Icons range the reference script used (U+E3xx) and the two
sunrise/sunset glyphs. Confirmed by rendering the actual codepoints and looking
at the result, not by a fontconfig query alone: an early `fc-list` check
reported the glyphs missing, which was the query's fault, and a missing glyph
in conky is an invisible blank rather than an error.
## Domain tables, ported from the polybar script
`/data/udt-backup/polybar/modules/weather/openweathermap-simple.sh` is the
reference. Its accumulated knowledge ports; none of its code does.
**Condition id to icon**, by upper bound: `<=232` thunderstorm, `<=311` light
drizzle, `<=321` heavy drizzle, `<=531` rain, `<=622` snow, `<=771` fog, `781`
tornado, `800` clear, `801` few clouds, `<=804` overcast, anything else an
error glyph.
**Day and night variants** for the ids that have them (thunderstorm, both
drizzles, rain, clear, few clouds), selected by comparing now against
`sys.sunrise` and `sys.sunset` from the same response. Snow, fog and tornado
have a single icon in the reference and keep one here.
**Beaufort thresholds** for the wind glyph, in km/h: 1, 5, 11, 19, 28, 38, 49,
61, 74, 88, 102, 117.
**Wind direction** is new, not in the reference: `wind.deg` binned to eight
compass points, each an arrow glyph. Bin boundaries are offset by half a step
so that north spans 348.75 to 11.25 degrees rather than starting at zero.
Units: OWM `metric` gives m/s for wind, so the card converts to km/h
(`* 3.6`). The reference's knots conversion and its `MIN_WIND` suppression are
dropped; the card always shows the wind row.
## Failure and staleness
One code path, three states, all drawing the normal card chrome so the
dashboard keeps its shape:
| State | Card shows |
|---|---|
| `weather.env` missing | `no weather data`, then `set ~/.config/udt/weather.env` |
| cache missing or unparseable | `no weather data`, then `waiting for first fetch` |
| cache older than 45 minutes | the data, drawn normally, plus a dim `stale <age>` by the city |
An empty cell was rejected: it is indistinguishable from a crashed widget,
which is a failure mode this project has already fought once. Silently showing
old values was rejected as a correctness bug.
The 45-minute threshold is three missed fetches at the 15-minute interval, so a
single transient failure does not flag the card.
The fetch script exits non-zero with a message on stderr when the key is
missing or curl fails, and **leaves any existing cache untouched** rather than
overwriting it with an error body.
The script checks `.cod` is 200 before replacing the cache, behind `curl -f`.
Measured against the live API, a bad key returns 401 and an unknown city 404,
so `-f` rejects both before `.cod` is read; the check covers the remaining case
of a 200 whose body is not usable weather. The reference script needed it as
its only defence because it ran curl without `-f`.
## Secrets and personal data
Nothing sensitive enters the repo.
`~/.config/udt/weather.env` holds `KEY`, `CITY`, `COUNTRY` and `UNITS`. It is
outside the repo, already covered by `.gitignore`, and does not exist yet, so
the "set weather.env" state above is what the card shows on first run. A
`weather.env.example` with placeholder values ships in the repo.
The location lives in that file rather than in the source, so no real location
appears in committed code.
The API key hardcoded in the reference script must be treated as exposed and
revoked: that file is mode 755 under a world-readable path. This design never
carries a key in-repo.
## Tests
`test/test_weather.lua`, in the style of the existing two, with a fixture OWM
response under `test/fixtures/` whose key and city are placeholders.
- condition id to icon at every range boundary: 232/233, 311/312, 321/322,
531/532, 622/623, 771/772, 781, 800, 801, 804, and an unknown id
- day and night selection for the ids that have both, by moving `now` across
sunrise and sunset
- Beaufort binning at each threshold and just either side of it
- `wind.deg` to arrow for all eight points, including the wraparound at 348.75
and 360 degrees
- sun position `t` at sunrise, midday and sunset, and the clamp before dawn and
after dusk
- truncated and garbage JSON returning nil rather than raising
- staleness classification at the 45-minute boundary
Run with the existing two:
lua test/test_data.lua && lua test/test_layout.lua && lua test/test_weather.lua
The card itself is verified by screenshot, which means looking at the PNG.
## Files
| Path | Change |
|---|---|
| `bin/weather-fetch.sh` | new, fetch and cache |
| `lib/weather.lua` | new, parse and domain tables |
| `widgets/weather.lua` | new, the card |
| `test/test_weather.lua` | new |
| `test/fixtures/weather.json` | new |
| `weather.env.example` | new |
| `conky.conf.in` | `conky.text` gains the `execi` line |
| `dashboard.lua` | one row in the `layout` table |
| `install.sh` (UDT repo) | symlink `bin/` alongside `lib/` and `widgets/`; also prefix its conky restart with `[workspace special:dash silent]`, which it currently lacks, so a reinstall does not pop the dashboard open |
| `README.md` | the widget, and the `weather.env` setup step |
## Out of scope
Forecast, hourly or daily. The current-weather endpoint has none of it, and the
card has no room. A forecast card would be its own widget and its own design.
|