From f058e83da43b0b661b45a7bd4c82b49c57e61d93 Mon Sep 17 00:00:00 2001 From: St33v Date: Tue, 9 Jun 2026 13:04:04 +1000 Subject: Add IDR713 radar fetch and APNG loop radarFetch.sh fetches BOM IDR713 transparencies (24h cache) and the last six 6-minute echo frames, composites them via cached lower/upper plates, and publishes an APNG loop to /srv/www/radar/. systemd: radar.{service,timer} on a 6-min cadence, with retry units mirroring the synoptic pattern. nginx: new radar.pestrel.com vhost (still commented in setup.sh until DNS propagation is confirmed). setup.sh provisions radar dirs, installs radar units, enables timer. deploy.sh accepts synoptic|radar arg. Parameterised on product code; adding another radar is a one-line config change. Spec: doc/bom-radar-spec.md. Co-Authored-By: Claude Opus 4.7 --- radarFetch.sh | 190 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100755 radarFetch.sh (limited to 'radarFetch.sh') diff --git a/radarFetch.sh b/radarFetch.sh new file mode 100755 index 0000000..0dcc457 --- /dev/null +++ b/radarFetch.sh @@ -0,0 +1,190 @@ +#!/bin/bash +set -euo pipefail +shopt -s nullglob + +# SJP 2026-06-09 +# Fetch BOM radar layers via anonymous FTP, alpha-composite them, +# publish the last FRAME_COUNT frames as an APNG loop. +# Parameterised on product code; default IDR713 (Sydney 128 km). +# Deployed to /opt/radar/radarFetch.sh on cremonde for radar.pestrel.com. + +RADAR_ID="${1:-IDR713}" +FRAME_COUNT="${FRAME_COUNT:-6}" +FRAME_DELAY="${FRAME_DELAY:-50}" # centiseconds per frame +END_PAUSE="${END_PAUSE:-150}" # centiseconds, pause on last frame +TRANSPARENCY_TTL_HOURS="${TRANSPARENCY_TTL_HOURS:-24}" + +DATA_ROOT="/var/lib/radar/${RADAR_ID,,}" +TRANS_DIR="${DATA_ROOT}/transparencies" +FRAMES_DIR="${DATA_ROOT}/frames" +PLATES_DIR="${DATA_ROOT}/plates" +OUT_DIR="${DATA_ROOT}/out" +PUBLISH_DIR="/srv/www/radar" +PUBLISH_PATH="${PUBLISH_DIR}/${RADAR_ID,,}-loop.apng" + +FTP_DYNAMIC="ftp://ftp.bom.gov.au/anon/gen/radar/" +FTP_STATIC="ftp://ftp.bom.gov.au/anon/gen/radar_transparencies/" + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || { echo "Required command not found: $1" >&2; exit 127; } +} +require_cmd curl +require_cmd magick + +mkdir -p "$TRANS_DIR" "$FRAMES_DIR" "$PLATES_DIR" "$OUT_DIR" + +fetch_validated() { + # $1 url $2 dest + local url="$1" dest="$2" tmp="${2}.tmp" + curl -q -fs -o "$tmp" "$url" || { rm -f "$tmp"; return 1; } + if magick identify "$tmp" >/dev/null 2>&1; then + mv "$tmp" "$dest" + else + rm -f "$tmp" + return 1 + fi +} + +# --- 1. Refresh transparencies if stale ---------------------------------- +TRANS_MARKER="${TRANS_DIR}/.last_refreshed" +needs_refresh=1 +if [ -f "$TRANS_MARKER" ]; then + age_h=$(( ( $(date +%s) - $(stat -c %Y "$TRANS_MARKER") ) / 3600 )) + [ "$age_h" -lt "$TRANSPARENCY_TTL_HOURS" ] && needs_refresh=0 +fi + +if [ "$needs_refresh" -eq 1 ]; then + echo "Refreshing transparencies for ${RADAR_ID}..." + if listing=$(curl -q -fs "$FTP_STATIC"); then + # ls -l style listing; filename is the last whitespace-delimited field + while read -r fname; do + [ -n "$fname" ] || continue + fetch_validated "${FTP_STATIC}${fname}" "${TRANS_DIR}/${fname}" \ + || echo " failed: ${fname}" >&2 + done < <(echo "$listing" | awk '{print $NF}' | grep "^${RADAR_ID}\." || true) + touch "$TRANS_MARKER" + # invalidate precomposited plates + rm -f "${PLATES_DIR}/lower.png" "${PLATES_DIR}/upper.png" + else + echo "Transparency listing failed; keeping cached copies." >&2 + fi +fi + +# --- 2. Build precomposited plates --------------------------------------- +BACKGROUND="${TRANS_DIR}/${RADAR_ID}.background.png" +if [ ! -f "$BACKGROUND" ]; then + echo "No background transparency cached; cannot composite." >&2 + exit 1 +fi + +existing_layers() { + # echo paths that exist, in given order + local base p + for base in "$@"; do + p="${TRANS_DIR}/${RADAR_ID}.${base}.png" + [ -f "$p" ] && echo "$p" + done +} + +if [ ! -f "${PLATES_DIR}/lower.png" ]; then + # background (opaque) + topography + optional feature overlays under the echo + readarray -t lower_layers < <(existing_layers \ + background topography catchments waterways wthrDistricts roads rail) + magick "${lower_layers[@]}" -background none -layers flatten "${PLATES_DIR}/lower.png" +fi + +if [ ! -f "${PLATES_DIR}/upper.png" ]; then + # range rings + place labels go over the echo so they stay legible + readarray -t upper_layers < <(existing_layers range locations) + if [ "${#upper_layers[@]}" -gt 0 ]; then + magick "${upper_layers[@]}" -background none -layers flatten "${PLATES_DIR}/upper.png" + fi +fi + +# --- 3. Determine current top-N frames on the FTP ------------------------ +echo "Listing dynamic frames..." +if ! listing=$(curl -q -fs "$FTP_DYNAMIC"); then + echo "Dynamic listing failed; serving last good loop." >&2 + exit 0 +fi + +mapfile -t remote_frames < <(echo "$listing" \ + | awk '{print $NF}' \ + | grep "^${RADAR_ID}\.T\.[0-9]\{12\}\.png$" \ + | sort \ + | tail -n "$FRAME_COUNT") + +if [ "${#remote_frames[@]}" -eq 0 ]; then + echo "No remote frames for ${RADAR_ID}; serving last good loop." >&2 + exit 0 +fi + +# --- 4. Fetch missing frames; evict frames outside the rolling buffer ---- +for fname in "${remote_frames[@]}"; do + if [ ! -f "${FRAMES_DIR}/${fname}" ]; then + echo " fetching ${fname}" + fetch_validated "${FTP_DYNAMIC}${fname}" "${FRAMES_DIR}/${fname}" \ + || echo " failed: ${fname}" >&2 + fi +done + +keep_set=" $(printf '%s ' "${remote_frames[@]}")" +for existing in "$FRAMES_DIR"/${RADAR_ID}.T.*.png; do + bn=$(basename "$existing") + case "$keep_set" in + *" $bn "*) ;; + *) rm -f "$existing" ;; + esac +done + +# --- 5. Composite each held frame ---------------------------------------- +LEGEND="${TRANS_DIR}/${RADAR_ID}.legend.0.png" +HAVE_UPPER=0; [ -f "${PLATES_DIR}/upper.png" ] && HAVE_UPPER=1 +HAVE_LEGEND=0; [ -f "$LEGEND" ] && HAVE_LEGEND=1 + +# clear stale composited frames +rm -f "${OUT_DIR}"/frame.*.png + +composited=() +seq=0 +for fname in "${remote_frames[@]}"; do + src="${FRAMES_DIR}/${fname}" + [ -f "$src" ] || continue + out="${OUT_DIR}/frame.$(printf '%02d' "$seq").png" + + cmd=(magick "${PLATES_DIR}/lower.png" "$src" -composite) + [ "$HAVE_UPPER" -eq 1 ] && cmd+=("${PLATES_DIR}/upper.png" -composite) + [ "$HAVE_LEGEND" -eq 1 ] && cmd+=(-gravity southeast "$LEGEND" -composite +gravity) + cmd+=("$out") + "${cmd[@]}" + + composited+=("$out") + seq=$((seq + 1)) +done + +if [ "${#composited[@]}" -eq 0 ]; then + echo "No frames composited; serving last good loop." >&2 + exit 0 +fi + +# --- 6. Assemble APNG loop ----------------------------------------------- +# Per-frame delays: last frame gets END_PAUSE so the eye can rest on "now". +mkdir -p "$PUBLISH_DIR" +TMP_APNG="${OUT_DIR}/loop.apng.tmp" + +build_cmd=(magick -loop 0) +last_idx=$(( ${#composited[@]} - 1 )) +for i in "${!composited[@]}"; do + if [ "$i" -eq "$last_idx" ]; then + build_cmd+=(-delay "$END_PAUSE" "${composited[$i]}") + else + build_cmd+=(-delay "$FRAME_DELAY" "${composited[$i]}") + fi +done +build_cmd+=("$TMP_APNG") +"${build_cmd[@]}" + +install -m 644 "$TMP_APNG" "$PUBLISH_PATH" +rm -f "$TMP_APNG" + +echo "Published ${PUBLISH_PATH} (frames=${#composited[@]}, latest=${remote_frames[-1]})" -- cgit v1.3