polybar/scripts/weather 14.0 K raw
1
#!/bin/sh
2
# Requires: curl, jq, a Nerd Font (the glyphs are nf-md-*), and linecast plus a
3
# terminal for the `detail` action.
4
# Weather for the bar, straight from the US National Weather Service. It began
5
# as polybar-scripts' openweathermap-fullfeatured, but against api.weather.gov,
6
# so there is no API key to keep out of the repo. The trade is that NWS only
7
# covers the US and its territories: /points returns 404 for anywhere else and
8
# the module goes blank.
9
#
10
# The bar gets current conditions and nothing else -- an icon and a temperature,
11
# the same shape as every other module here. The forecast is a click away.
12
#
13
#   weather          one line for the bar (the default)
14
#   weather detail   opens $TERMINAL on `linecast weather` for the full forecast
15
#
16
# NWS asks callers for a contactable User-Agent and for restraint, and this
17
# honours both. The grid lookup is cached for a day because a grid square does
18
# not move, and the rendered line for NWS_INTERVAL seconds, so the polybar
19
# restarts that screenchange-reload makes routine cost no requests at all. A
20
# failed fetch falls back to the last good line rather than blanking the bar.
21
set -u
22
23
# The location, in decimal degrees. There is no geolocation lookup here -- set
24
# both, in modules/weather.ini or in the environment, or the module stays blank
25
# and says why on stderr.
26
LAT=${NWS_LAT:-}
27
LON=${NWS_LON:-}
28
# us = degrees F, si = degrees C. Passed straight through to NWS for the
29
# forecast; observations always arrive in Celsius and are converted here.
30
UNITS=${NWS_UNITS:-us}
31
# Seconds a rendered line is reused before refetching. The module may poll
32
# faster than this -- it just gets the cache back until this expires.
33
INTERVAL=${NWS_INTERVAL:-900}
34
# NWS wants a User-Agent it can contact about a misbehaving client; an email
35
# address or a URL is what they ask for. Put yours here.
36
UA=${NWS_UA:-"polybar-nws (https://github.com/polybar/polybar-scripts)"}
37
# Polybar colour markup, left empty so the script prints plain text anywhere
38
# else. modules/weather.ini fills both in from colors.ini, which is where the
39
# palette belongs -- nothing in here should need editing to match the bar.
40
ICON_COLOR=${NWS_ICON_COLOR:-}
41
ALERT_COLOR=${NWS_ALERT_COLOR:-}
42
# 1 names the active alert on the bar as well as flagging it. Off by default:
43
# "Severe Thunderstorm Warning" is wider than everything else put together,
44
# and it is one click away in `detail` either way.
45
SHOW_ALERT_LABEL=${NWS_SHOW_ALERT_LABEL:-0}
46
47
SYMBOL=°
48
API=https://api.weather.gov
49
CACHE=${XDG_CACHE_HOME:-$HOME/.cache}/polybar-nws
50
GRID=$CACHE/grid
51
# Icon, temperature and alert event, tab separated -- deliberately not the
52
# finished bar string. The colours arrive from the module, so a bare run from a
53
# shell has none; caching a painted line would let whichever ran last decide
54
# how the bar looked for the next quarter hour, and would freeze the palette
55
# in until the cache expired.
56
LINE=$CACHE/line
57
# How long the /points and station lookup is trusted. The square does not move
58
# now that the coordinates are pinned, but stations do come and go, so the
59
# lookup is redone daily.
60
GRID_TTL=86400
61
62
# NWS condition token -> glyph. $1 is day or night, $2 the token lifted out of
63
# the icon URL; the full token list is at https://api.weather.gov/icons.
64
#
65
# These are nf-md (Material Design) glyphs, the family every other module here
66
# draws from. nf-weather has a finer-grained set, but it is a visibly lighter
67
# weight and read as pasted-in next to the solid icons around it.
68
#
69
# Every glyph below was checked at 13pt against the bar's own font. Material
70
# only splits day from night for clear and partly cloudy skies; nothing else
71
# needs it, since rain looks like rain whatever the hour.
72
get_icon() {
73
    case $2 in
74
        skc)                          d='󰖙'; n='󰖔';;
75
        few|sct)                      d='󰖕'; n='󰼱';;
