feat: track sender/receiver callsigns in pskr spots + full-width /pskreporter page

This commit is contained in:
Graham McInitre 2026-07-15 14:29:23 -05:00
parent a2e2776e7c
commit 51dda456aa
7 changed files with 138 additions and 6 deletions

View file

@ -58,6 +58,8 @@ defmodule Microwaveprop.Pskr do
transmit_time: DateTime.t(),
sender_grid: String.t(),
receiver_grid: String.t(),
sender_callsign: String.t() | nil,
receiver_callsign: String.t() | nil,
sender_country: integer() | nil,
receiver_country: integer() | nil
}
@ -89,6 +91,8 @@ defmodule Microwaveprop.Pskr do
transmit_time: transmit_time,
sender_grid: sender_grid,
receiver_grid: receiver_grid,
sender_callsign: normalize_callsign(Map.get(json, "sc")),
receiver_callsign: normalize_callsign(Map.get(json, "rc")),
sender_country: Map.get(json, "sa"),
receiver_country: Map.get(json, "ra")
}}
@ -135,6 +139,8 @@ defmodule Microwaveprop.Pskr do
max_snr_db: spot.snr_db,
min_snr_db: spot.snr_db,
modes: maybe_mode_list(spot.mode),
sender_callsigns: maybe_callsign_list(spot.sender_callsign),
receiver_callsigns: maybe_callsign_list(spot.receiver_callsign),
first_spot_at: spot.transmit_time,
last_spot_at: spot.transmit_time
}
@ -147,6 +153,8 @@ defmodule Microwaveprop.Pskr do
max_snr_db: max_snr(acc.max_snr_db, spot.snr_db),
min_snr_db: min_snr(acc.min_snr_db, spot.snr_db),
modes: add_mode(acc.modes, spot.mode),
sender_callsigns: add_callsign(acc.sender_callsigns, spot.sender_callsign),
receiver_callsigns: add_callsign(acc.receiver_callsigns, spot.receiver_callsign),
first_spot_at: earliest(acc.first_spot_at, spot.transmit_time),
last_spot_at: latest(acc.last_spot_at, spot.transmit_time)
}
@ -206,6 +214,16 @@ defmodule Microwaveprop.Pskr do
defp add_mode(modes, nil), do: modes
defp add_mode(modes, mode), do: if(mode in modes, do: modes, else: [mode | modes])
defp normalize_callsign(nil), do: nil
defp normalize_callsign(""), do: nil
defp normalize_callsign(call), do: String.upcase(call)
defp maybe_callsign_list(nil), do: []
defp maybe_callsign_list(call), do: [call]
defp add_callsign(calls, nil), do: calls
defp add_callsign(calls, call), do: if(call in calls, do: calls, else: [call | calls])
defp max_snr(nil, b), do: b
defp max_snr(a, nil), do: a
defp max_snr(a, b), do: max(a, b)

View file

@ -122,6 +122,10 @@ defmodule Microwaveprop.Pskr.Aggregator do
max_snr_db: fragment("GREATEST(?, EXCLUDED.max_snr_db)", s.max_snr_db),
min_snr_db: fragment("LEAST(?, EXCLUDED.min_snr_db)", s.min_snr_db),
modes: fragment("ARRAY(SELECT DISTINCT unnest(? || EXCLUDED.modes))", s.modes),
sender_callsigns:
fragment("ARRAY(SELECT DISTINCT unnest(? || EXCLUDED.sender_callsigns))", s.sender_callsigns),
receiver_callsigns:
fragment("ARRAY(SELECT DISTINCT unnest(? || EXCLUDED.receiver_callsigns))", s.receiver_callsigns),
first_spot_at: fragment("LEAST(?, EXCLUDED.first_spot_at)", s.first_spot_at),
last_spot_at: fragment("GREATEST(?, EXCLUDED.last_spot_at)", s.last_spot_at),
updated_at: fragment("EXCLUDED.updated_at")

View file

@ -46,6 +46,8 @@ defmodule Microwaveprop.Pskr.SpotHourly do
field :max_snr_db, :integer
field :min_snr_db, :integer
field :modes, {:array, :string}, default: []
field :sender_callsigns, {:array, :string}, default: []
field :receiver_callsigns, {:array, :string}, default: []
field :first_spot_at, :utc_datetime
field :last_spot_at, :utc_datetime
@ -56,8 +58,8 @@ defmodule Microwaveprop.Pskr.SpotHourly do
@cast_fields ~w(hour_utc band sender_grid receiver_grid sender_country receiver_country
sender_lat sender_lon receiver_lat receiver_lon midpoint_lat midpoint_lon
distance_km spot_count max_snr_db min_snr_db modes first_spot_at
last_spot_at)a
distance_km spot_count max_snr_db min_snr_db modes sender_callsigns
receiver_callsigns first_spot_at last_spot_at)a
@required_fields ~w(hour_utc band sender_grid receiver_grid spot_count)a

View file

@ -35,22 +35,27 @@ defmodule MicrowavepropWeb.PskrSpotsLive do
defp fmt_snr(min, max) when min == max, do: "#{min} dB"
defp fmt_snr(min, max), do: "#{min}#{max} dB"
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}>
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-none">
<.header>
PSK Reporter Spots
<:subtitle>Last {@limit} spot aggregates from the MQTT firehose</:subtitle>
</.header>
<div class="overflow-x-auto -mx-2 px-2">
<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>
@ -63,7 +68,13 @@ defmodule MicrowavepropWeb.PskrSpotsLive do
<tr>
<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>

