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() } # WIDEn-N / TRACEn-N allow n and N up to 7 per the APRS New-N paradigm. @alias_regex ~r/^(WIDE[1-7](-[1-7])?|TRACE[1-7](-[1-7])?|RELAY|ECHO|GATE|NOGATE|RFONLY)$/ @callsign_regex ~r/^[A-Z0-9]{1,2}[0-9][A-Z]{1,3}(-[0-9]{1,2})?$/ # Explicit allowlist of real APRS-IS Q-constructs (RFC qAR/qAC/qAo/etc.) # rather than `qA[A-Za-z]` which would over-match `qAa`, `qAd`, … @q_constructs ~w(qAC qAX qAU qAo qAO qAS qAr qAR qAZ qAI) @doc """ Returns `true` if `token` matches the APRS callsign shape used by this parser (1-2 alpha/digit prefix, one digit, 1-3 alpha suffix, optional -SSID 0..99). Exposed so callers (e.g. the calibration Mix task) can pre-filter path tokens with the same rule the parser itself applies. """ @spec valid_callsign?(String.t()) :: boolean() def valid_callsign?(token) when is_binary(token), do: Regex.match?(@callsign_regex, token) @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 token in @q_constructs -> :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