76
        bkn|ovc)                      d='󰖐'; n='󰖐';;
77
        wind_skc|wind_few|wind_sct|wind_bkn|wind_ovc)
78
                                      d='󰖝'; n='󰖝';;
79
        rain)                         d='󰖖'; n='󰖖';;
80
        rain_showers|rain_showers_hi) d='󰖗'; n='󰖗';;
81
        tsra|tsra_sct|tsra_hi)        d='󰙾'; n='󰙾';;
82
        snow)                         d='󰖘'; n='󰖘';;
83
        blizzard)                     d='󰼶'; n='󰼶';;
84
        sleet|fzra|rain_snow|rain_sleet|snow_sleet|rain_fzra|snow_fzra)
85
                                      d='󰙿'; n='󰙿';;
86
        fog)                          d='󰖑'; n='󰖑';;
87
        haze|dust)                    d='󰼰'; n='󰼰';;
88
        smoke)                        d='󰩱'; n='󰩱';;
89
        hot)                          d='󱃂'; n='󱃂';;
90
        cold)                         d='󱃃'; n='󱃃';;
91
        tornado)                      d='󰼸'; n='󰼸';;
92
        hurricane|tropical_storm)     d='󰢘'; n='󰢘';;
93
        *)                            d='󰼯'; n='󰼯';;
94
    esac
95
96
    if [ "$1" = night ]; then printf '%s' "$n"; else printf '%s' "$d"; fi