View file

@ -0,0 +1,10 @@
defmodule Microwaveprop.Repo.Migrations.AddCallsignsToPskrSpotsHourly do
use Ecto.Migration
def change do
alter table(:pskr_spots_hourly) do
add :sender_callsigns, {:array, :string}, null: false, default: []
add :receiver_callsigns, {:array, :string}, null: false, default: []
end
end
end

View file

@ -30,6 +30,8 @@ defmodule Microwaveprop.PskrTest do
assert spot.snr_db == -5
assert spot.sender_grid == "EM12KL"
assert spot.receiver_grid == "DM43ST"
assert spot.sender_callsign == "K5ABC"
assert spot.receiver_callsign == "K7XYZ"
assert spot.sender_country == 291
assert spot.receiver_country == 291
assert %DateTime{} = spot.transmit_time
@ -173,4 +175,61 @@ defmodule Microwaveprop.PskrTest do
assert acc.modes == ["FT8"]
end
end
describe "callsign tracking" do
test "collects unique sender and receiver callsigns across merges" do
{:ok, s1} = Pskr.parse_spot(Jason.encode!(@sample))
{:ok, s2} = Pskr.parse_spot(Jason.encode!(%{@sample | "sc" => "K5DEF", "rp" => -3}))
acc =
nil
|> Pskr.merge_spot(s1)
|> Pskr.merge_spot(s2)
assert Enum.sort(acc.sender_callsigns) == ["K5ABC", "K5DEF"]
assert acc.receiver_callsigns == ["K7XYZ"]
assert acc.spot_count == 2
end
test "does not add duplicate callsigns" do
{:ok, s1} = Pskr.parse_spot(Jason.encode!(@sample))
{:ok, s2} = Pskr.parse_spot(Jason.encode!(%{@sample | "rp" => -3}))
acc =
nil
|> Pskr.merge_spot(s1)
|> Pskr.merge_spot(s2)
assert acc.sender_callsigns == ["K5ABC"]
assert acc.receiver_callsigns == ["K7XYZ"]
end
test "handles nil and empty callsigns in payload" do
nil_call = @sample |> Map.put("sc", nil) |> Jason.encode!()
{:ok, s_nil} = Pskr.parse_spot(nil_call)
assert s_nil.sender_callsign == nil
empty_call = @sample |> Map.put("sc", "") |> Jason.encode!()
{:ok, s_empty} = Pskr.parse_spot(empty_call)
assert s_empty.sender_callsign == nil
acc = nil |> Pskr.merge_spot(s_nil) |> Pskr.merge_spot(s_empty)
assert acc.sender_callsigns == []
end
test "uppercases callsigns from payload" do
json = @sample |> Map.merge(%{"sc" => "k5abc", "rc" => "k7xyz"}) |> Jason.encode!()
{:ok, spot} = Pskr.parse_spot(json)
assert spot.sender_callsign == "K5ABC"
assert spot.receiver_callsign == "K7XYZ"
end
test "initializes callsign arrays from first spot" do
{:ok, spot} = Pskr.parse_spot(Jason.encode!(@sample))
acc = Pskr.merge_spot(nil, spot)
assert acc.sender_callsigns == ["K5ABC"]
assert acc.receiver_callsigns == ["K7XYZ"]
end
end
end

View file

@ -25,10 +25,11 @@ defmodule MicrowavepropWeb.PskrSpotsLiveTest do
(id, hour_utc, band, sender_grid, receiver_grid,
sender_lat, sender_lon, receiver_lat, receiver_lon,
distance_km, spot_count, max_snr_db, min_snr_db,
modes, first_spot_at, last_spot_at,
modes, sender_callsigns, receiver_callsigns,
first_spot_at, last_spot_at,
inserted_at, updated_at)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $17)
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $19)
""",
[
dump_uuid(),
@ -45,6 +46,8 @@ defmodule MicrowavepropWeb.PskrSpotsLiveTest do
Map.get(fields, :max_snr_db, 5),
Map.get(fields, :min_snr_db, 0),
Map.get(fields, :modes, ["FT8"]),
Map.get(fields, :sender_callsigns, []),
Map.get(fields, :receiver_callsigns, []),
Map.get(fields, :first_spot_at, fields.hour_utc),
Map.get(fields, :last_spot_at, fields.hour_utc)
]
@ -104,6 +107,31 @@ defmodule MicrowavepropWeb.PskrSpotsLiveTest do
refute html =~ "33 dB"
end
test "displays sender and receiver callsigns", %{conn: conn} do
insert_spot(%{sender_callsigns: ["K5ABC"], receiver_callsigns: ["K7XYZ"]})
{:ok, _lv, html} = live(conn, ~p"/pskreporter")
assert html =~ "K5ABC"
assert html =~ "K7XYZ"
end
test "displays multiple callsigns comma-separated", %{conn: conn} do
insert_spot(%{sender_callsigns: ["K5ABC", "W1AW"], receiver_callsigns: ["K7XYZ"]})
{:ok, _lv, html} = live(conn, ~p"/pskreporter")
assert html =~ "K5ABC, W1AW"
end
test "shows dash for empty callsigns", %{conn: conn} do
insert_spot(%{sender_callsigns: [], receiver_callsigns: []})
{:ok, _lv, html} = live(conn, ~p"/pskreporter")
assert html =~ ""
end
test "displays modes", %{conn: conn} do
insert_spot(%{modes: ["FT8", "FT4"]})