chore: updated weather 71f9f89e
Steve · 2026-08-31 23:19 3 file(s) · +38 −87
polybar/modules/weather.ini +4 −2
7 7
8 8
; scripts/ is symlinked into ~/.config/polybar the same way rofi/scripts is.
9 9
exec = ~/.config/polybar/scripts/weather
10 +
; Opens $TERMINAL (wezterm if polybar did not inherit one) on `linecast weather`.
10 11
click-left = ~/.config/polybar/scripts/weather detail
11 12
12 13
; The script serves a cached line until NWS_INTERVAL (15 minutes) is up, so
19 20
env-NWS_ICON_COLOR = ${colors.primary}
20 21
env-NWS_ALERT_COLOR = ${colors.alert}
21 22
22 -
; Pin the location to skip the geolocation lookup, and put a real address in
23 -
; NWS_UA -- NWS asks for one it can contact about a misbehaving client.
23 +
; Required: the script does no geolocation, so the module stays blank until
24 +
; both of these name a spot inside NWS coverage. Put a real address in NWS_UA
25 +
; too -- NWS asks for one it can contact about a misbehaving client.
24 26
; env-NWS_LAT = 36.1627
25 27
; env-NWS_LON = -86.7816
26 28
; env-NWS_UA = (dotfiles polybar, you@example.com)
polybar/scripts/weather +30 −85
1 1
#!/bin/sh
2 -
# Requires: curl, jq, a Nerd Font (the glyphs are nf-md-*), and dunst or any
3 -
# notification daemon for the `detail` action.
2 +
# Requires: curl, jq, a Nerd Font (the glyphs are nf-md-*), and linecast plus a
3 +
# terminal for the `detail` action.
4 4
# Weather for the bar, straight from the US National Weather Service. It began
5 5
# as polybar-scripts' openweathermap-fullfeatured, but against api.weather.gov,
6 6
# so there is no API key to keep out of the repo. The trade is that NWS only
11 11
# the same shape as every other module here. The forecast is a click away.
12 12
#
13 13
#   weather          one line for the bar (the default)
14 -
#   weather detail   a notification with the full forecast and any alerts
14 +
#   weather detail   opens $TERMINAL on `linecast weather` for the full forecast
15 15
#
16 16
# NWS asks callers for a contactable User-Agent and for restraint, and this
17 17
# honours both. The grid lookup is cached for a day because a grid square does
20 20
# failed fetch falls back to the last good line rather than blanking the bar.
21 21
set -u
22 22
23 -
# Blank asks the network where we are; set both to pin a location and skip
24 -
# the geolocation request entirely.
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.
25 26
LAT=${NWS_LAT:-}
26 27
LON=${NWS_LON:-}
27 28
# us = degrees F, si = degrees C. Passed straight through to NWS for the
53 54
# how the bar looked for the next quarter hour, and would freeze the palette
54 55
# in until the cache expired.
55 56
LINE=$CACHE/line
56 -
# How long the /points and station lookup is trusted. A day, so a laptop that
57 -
# travelled overnight re-locates on its own.
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.
58 60
GRID_TTL=86400
59 -
# vol uses 7303; a separate id so a volume popup and a forecast popup do not
60 -
# replace each other.
61 -
NOTIFY_ID=7304
62 61
63 62
# NWS condition token -> glyph. $1 is day or night, $2 the token lifted out of
64 63
# the icon URL; the full token list is at https://api.weather.gov/icons.
169 168
    fi
