diff options
| -rwxr-xr-x | bin/weather-fetch.sh | 120 | ||||
| -rw-r--r-- | weather.env.example | 14 |
2 files changed, 95 insertions, 39 deletions
diff --git a/bin/weather-fetch.sh b/bin/weather-fetch.sh index d4e31c0..4b37b17 100755 --- a/bin/weather-fetch.sh +++ b/bin/weather-fetch.sh @@ -1,7 +1,10 @@ #!/bin/bash -# Fetch current weather into a cache file. Run from conky's ${execi}, so it -# prints nothing on success and never blocks the dashboard: the widget only -# ever reads the cache. +# Fetch the forecast from Open-Meteo into a cache file. Run from conky's +# ${texeci}, so it prints nothing on success and never blocks the dashboard: +# the widgets only ever read the cache. +# +# Open-Meteo needs no key. The location is a city name from weather.env, +# geocoded once and cached, so no coordinates live in this repository. # # Exits non-zero with a message on stderr when it cannot fetch, and leaves any # existing cache untouched rather than replacing good data with an error. @@ -10,7 +13,8 @@ set -u ENV_FILE="${WEATHER_ENV:-$HOME/.config/udt/weather.env}" CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/udt" -CACHE="$CACHE_DIR/weather.json" +CACHE="$CACHE_DIR/weather.txt" +LOC="$CACHE_DIR/weather-loc" if [ ! -r "$ENV_FILE" ]; then echo "weather-fetch: no $ENV_FILE (copy weather.env.example)" >&2 @@ -22,45 +26,97 @@ set -a . "$ENV_FILE" set +a -if [ -z "${KEY:-}" ]; then - echo "weather-fetch: KEY is empty in $ENV_FILE" >&2 +if [ -z "${CITY:-}" ]; then + echo "weather-fetch: CITY is empty in $ENV_FILE" >&2 exit 1 fi +UNITS="${UNITS:-metric}" mkdir -p "$CACHE_DIR" -# The temp file sits in the same directory as the target, because rename is -# only atomic within a filesystem. The widget reads this cache on its own 2s -# cadence, so a fetch killed mid-write would otherwise hand it half a response. +# Location data shares a directory with UDT's other generated state, all of +# which is 600. Set the mode before anything lands, so no file is briefly +# world-readable. +umask 077 + +# Temp files sit beside the target, because rename is only atomic within a +# filesystem. The widgets read the cache on their own 2s cadence, so a fetch +# killed mid-write would otherwise hand them half a cache. TMP="$CACHE.tmp.$$" -trap 'rm -f "$TMP"' EXIT +FC="$TMP.forecast" +AIR="$TMP.air" +trap 'rm -f "$TMP" "$FC" "$AIR"' EXIT -# The response carries the configured city and its coordinates. That is not a -# secret, but it is location data and it shares a directory with UDT's other -# generated state, all of which is 600. Set the mode before the body lands in -# the file rather than after, so it is never briefly world-readable. -umask 077 +# Geocode once per CITY/COUNTRY pair. The cache's first field records which +# pair it answers, so editing weather.env re-geocodes on the next run. +PAIR="$CITY,${COUNTRY:-}" +lat="" lon="" cached="" +if [ -r "$LOC" ]; then + IFS=$'\t' read -r cached lat lon < "$LOC" + [ "$cached" = "$PAIR" ] || lat="" +fi +if [ -z "$lat" ]; then + read -r lat lon < <(curl -fsS --max-time 15 --get \ + "https://geocoding-api.open-meteo.com/v1/search" \ + --data-urlencode "name=$CITY" --data-urlencode "count=10" 2>/dev/null \ + | jq -r --arg cc "${COUNTRY:-}" ' + [.results[]? | select($cc == "" or .country_code == ($cc | ascii_upcase))][0] + | select(. != null) | "\(.latitude) \(.longitude)"') + if [ -z "$lat" ]; then + echo "weather-fetch: cannot geocode $PAIR" >&2 + exit 1 + fi + printf '%s\t%s\t%s\n' "$PAIR" "$lat" "$lon" > "$LOC" +fi + +if [ "$UNITS" = imperial ]; then + TEMP_UNIT=fahrenheit WIND_UNIT=mph +else + TEMP_UNIT=celsius WIND_UNIT=kmh +fi + +if ! curl -fsS --max-time 15 --get "https://api.open-meteo.com/v1/forecast" \ + --data-urlencode "latitude=$lat" --data-urlencode "longitude=$lon" \ + --data-urlencode "timeformat=unixtime" --data-urlencode "timezone=auto" \ + --data-urlencode "forecast_days=7" \ + --data-urlencode "temperature_unit=$TEMP_UNIT" \ + --data-urlencode "wind_speed_unit=$WIND_UNIT" \ + --data-urlencode "current=temperature_2m,apparent_temperature,weather_code,is_day,relative_humidity_2m,wind_speed_10m,wind_direction_10m" \ + --data-urlencode "hourly=temperature_2m,weather_code,precipitation_probability,is_day,uv_index" \ + --data-urlencode "daily=weather_code,temperature_2m_min,temperature_2m_max,precipitation_probability_max,sunrise,sunset" \ + -o "$FC" 2>/dev/null; then + echo "weather-fetch: forecast request failed" >&2 + exit 1 +fi + +# Air quality is a second service. Losing it costs one row, not the cache, so +# a failure leaves an empty file and the air line is simply omitted. +curl -fsS --max-time 15 --get "https://air-quality-api.open-meteo.com/v1/air-quality" \ + --data-urlencode "latitude=$lat" --data-urlencode "longitude=$lon" \ + --data-urlencode "current=european_aqi" \ + -o "$AIR" 2>/dev/null || : > "$AIR" -URL="https://api.openweathermap.org/data/2.5/weather" -if ! curl -fsS --max-time 15 --get "$URL" \ - --data-urlencode "appid=$KEY" \ - --data-urlencode "units=${UNITS:-metric}" \ - --data-urlencode "lang=${LANG_CODE:-en}" \ - --data-urlencode "q=${CITY},${COUNTRY}" \ - -o "$TMP" 2>/dev/null; then - echo "weather-fetch: request failed" >&2 +# Flatten to the line format lib/weather.lua parses. The 24 hours start at +# the current hour: the first hourly slot later than an hour before now. +if ! jq -r --arg units "$UNITS" --slurpfile air "$AIR" ' + .current.time as $now + | ([.hourly.time | to_entries[] | select(.value > $now - 3600) | .key] | first) as $i + | "units\t\($units)", + (.current | "current\t\(.time)\t\(.temperature_2m)\t\(.apparent_temperature)\t\(.weather_code)\t\(.is_day)\t\(.relative_humidity_2m)\t\(.wind_speed_10m)\t\(.wind_direction_10m)"), + "sun\t\(.daily.sunrise[0])\t\(.daily.sunset[0])", + ($air[0].current.european_aqi // empty | "air\t\(.)"), + (.hourly as $h | range($i; [$i + 24, ($h.time | length)] | min) as $k + | "hour\t\($h.time[$k])\t\($h.temperature_2m[$k])\t\($h.weather_code[$k])\t\($h.precipitation_probability[$k])\t\($h.is_day[$k])\t\($h.uv_index[$k])"), + (.daily as $d | range(0; $d.time | length) as $k + | "day\t\($d.time[$k])\t\($d.weather_code[$k])\t\($d.temperature_2m_min[$k])\t\($d.temperature_2m_max[$k])\t\($d.precipitation_probability_max[$k])") + ' "$FC" > "$TMP" 2>/dev/null; then + echo "weather-fetch: cannot read the forecast response" >&2 exit 1 fi -# Belt and braces behind `curl -f`. Measured against the live API: a bad key -# returns 401 and an unknown city 404, so curl -f already rejects both and this -# branch is not what catches them. It stays for the case -f cannot see: a 200 -# whose body is not usable weather, which is what the polybar script this was -# ported from actually hit, since it ran curl WITHOUT -f and so received error -# bodies with a success exit. Without this, such a body would reach the parser -# and replace a good cache. -if [ "$(jq -r '.cod // empty' "$TMP" 2>/dev/null)" != "200" ]; then - echo "weather-fetch: API error: $(jq -r '.message // "unknown"' "$TMP" 2>/dev/null)" >&2 +# A 200 whose body is not a usable forecast must not replace a good cache. +if ! grep -q $'^current\t[0-9]*\t-\\?[0-9]' "$TMP"; then + echo "weather-fetch: forecast has no current temperature" >&2 exit 1 fi diff --git a/weather.env.example b/weather.env.example index fa30879..7996d44 100644 --- a/weather.env.example +++ b/weather.env.example @@ -1,12 +1,12 @@ # Copy to ~/.config/udt/weather.env and fill in. That path is outside this -# repo on purpose: it holds a key, and anything committed is potentially -# public. chmod 600 it. +# repo on purpose: a location is personal data, and anything committed is +# potentially public. chmod 600 it. # -# Get a key at https://openweathermap.org/api. A new key returns -# "401 Invalid API key" for minutes to hours after creation; that is the API -# activating it, not a mistake in this file. +# The forecast comes from Open-Meteo (https://open-meteo.com), which needs no +# key. CITY is geocoded once through Open-Meteo's geocoding API; COUNTRY is the +# two-letter ISO code that picks the right place among same-named ones. +# UNITS is metric (C, km/h) or imperial (F, mph). -KEY=your_32_character_api_key_here -CITY=Your City +CITY=Example City COUNTRY=XX UNITS=metric |
