aboutsummaryrefslogtreecommitdiffstats
path: root/bin/weather-fetch.sh
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-18 14:29:34 +0200
committerDanilo M. <danix@danix.xyz>2026-10-18 14:29:34 +0200
commit0b5111fb636eef58e265810cf65df7f6e6d03923 (patch)
treeaf9a1af06997813a10494dccd5360488a89179b5 /bin/weather-fetch.sh
parent48e1a59fd091abdc409d7c30f5e33bdf53ea7cbd (diff)
downloadconky-theme-udt-0b5111fb636eef58e265810cf65df7f6e6d03923.tar.gz
conky-theme-udt-0b5111fb636eef58e265810cf65df7f6e6d03923.zip
feat(weather): fetch from Open-Meteo, no API key
Forecast and air quality from Open-Meteo, flattened by jq into the tab-separated cache lib/weather.lua parses. The city is geocoded once and cached; a failed air-quality call drops one line, not the cache. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Diffstat (limited to 'bin/weather-fetch.sh')
-rwxr-xr-xbin/weather-fetch.sh120
1 files changed, 88 insertions, 32 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