97
}
98
99
# The condition lives in the icon URL's path, not in a field of its own:
100
#   .../icons/land/day/tsra_hi,40/tsra,60?size=medium
101
# Prints "day tsra_hi" -- the leading period wins, and both the ",40" chance
102
# suffix and any second period are dropped.
103
icon_parts() {
104
    printf '%s' "${1%%\?*}" | awk -F/ '
105
        {
106
            for (i = 1; i < NF; i++)
107
                if ($i == "day" || $i == "night") {
108
                    sub(/,.*/, "", $(i + 1))
109
                    print $i, $(i + 1)
110
                    found = 1
111
                    exit
112
                }
113
        }
114
        END { if (!found) print "day unknown" }'
115
}
116
117
# curl with the headers NWS expects, plus a retry: gridpoint requests return
118
# a 500 often enough that one attempt is not a fair test.
119
fetch() {
120
    curl -sfL --compressed --max-time 8 --retry 2 --retry-delay 1 \
121
        -H "User-Agent: $UA" -H "Accept: application/geo+json" "$1"
122
}
123
124
# $2 wrapped in polybar's colour markup, or left alone when $1 is empty.
125
paint() {
126
    if [ -n "$1" ]; then printf '%%{F%s}%s%%{F-}' "$1" "$2"; else printf '%s' "$2"; fi
127
}
128
129
# The bar line, built from the three cached fields: glyph, temperature, and the
130
# active alert event (empty when there is none). Painting happens here, at the
131
# last possible moment, so the module's colours apply to a cached reading too.
132
#
133
# The trailing space goes *inside* the colour run. polybar clips each %{F...}
134
# segment at the advance width of the text in it, and these glyphs draw wider
135
# than the single cell they advance -- put the space after the %{F-} and the
136
# overflow is cut off, leaving a half-rendered blob. It is the same reason
137
# every other module here writes format-prefix = "<glyph> " rather than
138
# hanging the space off the label.
139
format_line() {
140
    line="$(paint "$ICON_COLOR" "$1 ")$2$SYMBOL"
141
    if [ -n "$3" ]; then
142
        if [ "$SHOW_ALERT_LABEL" = 1 ]; then flag="󰀦 $3"; else flag='󰀦'; fi
143
        line="$(paint "$ALERT_COLOR" "$flag ")$line"
144
    fi
145
    printf '%s\n' "$line"
146
}
147
148
# One field out of a JSON blob on stdin's place, empty rather than "null".
149
j() { printf '%s' "$1" | jq -r "$2 // empty" 2>/dev/null; }
150
151
# Seconds since $1 was last written; effectively infinite if it is not there.
152
age() {
153
    if [ -f "$1" ]; then
154
        echo $(( $(date +%s) - $(stat -c %Y "$1") ))
155
    else
156
        echo 999999999
157
    fi
158
}
159
160
# Observations are always Celsius no matter what ?units= asked for, so the
161
# conversion happens here rather than being left to the API.
162
c_to_display() {
163
    [ -n "${1:-}" ] || return 1
164
    if [ "$UNITS" = us ]; then
165
        awk -v c="$1" 'BEGIN { printf "%.0f", c * 9 / 5 + 32 }'
166
    else
167
        awk -v c="$1" 'BEGIN { printf "%.0f", c }'
168
    fi
169
}
170
171
# Whether $obs is recent enough to show as "now". Stations that report hourly
172
# routinely skip a cycle, so the cutoff is two hours; past that the forecast is
173
# the better answer. busybox date cannot parse an ISO stamp with an offset, and
174
# jq's fromdateiso8601 insists on a "Z" -- NWS only ever sends "+00:00", so that
175
# is the one offset translated, and anything else counts as fresh rather than
176
# throwing away a good observation over a timestamp this cannot read.
177
fresh_obs() {
178
    [ -n "$obs" ] || return 1
179
    ts=$(j "$obs" '.properties.timestamp | sub("\\+00:00$"; "Z") | fromdateiso8601')
180
    [ -n "$ts" ] || return 0
181
    [ $(( $(date +%s) - ts )) -lt 7200 ]
182
}
183
184
# Resolve coordinates to the three URLs everything else needs, and cache them.
185
# NWS models the country as a grid of 2.5km squares; /points is the only way
186
# to learn which square a latitude and longitude fall in, and the answer is
187
# stable, so this runs once a day at most.
188
resolve_grid() {
189
    if [ -z "$LAT" ] || [ -z "$LON" ]; then
190
        echo "${0##*/}: set NWS_LAT and NWS_LON to the location you want" >&2
191
        return 1
192
    fi
193
194
    # NWS caps coordinate precision at four decimals: /points answers anything
195
    # longer with a 301 to the truncated form, and /alerts just returns a 500.
196
    # Round here so neither can surprise a hand-set NWS_LAT.
197
    loc=$(printf '%s' "$LAT,$LON" | awk -F, '{ printf "%.4f,%.4f", $1, $2 }')
198
199
    points=$(fetch "$API/points/$loc") || return 1
200
    hourly=$(j "$points" '.properties.forecastHourly')
201
    stations=$(j "$points" '.properties.observationStations')
202
    [ -n "$hourly" ] && [ -n "$stations" ] || return 1
203
204
    # The station list is not sorted by distance despite looking like it is --
205
    # for Denver it leads with an airport 24km up the highway -- so pick the
206
    # nearest one properly. Flat-earth distance with a cosine correction on the
207
    # longitude is plenty over the handful of kilometres in play here.
208
    station=$(fetch "$stations" | jq -r --argjson lat "${loc%,*}" --argjson lon "${loc#*,}" '
209
        [ .features[]
210
          | { id: .properties.stationIdentifier,
211
              dx: ((.geometry.coordinates[0] - $lon) * (($lat * 3.14159265 / 180) | cos)),
212
              dy: (.geometry.coordinates[1] - $lat) } ]
213
        | min_by(.dx * .dx + .dy * .dy)
214
        | .id // empty')
215
    [ -n "$station" ] || return 1
216
217
    mkdir -p "$CACHE"
218
    printf '%s\n%s\n%s\n' "$hourly" "$station" "$loc" > "$GRID"
219
}
220
221
# Populates grid_hourly, grid_station and grid_point.
222
load_grid() {
223
    if [ "$(age "$GRID")" -gt "$GRID_TTL" ]; then
224
        resolve_grid || [ -f "$GRID" ] || return 1
225
    fi
226
    grid_hourly=$(sed -n 1p "$GRID")
227
    grid_station=$(sed -n 2p "$GRID")
228
    grid_point=$(sed -n 3p "$GRID")
229
    [ -n "$grid_hourly" ]
230
}
231
232
# NWS hands back active alerts in no particular order, so severity has to be
233
# sorted on rather than assumed -- the bar only ever shows the worst one. The
234
# severity has to be bound before the ranking array is piped in, or the index
235
# lookup reads .severity off the array instead of off the alert.
236
ALERT_ORDER='[.features[].properties]
237
    | sort_by((.severity // "Unknown") as $s
238
              | (["Extreme", "Severe", "Moderate", "Minor"] | index($s)) // 9)'
239
240
# The most serious active alert for our point, or nothing.
241
active_alert() {
242
    fetch "$API/alerts/active?point=$grid_point" | jq -r "$ALERT_ORDER | .[0].event // empty"
243
}
244
245
render() {
246
    load_grid || return 1
247
248
    temp=
249
    icon_url=
250
    obs=$(fetch "$API/stations/$grid_station/observations/latest")
251
252
    # The observation is a real measurement and is what "now" ought to mean.
253
    if fresh_obs; then
254
        temp=$(c_to_display "$(j "$obs" '.properties.temperature.value')") || temp=
255
        icon_url=$(j "$obs" '.properties.icon')
256
    fi
257
258
    # Small stations drop out for hours at a time, and a four-hour-old reading
259
    # is worse than none. Period 1 of the hourly forecast covers the current
260
    # hour and stands in when that happens -- which is why this request sits
261
    # down here rather than up beside the observation: in the ordinary case the
262
    # station answers and it never runs at all.
263
    if [ -z "$temp" ] || [ -z "$icon_url" ]; then
264
        hourly=$(fetch "$grid_hourly?units=$UNITS")
265
        [ -n "$temp" ] || temp=$(j "$hourly" '.properties.periods[0].temperature')
266
        [ -n "$icon_url" ] || icon_url=$(j "$hourly" '.properties.periods[0].icon')
267
    fi
268
269
    [ -n "$temp" ] || return 1
270
    # NWS sends whole numbers today; this is here so a stray decimal cannot
271
    # turn up inside the bar label.
272
    temp=${temp%%.*}
273
274
    # Unquoted on purpose: icon_parts prints the two arguments get_icon takes.
275
    icon=$(get_icon $(icon_parts "$icon_url"))
276
    alert=$(active_alert)
277
278
    mkdir -p "$CACHE"
279
    printf '%s\t%s\t%s\n' "$icon" "$temp" "$alert" > "$LINE"
280
    format_line "$icon" "$temp" "$alert"
281
}
282
283
# The cached reading, repainted with whatever colours this run was given.
284
# Fails on a missing file, and on the painted single-field line older versions
285
# of this script wrote, so either one just falls through to a fresh fetch.
286
show_cached() {
287
    [ -f "$LINE" ] || return 1
288
    IFS=$(printf '\t') read -r icon temp alert < "$LINE" || return 1
289
    [ -n "$temp" ] || return 1
290
    format_line "$icon" "$temp" "$alert"
291
}
292
293
# The bar line, from cache when it is still fresh. A failed fetch prints the
294
# last good line however old it is: a stale temperature is more use on a bar
295
# than a module that vanishes whenever the wifi hiccups.
296
bar() {
297
    if [ "$(age "$LINE")" -lt "$INTERVAL" ] && show_cached; then
298
        return 0
299
    fi
300
    render && return 0
301
302
    # Nothing new and nothing cached is either the first run without a network
303
    # or a location NWS does not cover. Say so on stderr, where polybar's log
304
    # will keep it, and exit clean so polybar does not treat it as a crash.
305
    show_cached ||
306
        echo "${0##*/}: no forecast (offline, or outside NWS coverage)" >&2
307
    return 0
308
}
309
310
# Everything that does not fit on the bar. linecast already draws the whole
311
# thing -- hourly curve, alerts, sun times -- in a terminal, and does it better
312
# than a notification body can, so the click just opens it. exec because
313
# nothing here needs to outlive the terminal.
314
#
315
# $TERMINAL comes from the shell profile, which polybar only inherits if it was
316
# started from a login session; wezterm is the fallback so a click still works
317
# when it was not. -e is what wezterm, alacritty, kitty and xterm all take.
318
detail() {
319
    exec "${TERMINAL:-wezterm}" -e linecast weather
320
}
321
322
case "${1:-bar}" in
323
    bar)    bar ;;
324
    detail) detail ;;
325
    *) echo "usage: ${0##*/} [bar|detail]" >&2; exit 2 ;;
326
esac