#!/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 require_cmd ffmpeg 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 via ffmpeg concat demuxer --------------------- # ImageMagick's PNG writer on Arch lacks APNG support; ffmpeg's apng muxer # handles it. Per-frame durations: last frame gets END_PAUSE. mkdir -p "$PUBLISH_DIR" TMP_APNG="${OUT_DIR}/loop.apng.tmp" LIST_FILE="${OUT_DIR}/concat.txt" : > "$LIST_FILE" last_idx=$(( ${#composited[@]} - 1 )) for i in "${!composited[@]}"; do printf "file '%s'\n" "${composited[$i]}" >> "$LIST_FILE" if [ "$i" -eq "$last_idx" ]; then printf "duration %s\n" "$(awk "BEGIN{print $END_PAUSE/100}")" >> "$LIST_FILE" else printf "duration %s\n" "$(awk "BEGIN{print $FRAME_DELAY/100}")" >> "$LIST_FILE" fi done # ffmpeg concat quirk: last file must be repeated without a duration line printf "file '%s'\n" "${composited[$last_idx]}" >> "$LIST_FILE" ffmpeg -y -hide_banner -loglevel error \ -f concat -safe 0 -i "$LIST_FILE" \ -plays 0 -vf format=rgba -f apng "$TMP_APNG" install -m 644 "$TMP_APNG" "$PUBLISH_PATH" rm -f "$TMP_APNG" echo "Published ${PUBLISH_PATH} (frames=${#composited[@]}, latest=${remote_frames[-1]})"