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