170 169
}
171 170
172 -
kmh_to_display() {
173 -
    [ -n "${1:-}" ] || return 1
174 -
    if [ "$UNITS" = us ]; then
175 -
        awk -v k="$1" 'BEGIN { printf "%.0f mph", k * 0.621371 }'
176 -
    else
177 -
        awk -v k="$1" 'BEGIN { printf "%.0f km/h", k }'
178 -
    fi
179 -
}
180 -
181 171
# Whether $obs is recent enough to show as "now". Stations that report hourly
182 172
# routinely skip a cycle, so the cutoff is two hours; past that the forecast is
183 173
# the better answer. busybox date cannot parse an ISO stamp with an offset, and
196 186
# to learn which square a latitude and longitude fall in, and the answer is
197 187
# stable, so this runs once a day at most.
198 188
resolve_grid() {
199 -
    if [ -n "$LAT" ] && [ -n "$LON" ]; then
200 -
        loc="$LAT,$LON"
201 -
    else
202 -
        # Two providers because both are free endpoints that go away without
203 -
        # warning; either one is accurate enough to pick a 2.5km square.
204 -
        loc=$(curl -sf --max-time 6 https://ipinfo.io/json | jq -r '.loc // empty')
205 -
        [ -n "$loc" ] || loc=$(curl -sf --max-time 6 'http://ip-api.com/json/?fields=lat,lon' |
206 -
            jq -r 'if .lat then "\(.lat),\(.lon)" else empty end')
207 -
        [ -n "$loc" ] || return 1
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
208 192
    fi
209 193
210 194
    # NWS caps coordinate precision at four decimals: /points answers anything
211 195
    # longer with a 301 to the truncated form, and /alerts just returns a 500.
212 -
    # Round here so neither can surprise a hand-set NWS_LAT later on.
213 -
    loc=$(printf '%s' "$loc" | awk -F, '{ printf "%.4f,%.4f", $1, $2 }')
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 }')
214 198
215 199
    points=$(fetch "$API/points/$loc") || return 1
216 200
    hourly=$(j "$points" '.properties.forecastHourly')
217 -
    daily=$(j "$points" '.properties.forecast')
218 201
    stations=$(j "$points" '.properties.observationStations')
219 -
    place=$(j "$points" '.properties.relativeLocation.properties |
220 -
        if .city then "\(.city), \(.state)" else empty end')
221 202
    [ -n "$hourly" ] && [ -n "$stations" ] || return 1
222 203
223 204
    # The station list is not sorted by distance despite looking like it is --
234 215
    [ -n "$station" ] || return 1
235 216
236 217
    mkdir -p "$CACHE"
237 -
    printf '%s\n%s\n%s\n%s\n%s\n' "$hourly" "$daily" "$station" "$loc" "$place" > "$GRID"
218 +
    printf '%s\n%s\n%s\n' "$hourly" "$station" "$loc" > "$GRID"
238 219
}
239 220
240 -
# Populates grid_hourly, grid_daily, grid_station, grid_point and grid_place.
221 +
# Populates grid_hourly, grid_station and grid_point.
241 222
load_grid() {
242 223
    if [ "$(age "$GRID")" -gt "$GRID_TTL" ]; then
243 224
        resolve_grid || [ -f "$GRID" ] || return 1
244 225
    fi
245 226
    grid_hourly=$(sed -n 1p "$GRID")
246 -
    grid_daily=$(sed -n 2p "$GRID")
247 -
    grid_station=$(sed -n 3p "$GRID")
248 -
    grid_point=$(sed -n 4p "$GRID")
249 -
    grid_place=$(sed -n 5p "$GRID")
227 +
    grid_station=$(sed -n 2p "$GRID")
228 +
    grid_point=$(sed -n 3p "$GRID")
250 229
    [ -n "$grid_hourly" ]
251 230
}
252 231
253 232
# NWS hands back active alerts in no particular order, so severity has to be
254 -
# sorted on rather than assumed. Shared by the bar, which wants only the worst
255 -
# one, and by detail, which lists them all. The severity has to be bound before
256 -
# the ranking array is piped in, or the index lookup reads .severity off the
257 -
# array instead of off the alert.
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.
258 236
ALERT_ORDER='[.features[].properties]
259 237
    | sort_by((.severity // "Unknown") as $s
260 238
              | (["Extreme", "Severe", "Moderate", "Minor"] | index($s)) // 9)'
329 307
    return 0
330 308
}
331 309
332 -
# Everything that does not fit on the bar: the alerts in full, what the nearest
333 -
# station is actually reading, and the NWS forecaster's own prose for the
334 -
# current period.
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.
335 318
detail() {
336 -
    load_grid || { notify "Weather" "No NWS grid for this location"; return 1; }
337 -
338 -
    obs=$(fetch "$API/stations/$grid_station/observations/latest")
339 -
    daily=$(fetch "$grid_daily?units=$UNITS")
340 -
341 -
    body=""
342 -
343 -
    alerts=$(fetch "$API/alerts/active?point=$grid_point" |
344 -
        jq -r "$ALERT_ORDER"' | .[] | "\(.event) - \(.headline // "")"')
345 -
    [ -n "$alerts" ] && body="$alerts
346 -
347 -
"
348 -
349 -
    if fresh_obs; then
350 -
        temp=$(c_to_display "$(j "$obs" '.properties.temperature.value')") || temp=""
351 -
        hum=$(j "$obs" '.properties.relativeHumidity.value')
352 -
        wind=$(kmh_to_display "$(j "$obs" '.properties.windSpeed.value')") || wind=""
353 -
        # Whichever of the two "feels like" numbers is in season; NWS leaves
354 -
        # the other null rather than sending both.
355 -
        feels=$(c_to_display "$(j "$obs" '.properties.heatIndex.value')") ||
356 -
            feels=$(c_to_display "$(j "$obs" '.properties.windChill.value')") || feels=""
357 -
358 -
        line="$(j "$obs" '.properties.textDescription') $temp$SYMBOL"
359 -
        [ -n "$feels" ] && line="$line (feels $feels$SYMBOL)"
360 -
        [ -n "$hum" ] && line="$line, $(awk -v h="$hum" 'BEGIN { printf "%.0f", h }')% humidity"
361 -
        [ -n "$wind" ] && line="$line, wind $wind"
362 -
    else
363 -
        line="No recent reading from $grid_station"
364 -
    fi
365 -
    body="$body$line
366 -
367 -
$(j "$daily" '.properties.periods[0] | "\(.name): \(.detailedForecast)"')"
368 -
369 -
    notify "${grid_place:-Weather} ($grid_station)" "$body"
370 -
}
371 -
372 -
notify() {
373 -
    dunstify -r "$NOTIFY_ID" -t 15000 "$1" "$2" 2>/dev/null ||
374 -
        notify-send "$1" "$2"
319 +
    exec "${TERMINAL:-wezterm}" -e linecast weather
375 320
}
376 321
377 322
case "${1:-bar}" in
zsh/dot-zshrc +4 −0
56 56
# pnpm
57 57
export PNPM_HOME="$HOME/Library/pnpm"
58 58
59 +
# Weather
60 +
export NWS_LAT="35.0274115"
61 +
export NWS_LONG="-85.3183822"
62 +
59 63
# fzf
60 64
export FZF_DEFAULT_COMMAND='fd --type f --hidden --exclude ".git"'
61 65
export FZF_DEFAULT_OPTS='