feat(aprs): add PathParser for verified RF hops from TNC2 paths
Pure-Elixir parser that walks an APRS digipeater path left-to-right and emits verified hops for callsigns carrying the used flag (`*`). Filters Q-constructs, TCPIP/TCPXX, and routing aliases (WIDE/TRACE/RELAY/etc.); handles anonymous-digi cases, missing station-position lookups, and self-loops without advancing source past unattributable hops. Used by upcoming 144 MHz calibration to attribute aprs.me receives to real digipeater geometry without persisting any APRS data on our side.
This commit is contained in:
parent
1312d84bb2
commit
4a5864f090
2 changed files with 348 additions and 0 deletions
128
lib/microwaveprop/aprs/path_parser.ex
Normal file
128
lib/microwaveprop/aprs/path_parser.ex
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
defmodule Microwaveprop.Aprs.PathParser do
|
||||
@moduledoc """
|
||||
Parses TNC2-style APRS digipeater paths into verified RF hops.
|
||||
|
||||
An APRS packet's `path` column (as stored by aprs.me) is a comma-
|
||||
separated chain of digipeater callsigns and routing aliases. Tokens
|
||||
with a trailing `*` ("used flag") indicate stations that actually
|
||||
digipeated the frame on RF — a verified receive.
|
||||
|
||||
Walk left-to-right; each "real callsign + used flag" is a verified
|
||||
hop from the *current source* to that callsign, then the source
|
||||
advances to that callsign for any subsequent hops.
|
||||
|
||||
We deliberately ignore:
|
||||
* Q-constructs (`qAR`, `qAC`, `qAS`, `qAo`, `qAX`, `qAZ`, `qAI`)
|
||||
* `TCPIP*`, `TCPXX*` (internet-only injection)
|
||||
* Routing aliases (`WIDE1-N`, `WIDE2-N`, `TRACE[12]-N`, `RELAY`,
|
||||
`ECHO`, `GATE`, `NOGATE`, `RFONLY`)
|
||||
|
||||
When the used flag lands on an alias (e.g. `WIDE1*` from
|
||||
`WA5VHU-8,WIDE1*,WIDE2-1,qAR,K5VOM-10`), we know A digi handled the
|
||||
frame but cannot attribute the hop to a callsign — we skip without
|
||||
emitting a hop.
|
||||
"""
|
||||
|
||||
alias Microwaveprop.Geo
|
||||
|
||||
@type position :: {lat :: number(), lon :: number()}
|
||||
@type station_lookup :: (String.t() -> position() | nil)
|
||||
@type hop :: %{
|
||||
src_callsign: String.t(),
|
||||
src_pos: position(),
|
||||
dst_callsign: String.t(),
|
||||
dst_pos: position(),
|
||||
distance_km: float(),
|
||||
heard_at: DateTime.t()
|
||||
}
|
||||
|
||||
@alias_regex ~r/^(WIDE[12](-[0-9])?|TRACE[12](-[0-9])?|RELAY|ECHO|GATE|NOGATE|RFONLY)$/
|
||||
@callsign_regex ~r/^[A-Z0-9]{1,2}[0-9][A-Z]{1,3}(-[0-9]{1,2})?$/
|
||||
@q_construct_regex ~r/^qA[A-Z]$/
|
||||
|
||||
@spec parse_hops(
|
||||
sender_callsign :: String.t(),
|
||||
sender_pos :: position(),
|
||||
path_string :: String.t(),
|
||||
heard_at :: DateTime.t(),
|
||||
station_lookup :: station_lookup()
|
||||
) :: [hop()]
|
||||
def parse_hops(sender_callsign, sender_pos, path_string, heard_at, station_lookup)
|
||||
when is_binary(sender_callsign) and is_binary(path_string) and is_function(station_lookup, 1) do
|
||||
path_string
|
||||
|> String.split(",", trim: true)
|
||||
|> Enum.map(&String.trim/1)
|
||||
|> Enum.reduce({{sender_callsign, sender_pos}, []}, fn token, {source, hops} ->
|
||||
process_token(token, source, hops, heard_at, station_lookup)
|
||||
end)
|
||||
|> elem(1)
|
||||
|> Enum.reverse()
|
||||
end
|
||||
|
||||
defp process_token("", source, hops, _heard_at, _lookup), do: {source, hops}
|
||||
|
||||
defp process_token(token, source, hops, heard_at, lookup) do
|
||||
used? = String.ends_with?(token, "*")
|
||||
callsign = if used?, do: String.trim_trailing(token, "*"), else: token
|
||||
|
||||
callsign
|
||||
|> classify()
|
||||
|> handle(callsign, used?, source, hops, heard_at, lookup)
|
||||
end
|
||||
|
||||
defp classify(token) do
|
||||
cond do
|
||||
Regex.match?(@q_construct_regex, token) -> :q_construct
|
||||
token in ["TCPIP", "TCPXX"] -> :internet
|
||||
Regex.match?(@alias_regex, token) -> :alias
|
||||
Regex.match?(@callsign_regex, token) -> :callsign
|
||||
true -> :unknown
|
||||
end
|
||||
end
|
||||
|
||||
defp handle(:callsign, callsign, true, source, hops, heard_at, lookup) do
|
||||
case lookup.(callsign) do
|
||||
nil ->
|
||||
# Used flag present but no known position — drop, do NOT advance source.
|
||||
{source, hops}
|
||||
|
||||
dst_pos ->
|
||||
{src_callsign, src_pos} = source
|
||||
|
||||
if self_loop?(src_callsign, src_pos, callsign, dst_pos) do
|
||||
{source, hops}
|
||||
else
|
||||
hop = build_hop(src_callsign, src_pos, callsign, dst_pos, heard_at)
|
||||
{{callsign, dst_pos}, [hop | hops]}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp handle(:callsign, _callsign, false, source, hops, _heard_at, _lookup) do
|
||||
# Real callsign without used flag — routing target, did not digipeat.
|
||||
{source, hops}
|
||||
end
|
||||
|
||||
defp handle(_other, _callsign, _used?, source, hops, _heard_at, _lookup) do
|
||||
# q_construct, internet, alias (used or not), unknown — never emit a hop.
|
||||
{source, hops}
|
||||
end
|
||||
|
||||
defp self_loop?(src_callsign, src_pos, dst_callsign, dst_pos) do
|
||||
src_callsign == dst_callsign or src_pos == dst_pos
|
||||
end
|
||||
|
||||
defp build_hop(src_callsign, src_pos, dst_callsign, dst_pos, heard_at) do
|
||||
{src_lat, src_lon} = src_pos
|
||||
{dst_lat, dst_lon} = dst_pos
|
||||
|
||||
%{
|
||||
src_callsign: src_callsign,
|
||||
src_pos: src_pos,
|
||||
dst_callsign: dst_callsign,
|
||||
dst_pos: dst_pos,
|
||||
distance_km: Geo.haversine_km(src_lat, src_lon, dst_lat, dst_lon),
|
||||
heard_at: heard_at
|
||||
}
|
||||
end
|
||||
end
|
||||
220
test/microwaveprop/aprs/path_parser_test.exs
Normal file
220
test/microwaveprop/aprs/path_parser_test.exs
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
defmodule Microwaveprop.Aprs.PathParserTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Microwaveprop.Aprs.PathParser
|
||||
|
||||
@sender "N5XXX-9"
|
||||
@sender_pos {32.897, -97.038}
|
||||
@heard_at ~U[2026-04-30 12:00:00Z]
|
||||
|
||||
defp lookup_fn(map) do
|
||||
fn callsign -> Map.get(map, callsign) end
|
||||
end
|
||||
|
||||
describe "parse_hops/5" do
|
||||
test "single used-flag callsign emits one hop from sender" do
|
||||
lookup = lookup_fn(%{"K5GVL-10" => {32.95, -96.10}})
|
||||
|
||||
hops =
|
||||
PathParser.parse_hops(
|
||||
@sender,
|
||||
@sender_pos,
|
||||
"K5GVL-10*,WIDE1-1,WIDE2-1",
|
||||
@heard_at,
|
||||
lookup
|
||||
)
|
||||
|
||||
assert [hop] = hops
|
||||
assert hop.src_callsign == @sender
|
||||
assert hop.src_pos == @sender_pos
|
||||
assert hop.dst_callsign == "K5GVL-10"
|
||||
assert hop.dst_pos == {32.95, -96.10}
|
||||
assert hop.heard_at == @heard_at
|
||||
assert is_float(hop.distance_km)
|
||||
end
|
||||
|
||||
test "two chained used-flag callsigns emit two hops, source advances" do
|
||||
lookup =
|
||||
lookup_fn(%{
|
||||
"K5XYZ-3" => {33.0, -96.5},
|
||||
"W5ABC-7" => {33.5, -95.5}
|
||||
})
|
||||
|
||||
hops =
|
||||
PathParser.parse_hops(
|
||||
@sender,
|
||||
@sender_pos,
|
||||
"K5XYZ-3*,W5ABC-7*,WIDE2-1",
|
||||
@heard_at,
|
||||
lookup
|
||||
)
|
||||
|
||||
assert length(hops) == 2
|
||||
[first, second] = hops
|
||||
|
||||
assert first.src_callsign == @sender
|
||||
assert first.src_pos == @sender_pos
|
||||
assert first.dst_callsign == "K5XYZ-3"
|
||||
assert first.dst_pos == {33.0, -96.5}
|
||||
|
||||
assert second.src_callsign == "K5XYZ-3"
|
||||
assert second.src_pos == {33.0, -96.5}
|
||||
assert second.dst_callsign == "W5ABC-7"
|
||||
assert second.dst_pos == {33.5, -95.5}
|
||||
end
|
||||
|
||||
test "anonymous WIDE1* used-flag yields no hops" do
|
||||
lookup = lookup_fn(%{})
|
||||
|
||||
hops =
|
||||
PathParser.parse_hops(
|
||||
@sender,
|
||||
@sender_pos,
|
||||
"WIDE1*,WIDE2-1",
|
||||
@heard_at,
|
||||
lookup
|
||||
)
|
||||
|
||||
assert hops == []
|
||||
end
|
||||
|
||||
test "mixed path with no used real callsigns yields no hops" do
|
||||
lookup = lookup_fn(%{"K5VOM-10" => {32.5, -96.5}})
|
||||
|
||||
hops =
|
||||
PathParser.parse_hops(
|
||||
@sender,
|
||||
@sender_pos,
|
||||
"WA5VHU-8,WIDE1*,WIDE2-1,qAR,K5VOM-10",
|
||||
@heard_at,
|
||||
lookup
|
||||
)
|
||||
|
||||
assert hops == []
|
||||
end
|
||||
|
||||
test "trailing unused real callsign does not emit a hop" do
|
||||
lookup =
|
||||
lookup_fn(%{
|
||||
"K5GVL-10" => {32.95, -96.10},
|
||||
"N5TXZ-10" => {33.1, -96.2}
|
||||
})
|
||||
|
||||
hops =
|
||||
PathParser.parse_hops(
|
||||
@sender,
|
||||
@sender_pos,
|
||||
"K5GVL-10*,N5TXZ-10",
|
||||
@heard_at,
|
||||
lookup
|
||||
)
|
||||
|
||||
assert [hop] = hops
|
||||
assert hop.dst_callsign == "K5GVL-10"
|
||||
end
|
||||
|
||||
test "TCPIP and Q-construct yield no hops" do
|
||||
lookup = lookup_fn(%{})
|
||||
|
||||
hops =
|
||||
PathParser.parse_hops(
|
||||
@sender,
|
||||
@sender_pos,
|
||||
"TCPIP*,qAC,T2TEXAS",
|
||||
@heard_at,
|
||||
lookup
|
||||
)
|
||||
|
||||
assert hops == []
|
||||
end
|
||||
|
||||
test "used callsign with nil lookup is dropped and source does NOT advance" do
|
||||
lookup =
|
||||
lookup_fn(%{
|
||||
"K5XYZ-3" => {33.0, -96.5}
|
||||
# W5ABC-7 deliberately missing
|
||||
})
|
||||
|
||||
hops =
|
||||
PathParser.parse_hops(
|
||||
@sender,
|
||||
@sender_pos,
|
||||
"K5XYZ-3*,W5ABC-7*",
|
||||
@heard_at,
|
||||
lookup
|
||||
)
|
||||
|
||||
assert [hop] = hops
|
||||
assert hop.src_callsign == @sender
|
||||
assert hop.dst_callsign == "K5XYZ-3"
|
||||
end
|
||||
|
||||
test "empty path string yields no hops" do
|
||||
lookup = lookup_fn(%{})
|
||||
|
||||
hops = PathParser.parse_hops(@sender, @sender_pos, "", @heard_at, lookup)
|
||||
|
||||
assert hops == []
|
||||
end
|
||||
|
||||
test "aliases-only path yields no hops" do
|
||||
lookup = lookup_fn(%{})
|
||||
|
||||
hops =
|
||||
PathParser.parse_hops(
|
||||
@sender,
|
||||
@sender_pos,
|
||||
"WIDE1-1,WIDE2-2",
|
||||
@heard_at,
|
||||
lookup
|
||||
)
|
||||
|
||||
assert hops == []
|
||||
end
|
||||
|
||||
test "Q-construct followed by IGate name yields no hops" do
|
||||
lookup = lookup_fn(%{"IGATE-CALL" => {33.0, -96.0}})
|
||||
|
||||
hops =
|
||||
PathParser.parse_hops(
|
||||
@sender,
|
||||
@sender_pos,
|
||||
"qAR,IGATE-CALL",
|
||||
@heard_at,
|
||||
lookup
|
||||
)
|
||||
|
||||
assert hops == []
|
||||
end
|
||||
|
||||
test "self-loop (callsign resolves to sender's own position) yields no hops" do
|
||||
lookup = lookup_fn(%{"K5GVL-10" => @sender_pos})
|
||||
|
||||
hops =
|
||||
PathParser.parse_hops(
|
||||
@sender,
|
||||
@sender_pos,
|
||||
"K5GVL-10*",
|
||||
@heard_at,
|
||||
lookup
|
||||
)
|
||||
|
||||
assert hops == []
|
||||
end
|
||||
|
||||
test "haversine distance for DFW → K5GVL-10 is approximately 88.4 km" do
|
||||
lookup = lookup_fn(%{"K5GVL-10" => {32.95, -96.10}})
|
||||
|
||||
[hop] =
|
||||
PathParser.parse_hops(
|
||||
@sender,
|
||||
@sender_pos,
|
||||
"K5GVL-10*",
|
||||
@heard_at,
|
||||
lookup
|
||||
)
|
||||
|
||||
assert abs(hop.distance_km - 88.4) < 1.0
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue