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:
Graham McIntire 2026-05-01 12:35:53 -05:00
parent 1312d84bb2
commit 4a5864f090
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
2 changed files with 348 additions and 0 deletions

View 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

View 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