Python pskr_mqtt_listen.py: - Guard against malformed CONNACK/SUBACK/PUBLISH packets - Fix _s() truthiness: is not None instead of if v (zero SNR was lost) - EINTR-safe select loop, OOB data handling, VBInt overflow check - Proper socket cleanup with try/finally + DISCONNECT on shutdown Elixir: - Log dropped spot errors in aggregator (was silently discarded) - Wire retain_scores_window into NotifyListener chain completion - Recurse sweep_tmp_dir into band/weather_scalars subdirectories - Rescue recalibrator.run/0 to write failed status row on crash - Handle nil in fmt_snr/fmt_callsigns (no more nil dB crashes) - Replace Stream.run with Enum.reduce logging exits in poll_worker - Handle File.stat race in ms_footprints prune, grid_center nil log - Fix unused variables, stale comments, missing get_path!/1 Rust: - Graceful JoinError handling in fetcher/hrdps_fetcher (no more panics) - round_to_5min returns Option (leap-second safe) - Acquire/Release ordering on shutdown flags (was Relaxed) - Parameterized NOTIFY in db.rs, defensive scalar sweep, NaN guard - OsString::push for tmp naming, clippy fixes
217 lines
6.7 KiB
Elixir
217 lines
6.7 KiB
Elixir
defmodule MicrowavepropWeb.PskrSpotsLive do
|
||
@moduledoc "`/pskreporter` — recent PSK Reporter spot aggregates. Refreshes every 60s."
|
||
use MicrowavepropWeb, :live_view
|
||
|
||
import Ecto.Query
|
||
|
||
alias Microwaveprop.Format
|
||
alias Microwaveprop.Pskr.SpotHourly
|
||
alias Microwaveprop.Repo
|
||
|
||
require Logger
|
||
|
||
@limit 100
|
||
@refresh_ms 60_000
|
||
|
||
@impl true
|
||
def mount(_params, _session, socket) do
|
||
_ = if connected?(socket), do: schedule_refresh()
|
||
|
||
spots = fetch_recent_spots()
|
||
socket = start_async(socket, :total_spots, fn -> fetch_total_spots() end)
|
||
socket = start_async(socket, :band_counts, fn -> fetch_band_counts() end)
|
||
|
||
{:ok,
|
||
socket
|
||
|> assign(
|
||
page_title: "PSK Reporter Spots",
|
||
limit: @limit,
|
||
total_spots: nil,
|
||
band_counts: [],
|
||
band_filter: nil,
|
||
spots_empty: spots == []
|
||
)
|
||
|> stream(:spots, spots, reset: true)}
|
||
end
|
||
|
||
@impl true
|
||
def handle_async(:total_spots, {:ok, total}, socket) do
|
||
{:noreply, assign(socket, total_spots: total)}
|
||
end
|
||
|
||
def handle_async(:band_counts, {:ok, counts}, socket) do
|
||
{:noreply, assign(socket, band_counts: counts)}
|
||
end
|
||
|
||
def handle_async(slot, {:exit, reason}, socket) do
|
||
Logger.warning("PskrSpotsLive async #{slot} failed: #{inspect(reason)}")
|
||
{:noreply, socket}
|
||
end
|
||
|
||
@impl true
|
||
def handle_info(:refresh_spots, socket) do
|
||
_ = schedule_refresh()
|
||
band = socket.assigns.band_filter
|
||
spots = fetch_recent_spots(band)
|
||
|
||
socket = start_async(socket, :total_spots, fn -> fetch_total_spots() end)
|
||
socket = start_async(socket, :band_counts, fn -> fetch_band_counts() end)
|
||
|
||
{:noreply,
|
||
socket
|
||
|> assign(spots_empty: spots == [])
|
||
|> stream(:spots, spots, reset: true)}
|
||
end
|
||
|
||
@impl true
|
||
def handle_event("filter_band", %{"band" => band}, socket) do
|
||
band_filter = if socket.assigns.band_filter == band, do: nil, else: band
|
||
spots = fetch_recent_spots(band_filter)
|
||
|
||
{:noreply,
|
||
socket
|
||
|> assign(band_filter: band_filter, spots_empty: spots == [])
|
||
|> stream(:spots, spots, reset: true)}
|
||
end
|
||
|
||
def handle_event("clear_filter", _params, socket) do
|
||
spots = fetch_recent_spots()
|
||
|
||
{:noreply,
|
||
socket
|
||
|> assign(band_filter: nil, spots_empty: spots == [])
|
||
|> stream(:spots, spots, reset: true)}
|
||
end
|
||
|
||
defp schedule_refresh do
|
||
Process.send_after(self(), :refresh_spots, @refresh_ms)
|
||
end
|
||
|
||
defp fetch_recent_spots(band \\ nil) do
|
||
query = from(s in SpotHourly, order_by: [desc: s.last_spot_at], limit: @limit)
|
||
|
||
query =
|
||
if band, do: from(s in query, where: s.band == ^band), else: query
|
||
|
||
Repo.all(query)
|
||
end
|
||
|
||
defp fetch_total_spots do
|
||
Repo.one(from(s in SpotHourly, select: coalesce(sum(s.spot_count), 0)))
|
||
end
|
||
|
||
defp fetch_band_counts do
|
||
Repo.all(
|
||
from(s in SpotHourly,
|
||
group_by: s.band,
|
||
select: {s.band, sum(s.spot_count)},
|
||
order_by: [desc: sum(s.spot_count)]
|
||
)
|
||
)
|
||
end
|
||
|
||
defp fmt_dt(nil), do: "—"
|
||
defp fmt_dt(dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M UTC")
|
||
|
||
defp fmt_snr(nil, _max), do: "—"
|
||
defp fmt_snr(_min, nil), do: "—"
|
||
defp fmt_snr(min, max) when min == max, do: "#{min} dB"
|
||
defp fmt_snr(min, max), do: "#{min}–#{max} dB"
|
||
|
||
defp fmt_callsigns(nil), do: "—"
|
||
defp fmt_callsigns([]), do: "—"
|
||
defp fmt_callsigns(calls), do: Enum.join(calls, ", ")
|
||
|
||
@impl true
|
||
def render(assigns) do
|
||
~H"""
|
||
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-none">
|
||
<.header>
|
||
PSK Reporter Spots
|
||
<:subtitle>
|
||
<%= if @band_filter do %>
|
||
Last {@limit} <span class="badge badge-sm">{@band_filter}</span> spot aggregates
|
||
<% else %>
|
||
Last {@limit} spot aggregates from the MQTT firehose
|
||
<% end %>
|
||
</:subtitle>
|
||
</.header>
|
||
|
||
<div class="flex flex-wrap items-baseline gap-x-6 gap-y-1 text-sm mb-4">
|
||
<span class="font-semibold">
|
||
Total spots stored: <span class="font-mono">{Format.number(@total_spots)}</span>
|
||
</span>
|
||
<button
|
||
:for={{band, count} <- @band_counts}
|
||
phx-click="filter_band"
|
||
phx-value-band={band}
|
||
class={[
|
||
"cursor-pointer transition-colors rounded px-1 -mx-1 py-0.5",
|
||
if(@band_filter == band,
|
||
do: "bg-primary/15 text-primary font-semibold",
|
||
else: "hover:bg-base-300 text-base-content/70"
|
||
)
|
||
]}
|
||
>
|
||
<span class="badge badge-sm mr-1">{band}</span>
|
||
<span class="font-mono">{Format.number(count)}</span>
|
||
</button>
|
||
<button
|
||
:if={@band_filter}
|
||
phx-click="clear_filter"
|
||
class="text-xs text-primary hover:underline cursor-pointer"
|
||
>
|
||
Clear filter
|
||
</button>
|
||
</div>
|
||
|
||
<div class="overflow-x-auto">
|
||
<table class="table table-sm table-zebra whitespace-nowrap">
|
||
<thead>
|
||
<tr>
|
||
<th>Last Heard (UTC)</th>
|
||
<th>Band</th>
|
||
<th>From</th>
|
||
<th>From Grid</th>
|
||
<th>To</th>
|
||
<th>To Grid</th>
|
||
<th>Distance</th>
|
||
<th class="text-right">Spots</th>
|
||
<th>SNR</th>
|
||
<th>Modes</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="pskr-spots" phx-update="stream">
|
||
<tr :for={{dom_id, spot} <- @streams.spots} id={dom_id}>
|
||
<td class="text-xs font-mono">{fmt_dt(spot.last_spot_at)}</td>
|
||
<td class="text-xs"><span class="badge badge-sm">{spot.band}</span></td>
|
||
<td class="text-xs font-mono whitespace-nowrap">
|
||
{fmt_callsigns(spot.sender_callsigns)}
|
||
</td>
|
||
<td class="text-xs font-mono">{spot.sender_grid}</td>
|
||
<td class="text-xs font-mono whitespace-nowrap">
|
||
{fmt_callsigns(spot.receiver_callsigns)}
|
||
</td>
|
||
<td class="text-xs font-mono">{spot.receiver_grid}</td>
|
||
<td class="text-xs font-mono">{Format.distance_km(spot.distance_km)}</td>
|
||
<td class="text-xs text-right font-mono">{Format.number(spot.spot_count)}</td>
|
||
<td class="text-xs font-mono">{fmt_snr(spot.min_snr_db, spot.max_snr_db)}</td>
|
||
<td class="text-xs">
|
||
<span :for={mode <- spot.modes} class="badge badge-xs mr-0.5">{mode}</span>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<p :if={@spots_empty} class="text-sm text-base-content/50 italic mt-4">
|
||
<%= if @band_filter do %>
|
||
No spots found for band {@band_filter}.
|
||
<% else %>
|
||
No PSK Reporter spots received yet. The MQTT listener is running — spots will appear here as they arrive.
|
||
<% end %>
|
||
</p>
|
||
</Layouts.app>
|
||
"""
|
||
end
|
||
end
|