| 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 |