From 96bc42fafff425cf7690ad417cc6d4d4a757547b Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sun, 19 Jul 2026 15:44:24 -0500 Subject: docs: file interface design references Record the reconciled weather work and future component direction.\nFile the clock and retro-interface reference materials for later design work. --- working/weather-kit-integration/README.org | 119 ++++++++++++ working/weather-kit-integration/weather | 215 +++++++++++++++++++++ working/weather-kit-integration/weather-chip.html | 97 ++++++++++ working/weather-kit-integration/weather-glyphs.svg | 81 ++++++++ 4 files changed, 512 insertions(+) create mode 100644 working/weather-kit-integration/README.org create mode 100755 working/weather-kit-integration/weather create mode 100644 working/weather-kit-integration/weather-chip.html create mode 100644 working/weather-kit-integration/weather-glyphs.svg (limited to 'working/weather-kit-integration') diff --git a/working/weather-kit-integration/README.org b/working/weather-kit-integration/README.org new file mode 100644 index 0000000..d04899f --- /dev/null +++ b/working/weather-kit-integration/README.org @@ -0,0 +1,119 @@ +#+TITLE: Weather kit — for the Dupre kit / waybar / Wayland UX +#+AUTHOR: Craig Jennings +#+DATE: 2026-07-18 + +Built to completion elsewhere and handed to archsetup, which owns the Dupre kit, +waybar, and the Wayland UI/UX and will maintain it from here. This note is the +integration guide: what's in the package, the data contract, and how each +surface consumes it. + +* What's in the package + +| File | Purpose | +|----------------------+-------------------------------------------------------------------------| +| =weather= | The fetcher. Python 3, stdlib only. Hits Open-Meteo, normalises, caches. | +| =weather-glyphs.svg= | The custom condition glyph set (9 symbols, currentColor). | +| =weather-chip.html= | Reference renderer: the chosen chip, wired to the contract. Port target.| +| =README.org= | This file. | + +* The design decisions it encodes (settled over the 2026-07-18 session) + +- *Source: Open-Meteo, =model=best_match=.* No API key, no signup, non-commercial + free tier (10k calls/day; a 15-min poll is ~96/day). =best_match= auto-selects + the most accurate regional model — NOAA blend in the US, ECMWF/ICON abroad — so + the same binary is right on ratio in New Orleans and on velox travelling. For a + bar chip there's no perceptible accuracy gap vs the NWS API, and NWS is US-only, + so Open-Meteo is the single source. (NWS stays a possible *additive* feature + later, only for official US severe-weather alerts.) +- *One fetcher, one cache, thin renderers.* Every surface shells out to =weather= + and renders the JSON. Nobody re-implements the API call or the cache. A shared + cache means several widgets polling still hit the API at most once per TTL and + all show the identical reading. +- *Windy is a condition, not a compass reading.* Below the threshold the chip + shows the direction vane + speed; at/above it, the gust glyph + speed. Same + width either way. Threshold default *15 mph* (Beaufort 4 — where wind stops + being ambient). It's a =--windy-threshold= flag, derived at output from + =wind_mph=, so changing it takes effect without a cache bust. +- *Custom glyphs, not Nerd Font.* Seven conditions + a gust mark + a vane, drawn + to sit in the instrument aesthetic. =currentColor=, so each surface recolours. + +* The contract (=weather --json=) + +#+begin_src json +{ + "temp_f": 82, "feels_f": 92, + "condition": "clear", // clear | partly | cloud | fog | rain | snow | storm + "wmo_code": 0, + "windy": false, // wind_mph >= threshold + "wind_mph": 6, "wind_dir": "WNW", "wind_deg": 293, + "location": "New Orleans", + "observed": "2026-07-18T01:00", "stale": false +} +#+end_src + +=condition= is the coupling to the glyphs. Mapping (also in =weather-chip.html=): +=clear->wx-sun=, =partly->wx-partly=, =cloud->wx-cloud=, =fog->wx-fog=, +=rain->wx-rain=, =snow->wx-snow=, =storm->wx-storm=; plus =wx-wind= (shown when +=windy=) and =wx-vane= (shown otherwise, rotated by =wind_deg=). A consumer never +sees the WMO number unless it wants =wmo_code=. + +=stale: true= means the network fetch failed and this is the last good reading — +render it dimmed, never blank the bar. + +* WMO weather-code -> condition (what the fetcher collapses) + +| WMO code(s) | condition | +|--------------------------------------+-----------| +| 0 | clear | +| 1, 2 | partly | +| 3 | cloud | +| 45, 48 | fog | +| 51-57, 61-67, 80-82 | rain | +| 71-77, 85-86 | snow | +| 95, 96, 99 | storm | +| (anything unmapped) | cloud | + +* Install + +Drop =weather= on =PATH= the way the other fleet CLIs travel (=agent-page=, etc.): +into the rulesets/dotfiles bin so =make install= / stow links it to +=~/.local/bin/weather= on every machine. It's dependency-free, so nothing else to +provision. + +No coordinates are baked in (nothing personal ships in the repo). Each machine +sets its own location, resolved in order: + +1. =--lat/--lon= flags. +2. =$WEATHER_LAT= / =$WEATHER_LON= (and optional =$WEATHER_NAME=). +3. =~/.config/weather/config.json= — ={"lat":29.97,"lon":-90.09,"name":"New Orleans"}=. + Add ="use_geo": true= (or pass =--geo=) to prefer =whereami= — the right call + on velox, which travels. + +* How each surface consumes it + +- *waybar* — a custom module: + #+begin_src jsonc + "custom/weather": { "exec": "weather --format waybar", "return-type": "json", + "interval": 900, "on-click": "..." } + #+end_src + =--format waybar= emits ={text, alt, class, tooltip}=; the stylesheet picks the + icon by =alt=/=class= (=.clear=, =.windy=, =.stale=, ...). +- *Dupre kit / =svg.el=* — =(json-parse-string (shell-command-to-string "weather --json"))=, + then render the chip. =weather-chip.html='s =renderChip(reading)= is the exact + logic to port: glyph, temp, wind slot (gust vs vane by =windy=). +- *clock-panel (Python)* — =json.loads(subprocess.run(["weather","--json"],...).stdout)=. + No HTTP in clock-panel at all. + +* Notes / open choices for the maintainer + +- Poll cadence lives with the consumer (waybar =interval=), not the tool; the tool + just honours its own cache TTL (=--ttl=, default 900s). +- =weather-glyphs.svg= is the canonical glyph source. =weather-chip.html= inlines a + copy for a standalone demo — if the glyphs change, update both (or have the kit + build inline from the =.svg=). +- The chip currently shows the wind slot always. If it ever reads busy on a packed + bar, hiding the slot below the threshold (show only when windy) is a one-line + change — the data's already there. +- A "windy later today" hint would want the hourly endpoint (=hourly=wind_speed_10m=) + and a small max-over-next-N-hours; the fetcher is structured to add that as a + second field without touching consumers. diff --git a/working/weather-kit-integration/weather b/working/weather-kit-integration/weather new file mode 100755 index 0000000..6d0c0f1 --- /dev/null +++ b/working/weather-kit-integration/weather @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +"""weather -- one fetcher for the whole fleet. + +Hits Open-Meteo (model=best_match, so NOAA blend in the US, ECMWF/ICON abroad), +normalises the reading, caches the last good value, and prints it. Every app +(waybar, clock-panel, an Emacs svg.el panel) shells out to this and renders the +JSON -- nobody re-implements the API call, the code mapping, or the cache. + +Stdlib only: no pip, no venv. Runs as a bare executable. + +Contract (--format json): + { + "temp_f": 82, "feels_f": 92, + "condition": "clear", # clear|partly|cloud|fog|rain|snow|storm + "wmo_code": 0, + "windy": false, # wind_mph >= threshold (default 15) + "wind_mph": 6, "wind_dir": "WNW", "wind_deg": 293, + "location": "New Orleans", + "observed": "2026-07-18T01:00", "stale": false + } + +Location resolves in order: --lat/--lon, then $WEATHER_LAT/$WEATHER_LON, then +~/.config/weather/config.json, then `whereami` (with --geo). No coordinates are +baked in, so nothing personal ships in the repo -- each machine configures its +own. +""" +import argparse +import json +import os +import subprocess +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path + +API = "https://api.open-meteo.com/v1/forecast" +CONFIG = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "weather" / "config.json" +CACHE = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "weather" / "current.json" +DEFAULT_TTL = 900 # 15 min -- weather changes slowly; keep API calls rare +DEFAULT_WINDY = 15 # mph; Beaufort 4, where wind stops being ambient + +# WMO weather-interpretation code -> our semantic condition. +# Ranges collapse to the seven condition glyphs; "windy" is derived from speed. +_WMO = { + 0: "clear", + 1: "partly", 2: "partly", + 3: "cloud", + 45: "fog", 48: "fog", + 51: "rain", 53: "rain", 55: "rain", 56: "rain", 57: "rain", + 61: "rain", 63: "rain", 65: "rain", 66: "rain", 67: "rain", + 80: "rain", 81: "rain", 82: "rain", + 71: "snow", 73: "snow", 75: "snow", 77: "snow", 85: "snow", 86: "snow", + 95: "storm", 96: "storm", 99: "storm", +} + +_CARDINALS = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", + "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"] + + +def condition_for(code): + return _WMO.get(int(code), "cloud") # unknown code -> cloud, the safe neutral + + +def cardinal(deg): + return _CARDINALS[int((deg % 360) / 22.5 + 0.5) % 16] + + +def resolve_location(args): + """Return (lat, lon, name). Raise if nothing configured.""" + if args.lat is not None and args.lon is not None: + return args.lat, args.lon, args.location + env_lat, env_lon = os.environ.get("WEATHER_LAT"), os.environ.get("WEATHER_LON") + if env_lat and env_lon: + return float(env_lat), float(env_lon), args.location or os.environ.get("WEATHER_NAME") + if CONFIG.exists(): + cfg = json.loads(CONFIG.read_text()) + if args.geo or cfg.get("use_geo"): + geo = _whereami() + if geo: + return geo[0], geo[1], args.location or cfg.get("name") + if "lat" in cfg and "lon" in cfg: + return cfg["lat"], cfg["lon"], args.location or cfg.get("name") + if args.geo: + geo = _whereami() + if geo: + return geo[0], geo[1], args.location + raise SystemExit( + "weather: no location. Pass --lat/--lon, set $WEATHER_LAT/$WEATHER_LON, " + f"write {CONFIG} (\"lat\"/\"lon\"[/\"name\"]), or use --geo on a machine " + "that travels." + ) + + +def _whereami(): + """Best-effort coords from the `whereami` tool. Only meaningful where the + machine actually moves (velox); silently gives up otherwise.""" + try: + out = subprocess.run(["whereami"], capture_output=True, text=True, timeout=20).stdout + except (OSError, subprocess.SubprocessError): + return None + for line in out.splitlines(): + if "coords:" in line: + try: + nums = line.split("coords:")[1].split("(")[0] + lat, lon = (float(x) for x in nums.replace(",", " ").split()[:2]) + return lat, lon + except (ValueError, IndexError): + return None + return None + + +def fetch(lat, lon, name, threshold): + params = ( + f"?latitude={lat}&longitude={lon}" + "¤t=temperature_2m,apparent_temperature,weather_code," + "wind_speed_10m,wind_direction_10m" + "&temperature_unit=fahrenheit&wind_speed_unit=mph" + "&models=best_match&timezone=auto" + ) + req = urllib.request.Request(API + params, headers={"User-Agent": "weather-cli/1.0"}) + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.load(resp) + cur = data["current"] + mph = round(cur["wind_speed_10m"]) + deg = cur["wind_direction_10m"] + return { + "temp_f": round(cur["temperature_2m"]), + "feels_f": round(cur["apparent_temperature"]), + "condition": condition_for(cur["weather_code"]), + "wmo_code": int(cur["weather_code"]), + "windy": mph >= threshold, + "wind_mph": mph, + "wind_dir": cardinal(deg), + "wind_deg": round(deg), + "location": name or f"{lat:.2f},{lon:.2f}", + "observed": cur["time"], + "stale": False, + } + + +def read_cache(): + try: + blob = json.loads(CACHE.read_text()) + return blob["fetched_at"], blob["reading"] + except (OSError, ValueError, KeyError): + return None, None + + +def write_cache(reading): + CACHE.parent.mkdir(parents=True, exist_ok=True) + CACHE.write_text(json.dumps({"fetched_at": int(time.time()), "reading": reading})) + + +def get_reading(args): + """Cache-first, network-second, stale-cache-on-failure. One shared cache + means five widgets polling still hit the API at most once per TTL.""" + fetched_at, cached = read_cache() + if cached and not args.no_cache and fetched_at and (time.time() - fetched_at) < args.ttl: + return cached + lat, lon, name = resolve_location(args) + try: + reading = fetch(lat, lon, name, args.windy_threshold) + write_cache(reading) + return reading + except (urllib.error.URLError, OSError, KeyError, ValueError) as e: + if cached: # never blank the bar: serve last good, flagged + cached["stale"] = True + return cached + raise SystemExit(f"weather: fetch failed and no cache to fall back on ({e})") + + +def render(reading, fmt): + if fmt == "json": + return json.dumps(reading) + if fmt == "plain": + wind = "windy" if reading["windy"] else reading["wind_dir"] + s = f"{reading['temp_f']}°F {reading['condition']}, wind {wind} {reading['wind_mph']}" + return s + (" (stale)" if reading["stale"] else "") + if fmt == "waybar": + # Data only; the caller's stylesheet picks the icon by `alt`/`class`. + wind = "windy" if reading["windy"] else f"{reading['wind_dir']} {reading['wind_mph']}" + return json.dumps({ + "text": f"{reading['temp_f']}° {reading['wind_mph']}", + "alt": reading["condition"], + "class": (["stale"] if reading["stale"] else []) + [reading["condition"]] + + (["windy"] if reading["windy"] else []), + "tooltip": f"{reading['location']}: {reading['temp_f']}°F " + f"(feels {reading['feels_f']}°), {reading['condition']}, " + f"wind {reading['wind_dir']} {reading['wind_mph']} mph" + + (" [stale]" if reading["stale"] else ""), + }) + raise SystemExit(f"weather: unknown format {fmt!r}") + + +def main(argv=None): + p = argparse.ArgumentParser(description="Normalised current weather from Open-Meteo.") + p.add_argument("--lat", type=float, help="latitude override") + p.add_argument("--lon", type=float, help="longitude override") + p.add_argument("--location", help="display name for the reading") + p.add_argument("--geo", action="store_true", help="geolocate via `whereami` (traveling machines)") + p.add_argument("--format", choices=["json", "plain", "waybar"], default="json") + p.add_argument("--windy-threshold", type=int, default=DEFAULT_WINDY, help="mph at which windy=true") + p.add_argument("--ttl", type=int, default=DEFAULT_TTL, help="cache seconds") + p.add_argument("--no-cache", action="store_true", help="force a fresh fetch") + args = p.parse_args(argv) + reading = get_reading(args) + # Derive windy from cached wind_mph so a changed threshold takes effect + # without waiting for the cache to expire. + reading["windy"] = reading["wind_mph"] >= args.windy_threshold + print(render(reading, args.format)) + + +if __name__ == "__main__": + main() diff --git a/working/weather-kit-integration/weather-chip.html b/working/weather-kit-integration/weather-chip.html new file mode 100644 index 0000000..f9e05d4 --- /dev/null +++ b/working/weather-kit-integration/weather-chip.html @@ -0,0 +1,97 @@ + + + + +Weather chip — reference renderer + + + + +

Weather chip — reference renderer

+

The chosen bar form, wired to the weather CLI contract. Each chip below is built by renderChip(reading) from a contract object (the JSON weather --json prints). This is the reference for the port into the Dupre kit's svg.el: condition glyph, temperature, then the wind slot — which collapses to the gust glyph + speed when windy, or the direction vane + speed when calm. Constant width either way.

+

To drive it live: weather --json | (feed into renderChip). The samples here are static so the condition and calm/windy mapping is visible at a glance.

+ + + + +
+ + + + diff --git a/working/weather-kit-integration/weather-glyphs.svg b/working/weather-kit-integration/weather-glyphs.svg new file mode 100644 index 0000000..0cc3189 --- /dev/null +++ b/working/weather-kit-integration/weather-glyphs.svg @@ -0,0 +1,81 @@ + + + -- cgit v1.2.3