diff --git a/lib/microwaveprop/aprs/path_parser.ex b/lib/microwaveprop/aprs/path_parser.ex new file mode 100644 index 00000000..a94c9641 --- /dev/null +++ b/lib/microwaveprop/aprs/path_parser.ex @@ -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 diff --git a/test/microwaveprop/aprs/path_parser_test.exs b/test/microwaveprop/aprs/path_parser_test.exs new file mode 100644 index 00000000..5fce75cf --- /dev/null +++ b/test/microwaveprop/aprs/path_parser_test.exs @@ -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