- Extract PathParser.valid_callsign?/1 and reuse from the calibration Mix task instead of duplicating the regex. - Document raise behavior on Aprs.recent_packets_with_paths/1 and Aprs.station_positions/1 (already noted in moduledoc; @doc reinforces for callers reading the function head). - Note in the BandConfig guard comment why it sits before the Oban pause (so a misconfig exits without leaving queues paused). - Reword the band-mismatched-negatives note as a v1 limitation (no TODO tag — codebase convention is zero TODOs).
140 lines
4.9 KiB
Elixir
140 lines
4.9 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()
|
|
}
|
|
|
|
# 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
|