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