1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
|
#!/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
# shellcheck source=/dev/null
. "$ENV_FILE"
if [ -z "${CITY:-}" ]; then
echo "weather-fetch: CITY is empty in $ENV_FILE" >&2
exit 1
fi
UNITS="${UNITS:-metric}"
# Location data shares a directory with UDT's other generated state, all of
# which is 600. Set the mode before anything lands, the
# directory included, so no file is briefly
# world-readable.
umask 077
mkdir -p "$CACHE_DIR"
# 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" "$LOC.tmp.$$"' 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" ] && [ -n "$lon" ] || 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.tmp.$$"
mv -f "$LOC.tmp.$$" "$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" --rawfile 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 | fromjson?) | .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"
|