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.
128 lines
4.2 KiB
Elixir
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
|