diff --git a/lib/microwaveprop/pskr.ex b/lib/microwaveprop/pskr.ex index 0357fae8..8e3bb0bb 100644 --- a/lib/microwaveprop/pskr.ex +++ b/lib/microwaveprop/pskr.ex @@ -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) diff --git a/lib/microwaveprop/pskr/aggregator.ex b/lib/microwaveprop/pskr/aggregator.ex index c154ad43..2f550e63 100644 --- a/lib/microwaveprop/pskr/aggregator.ex +++ b/lib/microwaveprop/pskr/aggregator.ex @@ -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") diff --git a/lib/microwaveprop/pskr/spot_hourly.ex b/lib/microwaveprop/pskr/spot_hourly.ex index e2c828ce..8044af8a 100644 --- a/lib/microwaveprop/pskr/spot_hourly.ex +++ b/lib/microwaveprop/pskr/spot_hourly.ex @@ -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 diff --git a/lib/microwaveprop_web/live/pskr_spots_live.ex b/lib/microwaveprop_web/live/pskr_spots_live.ex index 7712f18a..44657901 100644 --- a/lib/microwaveprop_web/live/pskr_spots_live.ex +++ b/lib/microwaveprop_web/live/pskr_spots_live.ex @@ -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""" - + <.header> PSK Reporter Spots <:subtitle>Last {@limit} spot aggregates from the MQTT firehose -
+
+ + @@ -63,7 +68,13 @@ defmodule MicrowavepropWeb.PskrSpotsLive do + + diff --git a/priv/repo/migrations/20260715192512_add_callsigns_to_pskr_spots_hourly.exs b/priv/repo/migrations/20260715192512_add_callsigns_to_pskr_spots_hourly.exs new file mode 100644 index 00000000..6288d8f9 --- /dev/null +++ b/priv/repo/migrations/20260715192512_add_callsigns_to_pskr_spots_hourly.exs @@ -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 diff --git a/test/microwaveprop/pskr_test.exs b/test/microwaveprop/pskr_test.exs index fd03dfd1..767a7c0a 100644 --- a/test/microwaveprop/pskr_test.exs +++ b/test/microwaveprop/pskr_test.exs @@ -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 diff --git a/test/microwaveprop_web/live/pskr_spots_live_test.exs b/test/microwaveprop_web/live/pskr_spots_live_test.exs index 2efbcf1c..0dc66368 100644 --- a/test/microwaveprop_web/live/pskr_spots_live_test.exs +++ b/test/microwaveprop_web/live/pskr_spots_live_test.exs @@ -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 =~ "3–3 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"]})
Last Heard (UTC) BandFrom From GridTo To Grid Distance Spots
{fmt_dt(spot.last_spot_at)} {spot.band} + {fmt_callsigns(spot.sender_callsigns)} + {spot.sender_grid} + {fmt_callsigns(spot.receiver_callsigns)} + {spot.receiver_grid} {Format.distance_km(spot.distance_km)} {Format.number(spot.spot_count)}