prop/lib/microwaveprop/aprs/path_parser.ex
Graham McIntire 4759d1313e
fix(aprs): tighten PathParser self-loop check and Q-construct regex
Three spec-compliance fixes to PathParser:

1. Q-construct regex now accepts lowercase letters (qAo, qAr, etc.).
   The moduledoc lists qAo as a Q-construct to skip but the previous
   ~r/^qA[A-Z]$/ rejected it, leaving it to be misclassified.

2. self_loop?/2 now compares callsigns only; the spurious
   src_pos == dst_pos clause would falsely suppress two distinct
   stations that happen to share coarse coordinates (e.g. same building).

3. Strengthen the "lookup returned nil → drop hop, do NOT advance source"
   test: extend the path to three tokens so the assertion actually proves
   the third hop chains from the previous good source rather than from
   the dropped one. The two-token version passed regardless of behavior.

Self-loop fixture updated to trigger via callsign equality (the only
remaining mechanism) instead of position equality.
2026-05-01 12:40:17 -05:00

128 lines
4.2 KiB
Elixir

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-Za-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, callsign) 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, dst_callsign) do
src_callsign == dst_callsign
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