feat: track sender/receiver callsigns in pskr spots + full-width /pskreporter page
This commit is contained in:
parent
a2e2776e7c
commit
51dda456aa
7 changed files with 138 additions and 6 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue