# 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 ` 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.