#!/bin/bash # 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. set -u ENV_FILE="${WEATHER_ENV:-$HOME/.config/udt/weather.env}" CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/udt" 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 exit 1 fi set -a # shellcheck source=/dev/null . "$ENV_FILE" set +a if [ -z "${CITY:-}" ]; then echo "weather-fetch: CITY is empty in $ENV_FILE" >&2 exit 1 fi UNITS="${UNITS:-metric}" mkdir -p "$CACHE_DIR" # 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.$$" FC="$TMP.forecast" AIR="$TMP.air" trap 'rm -f "$TMP" "$FC" "$AIR"' EXIT # 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" # 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 # 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 mv -f "$TMP" "$CACHE"