tons of refactoring

This commit is contained in:
Graham McIntire 2025-06-19 12:57:13 -05:00
parent 3fb11706d2
commit c1a4bb736c
No known key found for this signature in database
14 changed files with 909 additions and 873 deletions

View file

@ -14,8 +14,10 @@ defmodule Aprs.BadPacket do
timestamps(type: :utc_datetime_usec) timestamps(type: :utc_datetime_usec)
end end
@type t :: %__MODULE__{}
@doc false @doc false
@spec changeset(%Aprs.BadPacket{}, map()) :: Ecto.Changeset.t() @spec changeset(Aprs.BadPacket.t(), map()) :: Ecto.Changeset.t()
def changeset(bad_packet, attrs) do def changeset(bad_packet, attrs) do
bad_packet bad_packet
|> cast(attrs, [:raw_packet, :error_message, :error_type, :attempted_at]) |> cast(attrs, [:raw_packet, :error_message, :error_type, :attempted_at])

View file

@ -16,8 +16,10 @@ defmodule Aprs.DataExtended do
field :symbol_table_id, :string field :symbol_table_id, :string
end end
@type t :: %__MODULE__{}
@doc false @doc false
@spec changeset(%Aprs.DataExtended{}, map()) :: Ecto.Changeset.t() @spec changeset(Aprs.DataExtended.t(), map()) :: Ecto.Changeset.t()
def changeset(%DataExtended{} = data_extended, attrs) do def changeset(%DataExtended{} = data_extended, attrs) do
data_extended data_extended
|> cast(attrs, [ |> cast(attrs, [

View file

@ -3,44 +3,42 @@ defmodule Aprs.DeviceIdentification do
Handles APRS device identification based on the APRS device identification database. Handles APRS device identification based on the APRS device identification database.
""" """
@device_patterns [
{~r/^ \x00\x00$/, "Original MIC-E"},
{~r/^>\x00\^$/, "Kenwood TH-D74"},
{~r/^>\x00\x00$/, "Kenwood TH-D74A"},
{~r/^]\x00=$/, "Kenwood DM-710"},
{~r/^]\x00\x00$/, "Kenwood DM-700"},
{~r/^`_ $/, "Yaesu VX-8"},
{~r/^`_\"$/, "Yaesu FTM-350"},
{~r/^`_#$/, "Yaesu VX-8G"},
{~r/^`_\$$/, "Yaesu FT1D"},
{~r/^`_%$/, "Yaesu FTM-400DR"},
{~r/^`_\)$/, "Yaesu FTM-100D"},
{~r/^`_\($/, "Yaesu FT2D"},
{~r/^` X$/, "AP510"},
{~r/^`\x00\x00$/, "Mic-Emsg"},
{~r/^'\|3$/, "Byonics TinyTrack3"},
{~r/^'\|4$/, "Byonics TinyTrack4"},
{~r/^':4$/, "SCS GmbH & Co. P4dragon DR-7400 modems"},
{~r/^':8$/, "SCS GmbH & Co. P4dragon DR-7800 modems"},
{~r/^'\x00\x00$/, "McTrackr"},
{~r/^\x00\"\x00$/, "Hamhud"},
{~r/^\x00\/\x00$/, "Argent"},
{~r/^\x00\^\x00$/, "HinzTec anyfrog"},
{~r/^\x00\*\x00$/, "APOZxx www.KissOZ.dk Tracker. OZ1EKD and OZ7HVO"},
{~r/^\x00~\x00$/, "Other"}
]
@doc """ @doc """
Identifies the manufacturer and model of an APRS device based on its symbol pattern. Identifies the manufacturer and model of an APRS device based on its symbol pattern.
Returns a tuple of {manufacturer, model} or "Unknown" if the device cannot be identified. Returns a tuple of {manufacturer, model} or "Unknown" if the device cannot be identified.
""" """
@spec identify_device(String.t()) :: String.t() @spec identify_device(String.t()) :: String.t()
def identify_device(symbols) do def identify_device(symbols) do
case symbols do Enum.find_value(@device_patterns, "Unknown", fn {regex, name} ->
# Original MIC-E devices if Regex.match?(regex, symbols), do: name
" " <> <<0, 0>> -> "Original MIC-E" end)
# Kenwood devices
">" <> <<0>> <> "^" -> "Kenwood TH-D74"
">" <> <<0, 0>> -> "Kenwood TH-D74A"
"]" <> <<0>> <> "=" -> "Kenwood DM-710"
"]" <> <<0, 0>> -> "Kenwood DM-700"
# Yaesu devices
"`_" <> " " -> "Yaesu VX-8"
"`_" <> "\"" -> "Yaesu FTM-350"
"`_" <> "#" -> "Yaesu VX-8G"
"`_" <> "$" -> "Yaesu FT1D"
"`_" <> "%" -> "Yaesu FTM-400DR"
"`_" <> ")" -> "Yaesu FTM-100D"
"`_" <> "(" -> "Yaesu FT2D"
# Other devices
"` X" -> "AP510"
"`" <> <<0, 0>> -> "Mic-Emsg"
"'|3" -> "Byonics TinyTrack3"
"'|4" -> "Byonics TinyTrack4"
"':4" -> "SCS GmbH & Co. P4dragon DR-7400 modems"
"':8" -> "SCS GmbH & Co. P4dragon DR-7800 modems"
"'" <> <<0, 0>> -> "McTrackr"
<<0>> <> "\"" <> <<0>> -> "Hamhud"
<<0>> <> "/" <> <<0>> -> "Argent"
<<0>> <> "^" <> <<0>> -> "HinzTec anyfrog"
<<0>> <> "*" <> <<0>> -> "APOZxx www.KissOZ.dk Tracker. OZ1EKD and OZ7HVO"
<<0>> <> "~" <> <<0>> -> "Other"
# Unknown devices
_ -> "Unknown"
end
end end
@doc """ @doc """

View file

@ -115,10 +115,10 @@ defmodule Aprs.EncodingUtils do
# Ensure the final result is valid UTF-8 # Ensure the final result is valid UTF-8
@spec ensure_valid_utf8(binary()) :: binary() @spec ensure_valid_utf8(binary()) :: binary()
defp ensure_valid_utf8(binary) do defp ensure_valid_utf8(binary) do
if String.valid?(binary) do if String.valid?(binary), do: binary, else: try_convert_utf8(binary)
binary end
else
# If still invalid, try to convert to valid UTF-8 defp try_convert_utf8(binary) do
case :unicode.characters_to_binary(binary, :latin1, :utf8) do case :unicode.characters_to_binary(binary, :latin1, :utf8) do
result when is_binary(result) -> result when is_binary(result) ->
result result
@ -131,7 +131,6 @@ defmodule Aprs.EncodingUtils do
|> :binary.list_to_bin() |> :binary.list_to_bin()
end end
end end
end
@doc """ @doc """
Converts a binary to a hex string representation for debugging. Converts a binary to a hex string representation for debugging.

View file

@ -155,7 +155,8 @@ defmodule Aprs.Is do
# Server methods # Server methods
@spec connect_to_aprs_is(String.t() | charlist(), pos_integer()) :: {:ok, :ssl.sslsocket()} | {:error, any()} @spec connect_to_aprs_is(String.t() | charlist(), pos_integer()) ::
{:ok, :ssl.sslsocket()} | {:error, any()}
defp connect_to_aprs_is(server, port) do defp connect_to_aprs_is(server, port) do
# Additional safeguard: prevent connections in test environment # Additional safeguard: prevent connections in test environment
if Application.get_env(:aprs, :env) == :test or if Application.get_env(:aprs, :env) == :test or
@ -169,7 +170,8 @@ defmodule Aprs.Is do
end end
end end
@spec send_login_string(:ssl.sslsocket(), String.t(), String.t(), String.t()) :: :ok | {:error, any()} @spec send_login_string(:ssl.sslsocket(), String.t(), String.t(), String.t()) ::
:ok | {:error, any()}
defp send_login_string(socket, aprs_user_id, aprs_passcode, filter) do defp send_login_string(socket, aprs_user_id, aprs_passcode, filter) do
login_string = login_string =
"user #{aprs_user_id} pass #{aprs_passcode} vers aprs.me 0.1 filter #{filter}\r\n" "user #{aprs_user_id} pass #{aprs_passcode} vers aprs.me 0.1 filter #{filter}\r\n"
@ -447,40 +449,44 @@ defmodule Aprs.Is do
# Helper to check if a packet has position data worth storing # Helper to check if a packet has position data worth storing
@spec has_position_data?(map()) :: boolean() @spec has_position_data?(map()) :: boolean()
defp has_position_data?(packet) do defp has_position_data?(%{data_extended: nil} = packet), do: log_and_return_nil_data_extended(packet)
defp has_position_data?(%{data_extended: %{latitude: lat, longitude: lon}} = _packet)
when not is_nil(lat) and not is_nil(lon),
do: check_lat_lon_coords(lat, lon)
defp has_position_data?(%{data_extended: %MicE{} = mic_e}), do: check_mic_e_coords(mic_e)
defp has_position_data?(packet), do: log_and_return_unrecognized(packet)
defp log_and_return_nil_data_extended(packet) do
require Logger require Logger
result =
case packet do
%{data_extended: nil} ->
Logger.debug("Packet has nil data_extended: #{inspect(packet.sender)}") Logger.debug("Packet has nil data_extended: #{inspect(packet.sender)}")
false false
end
%{data_extended: %{latitude: lat, longitude: lon}} defp check_lat_lon_coords(lat, lon) do
when not is_nil(lat) and not is_nil(lon) ->
# Check if coordinates are valid numbers
valid = are_valid_coords?(lat, lon) valid = are_valid_coords?(lat, lon)
if !valid do if !valid do
require Logger
Logger.debug("Invalid coordinates: lat=#{inspect(lat)}, lon=#{inspect(lon)}") Logger.debug("Invalid coordinates: lat=#{inspect(lat)}, lon=#{inspect(lon)}")
end end
valid valid
%{data_extended: %MicE{} = mic_e} ->
# MicE packets have lat/lon in separate components
valid =
is_number(mic_e.lat_degrees) and is_number(mic_e.lat_minutes) and
is_number(mic_e.lon_degrees) and is_number(mic_e.lon_minutes)
valid
_other ->
Logger.debug("Unrecognized packet format: #{inspect(Map.keys(packet))}")
false
end end
result defp check_mic_e_coords(mic_e) do
is_number(mic_e.lat_degrees) and is_number(mic_e.lat_minutes) and
is_number(mic_e.lon_degrees) and is_number(mic_e.lon_minutes)
end
defp log_and_return_unrecognized(packet) do
require Logger
Logger.debug("Unrecognized packet format: #{inspect(Map.keys(packet))}")
false
end end
# Helper to validate coordinate values # Helper to validate coordinate values

View file

@ -60,8 +60,10 @@ defmodule Aprs.Packet do
timestamps() timestamps()
end end
@type t :: %__MODULE__{}
@doc false @doc false
@spec changeset(%Aprs.Packet{}, map()) :: Ecto.Changeset.t() @spec changeset(Aprs.Packet.t(), map()) :: Ecto.Changeset.t()
def changeset(packet, attrs) do def changeset(packet, attrs) do
# Convert atom data_type to string # Convert atom data_type to string
attrs = normalize_data_type(attrs) attrs = normalize_data_type(attrs)
@ -143,7 +145,7 @@ defmodule Aprs.Packet do
coords coords
end end
if is_valid_coordinates?(lat, lon) do if valid_coordinates?(lat, lon) do
try do try do
location = create_point(lat, lon) location = create_point(lat, lon)
@ -174,7 +176,7 @@ defmodule Aprs.Packet do
lat = get_field(changeset, :lat) || get_change(changeset, :lat) lat = get_field(changeset, :lat) || get_change(changeset, :lat)
lon = get_field(changeset, :lon) || get_change(changeset, :lon) lon = get_field(changeset, :lon) || get_change(changeset, :lon)
if is_valid_coordinates?(lat, lon) do if valid_coordinates?(lat, lon) do
put_change(changeset, :has_position, true) put_change(changeset, :has_position, true)
else else
changeset changeset
@ -182,7 +184,7 @@ defmodule Aprs.Packet do
end end
end end
defp is_valid_coordinates?(lat, lon) do defp valid_coordinates?(lat, lon) do
is_number(lat) && is_number(lon) && is_number(lat) && is_number(lon) &&
lat >= -90 && lat <= 90 && lat >= -90 && lat <= 90 &&
lon >= -180 && lon <= 180 lon >= -180 && lon <= 180
@ -241,6 +243,15 @@ defmodule Aprs.Packet do
# Extract data from standard map-based data_extended # Extract data from standard map-based data_extended
defp extract_from_map(data_extended) do defp extract_from_map(data_extended) do
%{} %{}
|> put_symbol_fields(data_extended)
|> put_weather_fields(data_extended)
|> put_equipment_fields(data_extended)
|> put_message_fields(data_extended)
|> extract_weather_data(data_extended)
end
defp put_symbol_fields(map, data_extended) do
map
|> maybe_put(:symbol_code, data_extended[:symbol_code] || data_extended["symbol_code"]) |> maybe_put(:symbol_code, data_extended[:symbol_code] || data_extended["symbol_code"])
|> maybe_put( |> maybe_put(
:symbol_table_id, :symbol_table_id,
@ -252,21 +263,29 @@ defmodule Aprs.Packet do
:aprs_messaging, :aprs_messaging,
data_extended[:aprs_messaging?] || data_extended["aprs_messaging?"] data_extended[:aprs_messaging?] || data_extended["aprs_messaging?"]
) )
|> maybe_put(:temperature, data_extended[:temperature] || data_extended["temperature"]) end
|> maybe_put(:humidity, data_extended[:humidity] || data_extended["humidity"])
|> maybe_put(:wind_speed, data_extended[:wind_speed] || data_extended["wind_speed"]) defp put_weather_fields(map, data_extended) do
|> maybe_put( weather_fields = [
:temperature,
:humidity,
:wind_speed,
:wind_direction, :wind_direction,
data_extended[:wind_direction] || data_extended["wind_direction"] :wind_gust,
) :pressure,
|> maybe_put(:wind_gust, data_extended[:wind_gust] || data_extended["wind_gust"]) :rain_1h,
|> maybe_put(:pressure, data_extended[:pressure] || data_extended["pressure"]) :rain_24h,
|> maybe_put(:rain_1h, data_extended[:rain_1h] || data_extended["rain_1h"]) :rain_since_midnight
|> maybe_put(:rain_24h, data_extended[:rain_24h] || data_extended["rain_24h"]) ]
|> maybe_put(
:rain_since_midnight, Enum.reduce(weather_fields, map, fn field, acc ->
data_extended[:rain_since_midnight] || data_extended["rain_since_midnight"] value = data_extended[field] || data_extended[to_string(field)]
) maybe_put(acc, field, value)
end)
end
defp put_equipment_fields(map, data_extended) do
map
|> maybe_put(:manufacturer, data_extended[:manufacturer] || data_extended["manufacturer"]) |> maybe_put(:manufacturer, data_extended[:manufacturer] || data_extended["manufacturer"])
|> maybe_put( |> maybe_put(
:equipment_type, :equipment_type,
@ -275,13 +294,16 @@ defmodule Aprs.Packet do
|> maybe_put(:course, data_extended[:course] || data_extended["course"]) |> maybe_put(:course, data_extended[:course] || data_extended["course"])
|> maybe_put(:speed, data_extended[:speed] || data_extended["speed"]) |> maybe_put(:speed, data_extended[:speed] || data_extended["speed"])
|> maybe_put(:altitude, data_extended[:altitude] || data_extended["altitude"]) |> maybe_put(:altitude, data_extended[:altitude] || data_extended["altitude"])
end
defp put_message_fields(map, data_extended) do
map
|> maybe_put(:addressee, data_extended[:addressee] || data_extended["addressee"]) |> maybe_put(:addressee, data_extended[:addressee] || data_extended["addressee"])
|> maybe_put(:message_text, data_extended[:message_text] || data_extended["message_text"]) |> maybe_put(:message_text, data_extended[:message_text] || data_extended["message_text"])
|> maybe_put( |> maybe_put(
:message_number, :message_number,
data_extended[:message_number] || data_extended["message_number"] data_extended[:message_number] || data_extended["message_number"]
) )
|> extract_weather_data(data_extended)
end end
# Extract data from MicE packets # Extract data from MicE packets
@ -345,6 +367,14 @@ defmodule Aprs.Packet do
defp maybe_extract_weather_field(attrs, weather_string, regex, keys) do defp maybe_extract_weather_field(attrs, weather_string, regex, keys) do
case Regex.run(regex, weather_string) do case Regex.run(regex, weather_string) do
[_full | matches] -> [_full | matches] ->
update_attrs_with_weather_matches(attrs, keys, matches)
_ ->
attrs
end
end
defp update_attrs_with_weather_matches(attrs, keys, matches) do
keys keys
|> Enum.zip(matches) |> Enum.zip(matches)
|> Enum.reduce(attrs, fn {key, value}, acc -> |> Enum.reduce(attrs, fn {key, value}, acc ->
@ -353,10 +383,6 @@ defmodule Aprs.Packet do
:error -> acc :error -> acc
end end
end) end)
_ ->
attrs
end
end end
# Helper to put a value only if it's not nil # Helper to put a value only if it's not nil
@ -451,7 +477,7 @@ defmodule Aprs.Packet do
""" """
@spec create_point(number() | nil, number() | nil) :: Geo.Point.t() | nil @spec create_point(number() | nil, number() | nil) :: Geo.Point.t() | nil
def create_point(lat, lon) when is_number(lat) and is_number(lon) do def create_point(lat, lon) when is_number(lat) and is_number(lon) do
if lat >= -90 and lat <= 90 and lon >= -180 and lon <= 180 do if valid_coordinates?(lat, lon) do
%Geo.Point{coordinates: {lon, lat}, srid: 4326} %Geo.Point{coordinates: {lon, lat}, srid: 4326}
end end
end end
@ -468,14 +494,14 @@ defmodule Aprs.Packet do
@doc """ @doc """
Get latitude from a packet's location geometry. Get latitude from a packet's location geometry.
""" """
@spec lat(%Aprs.Packet{}) :: number() | nil @spec lat(Aprs.Packet.t()) :: number() | nil
def lat(%__MODULE__{location: %Geo.Point{coordinates: {_lon, lat}}}), do: lat def lat(%__MODULE__{location: %Geo.Point{coordinates: {_lon, lat}}}), do: lat
def lat(_), do: nil def lat(_), do: nil
@doc """ @doc """
Get longitude from a packet's location geometry. Get longitude from a packet's location geometry.
""" """
@spec lon(%Aprs.Packet{}) :: number() | nil @spec lon(Aprs.Packet.t()) :: number() | nil
def lon(%__MODULE__{location: %Geo.Point{coordinates: {lon, _lat}}}), do: lon def lon(%__MODULE__{location: %Geo.Point{coordinates: {lon, _lat}}}), do: lon
def lon(_), do: nil def lon(_), do: nil

View file

@ -115,7 +115,8 @@ defmodule Aprs.Packets do
* `packet_data` - The original packet data that failed to parse * `packet_data` - The original packet data that failed to parse
* `error` - The error that occurred during parsing * `error` - The error that occurred during parsing
""" """
@spec store_bad_packet(map() | String.t(), any()) :: {:ok, struct()} | {:error, Ecto.Changeset.t()} @spec store_bad_packet(map() | String.t(), any()) ::
{:ok, struct()} | {:error, Ecto.Changeset.t()}
def store_bad_packet(packet_data, error) do def store_bad_packet(packet_data, error) do
error_type = error_type =
case error do case error do
@ -143,28 +144,31 @@ defmodule Aprs.Packets do
# Extracts position data from packet, checking various possible locations # Extracts position data from packet, checking various possible locations
defp extract_position(packet_data) do defp extract_position(packet_data) do
cond do
# Check for lat/lon at top level # Check for lat/lon at top level
not is_nil(packet_data[:lat]) and not is_nil(packet_data[:lon]) -> if not is_nil(packet_data[:lat]) and not is_nil(packet_data[:lon]) do
{to_float(packet_data.lat), to_float(packet_data.lon)} {to_float(packet_data.lat), to_float(packet_data.lon)}
else
extract_position_from_data_extended(packet_data[:data_extended])
end
end
# Check data_extended struct or map defp extract_position_from_data_extended(nil), do: {nil, nil}
not is_nil(packet_data[:data_extended]) ->
data_extended = packet_data.data_extended
cond do defp extract_position_from_data_extended(data_extended) when is_map(data_extended) do
# Standard position format # Standard position format
is_map(data_extended) and not is_nil(data_extended[:latitude]) and if not is_nil(data_extended[:latitude]) and not is_nil(data_extended[:longitude]) do
not is_nil(data_extended[:longitude]) ->
{to_float(data_extended[:latitude]), to_float(data_extended[:longitude])} {to_float(data_extended[:latitude]), to_float(data_extended[:longitude])}
# MicE packet format with components # MicE packet format with components
is_map(data_extended) and data_extended.__struct__ == Parser.Types.MicE -> else
mic_e = data_extended extract_position_from_mic_e(data_extended)
end
end
defp extract_position_from_data_extended(_), do: {nil, nil}
defp extract_position_from_mic_e(%{__struct__: Parser.Types.MicE} = mic_e) do
if is_number(mic_e.lat_degrees) and is_number(mic_e.lat_minutes) and if is_number(mic_e.lat_degrees) and is_number(mic_e.lat_minutes) and
is_number(mic_e.lon_degrees) and is_number(mic_e.lon_minutes) do is_number(mic_e.lon_degrees) and is_number(mic_e.lon_minutes) do
# Convert MicE components to decimal degrees
lat = mic_e.lat_degrees + mic_e.lat_minutes / 60.0 lat = mic_e.lat_degrees + mic_e.lat_minutes / 60.0
lat = if mic_e.lat_direction == :south, do: -lat, else: lat lat = if mic_e.lat_direction == :south, do: -lat, else: lat
@ -175,15 +179,9 @@ defmodule Aprs.Packets do
else else
{nil, nil} {nil, nil}
end end
true ->
{nil, nil}
end end
true -> defp extract_position_from_mic_e(_), do: {nil, nil}
{nil, nil}
end
end
@doc """ @doc """
Gets packets for replay. Gets packets for replay.

View file

@ -11,6 +11,182 @@ defmodule AprsWeb.Helpers.AprsSymbols do
Symbol positioning is based on ASCII code of the symbol_code. Symbol positioning is based on ASCII code of the symbol_code.
""" """
@symbol_descriptions %{
{"/", "!"} => "Police/Sheriff",
{"/", "\""} => "Reserved",
{"/", "#"} => "Digipeater",
{"/", "$"} => "Phone",
{"/", "%"} => "DX Cluster",
{"/", "&"} => "HF Gateway",
{"/", "'"} => "Small Aircraft",
{"/", "("} => "Mobile Satellite Station",
{"/", ")"} => "Wheelchair",
{"/", "*"} => "Snowmobile",
{"/", "+"} => "Red Cross",
{"/", ","} => "Boy Scout",
{"/", "-"} => "House",
{"/", "."} => "X",
{"/", "/"} => "Position",
{"/", "0"} => "Circle",
{"/", "1"} => "Circle",
{"/", "2"} => "Circle",
{"/", "3"} => "Circle",
{"/", "4"} => "Circle",
{"/", "5"} => "Circle",
{"/", "6"} => "Circle",
{"/", "7"} => "Circle",
{"/", "8"} => "Circle",
{"/", "9"} => "Circle",
{"/", ":"} => "Fire Department",
{"/", ";"} => "Campground",
{"/", "<"} => "Motorcycle",
{"/", "="} => "Rail Engine",
{"/", ">"} => "Car",
{"/", "?"} => "File Server",
{"/", "@"} => "HC Future",
{"/", "A"} => "Aid Station",
{"/", "B"} => "BBS",
{"/", "C"} => "Canoe",
{"/", "D"} => "Reserved",
{"/", "E"} => "Eyeball",
{"/", "F"} => "Tractor",
{"/", "G"} => "Grid Square",
{"/", "H"} => "Hotel",
{"/", "I"} => "TCP/IP",
{"/", "J"} => "Phone",
{"/", "K"} => "School",
{"/", "L"} => "PC User",
{"/", "M"} => "MacAPRS",
{"/", "N"} => "NTS Station",
{"/", "O"} => "Balloon",
{"/", "P"} => "Police",
{"/", "Q"} => "TBD",
{"/", "R"} => "Recreational Vehicle",
{"/", "S"} => "Shuttle",
{"/", "T"} => "SSTV",
{"/", "U"} => "Bus",
{"/", "V"} => "ATV",
{"/", "W"} => "National Weather Service",
{"/", "X"} => "Helo",
{"/", "Y"} => "Yacht",
{"/", "Z"} => "WinAPRS",
{"/", "["} => "Jogger",
{"/", "\\"} => "Triangle",
{"/", "]"} => "PBBS",
{"/", "^"} => "Aircraft",
{"/", "_"} => "Weather Station",
{"/", "`"} => "Dish Antenna",
{"/", "a"} => "Ambulance",
{"/", "b"} => "Bike",
{"/", "c"} => "Incident Command Post",
{"/", "d"} => "Fire Depts",
{"/", "e"} => "Horse",
{"/", "f"} => "Fire Truck",
{"/", "g"} => "Glider",
{"/", "h"} => "Hospital",
{"/", "i"} => "IOTA",
{"/", "j"} => "Jeep",
{"/", "k"} => "Truck",
{"/", "l"} => "Laptop",
{"/", "m"} => "Mic-E",
{"/", "n"} => "Node",
{"/", "o"} => "EOC",
{"/", "p"} => "Dog",
{"/", "q"} => "Grid",
{"/", "r"} => "Repeater",
{"/", "s"} => "Ship",
{"/", "t"} => "Truck Stop",
{"/", "u"} => "Truck",
{"/", "v"} => "Van",
{"/", "w"} => "Water Station",
{"/", "x"} => "X-APRS",
{"/", "y"} => "Yagi",
{"/", "z"} => "Shelter",
{"\\", "!"} => "Emergency",
{"\\", "\""} => "Reserved",
{"\\", "#"} => "Digipeater",
{"\\", "$"} => "Bank",
{"\\", "%"} => "Reserved",
{"\\", "&"} => "Reserved",
{"\\", "'"} => "Reserved",
{"\\", "("} => "Reserved",
{"\\", ")"} => "Reserved",
{"\\", "*"} => "Reserved",
{"\\", "+"} => "Reserved",
{"\\", ","} => "Reserved",
{"\\", "-"} => "Reserved",
{"\\", "."} => "Reserved",
{"\\", "/"} => "Triangle",
{"\\", "0"} => "Reserved",
{"\\", "1"} => "Reserved",
{"\\", "2"} => "Reserved",
{"\\", "3"} => "Reserved",
{"\\", "4"} => "Reserved",
{"\\", "5"} => "Reserved",
{"\\", "6"} => "Reserved",
{"\\", "7"} => "Reserved",
{"\\", "8"} => "Reserved",
{"\\", "9"} => "Reserved",
{"\\", ":"} => "Reserved",
{"\\", ";"} => "Reserved",
{"\\", "<"} => "Reserved",
{"\\", "="} => "Reserved",
{"\\", ">"} => "Car (alternate)",
{"\\", "?"} => "Reserved",
{"\\", "@"} => "Reserved",
{"\\", "A"} => "Reserved",
{"\\", "B"} => "Reserved",
{"\\", "C"} => "Reserved",
{"\\", "D"} => "Reserved",
{"\\", "E"} => "Reserved",
{"\\", "F"} => "Reserved",
{"\\", "G"} => "Reserved",
{"\\", "H"} => "Reserved",
{"\\", "I"} => "Reserved",
{"\\", "J"} => "Reserved",
{"\\", "K"} => "Reserved",
{"\\", "L"} => "Reserved",
{"\\", "M"} => "Reserved",
{"\\", "N"} => "Reserved",
{"\\", "O"} => "Reserved",
{"\\", "P"} => "Reserved",
{"\\", "Q"} => "Reserved",
{"\\", "R"} => "Reserved",
{"\\", "S"} => "Reserved",
{"\\", "T"} => "Reserved",
{"\\", "U"} => "Reserved",
{"\\", "V"} => "Reserved",
{"\\", "W"} => "Reserved",
{"\\", "X"} => "Reserved",
{"\\", "Y"} => "Reserved",
{"\\", "Z"} => "Reserved",
{"\\", "["} => "Reserved",
{"\\", "\\"} => "Reserved",
{"\\", "]"} => "Reserved",
{"\\", "^"} => "Reserved",
{"\\", "_"} => "Reserved",
{"\\", "`"} => "Reserved",
{"\\", "a"} => "Reserved",
{"\\", "b"} => "Reserved",
{"\\", "c"} => "Reserved",
{"\\", "d"} => "Reserved",
{"\\", "e"} => "Reserved",
{"\\", "f"} => "Reserved",
{"\\", "g"} => "Reserved",
{"\\", "h"} => "Reserved",
{"\\", "i"} => "Reserved",
{"\\", "j"} => "Reserved",
{"\\", "k"} => "Reserved",
{"\\", "l"} => "Reserved",
{"\\", "m"} => "Reserved",
{"\\", "n"} => "Reserved",
{"\\", "o"} => "Reserved",
{"\\", "p"} => "Reserved",
{"\\", "q"} => "Reserved",
{"\\", "r"} => "Reserved",
{"\\", "s"} => "Reserved"
}
@doc """ @doc """
Get the sprite sheet filename for a given symbol table ID. Get the sprite sheet filename for a given symbol table ID.
@ -171,191 +347,6 @@ defmodule AprsWeb.Helpers.AprsSymbols do
""" """
@spec symbol_description(String.t(), String.t()) :: String.t() @spec symbol_description(String.t(), String.t()) :: String.t()
def symbol_description(symbol_table_id, symbol_code) do def symbol_description(symbol_table_id, symbol_code) do
case {symbol_table_id, symbol_code} do Map.get(@symbol_descriptions, {symbol_table_id, symbol_code}, "Unknown symbol")
# Primary Table (/)
{"/", "!"} -> "Police/Sheriff"
{"/", "\""} -> "Reserved"
{"/", "#"} -> "Digipeater"
{"/", "$"} -> "Phone"
{"/", "%"} -> "DX Cluster"
{"/", "&"} -> "HF Gateway"
{"/", "'"} -> "Small Aircraft"
{"/", "("} -> "Mobile Satellite Station"
{"/", ")"} -> "Wheelchair"
{"/", "*"} -> "Snowmobile"
{"/", "+"} -> "Red Cross"
{"/", ","} -> "Boy Scout"
{"/", "-"} -> "House"
{"/", "."} -> "X"
{"/", "/"} -> "Position"
{"/", "0"} -> "Circle"
{"/", "1"} -> "Circle"
{"/", "2"} -> "Circle"
{"/", "3"} -> "Circle"
{"/", "4"} -> "Circle"
{"/", "5"} -> "Circle"
{"/", "6"} -> "Circle"
{"/", "7"} -> "Circle"
{"/", "8"} -> "Circle"
{"/", "9"} -> "Circle"
{"/", ":"} -> "Fire Department"
{"/", ";"} -> "Campground"
{"/", "<"} -> "Motorcycle"
{"/", "="} -> "Rail Engine"
{"/", ">"} -> "Car"
{"/", "?"} -> "File Server"
{"/", "@"} -> "HC Future"
{"/", "A"} -> "Aid Station"
{"/", "B"} -> "BBS"
{"/", "C"} -> "Canoe"
{"/", "D"} -> "Reserved"
{"/", "E"} -> "Eyeball"
{"/", "F"} -> "Tractor"
{"/", "G"} -> "Grid Square"
{"/", "H"} -> "Hotel"
{"/", "I"} -> "TCP/IP"
{"/", "J"} -> "Phone"
{"/", "K"} -> "School"
{"/", "L"} -> "PC User"
{"/", "M"} -> "MacAPRS"
{"/", "N"} -> "NTS Station"
{"/", "O"} -> "Balloon"
{"/", "P"} -> "Police"
{"/", "Q"} -> "TBD"
{"/", "R"} -> "Recreational Vehicle"
{"/", "S"} -> "Shuttle"
{"/", "T"} -> "SSTV"
{"/", "U"} -> "Bus"
{"/", "V"} -> "ATV"
{"/", "W"} -> "National Weather Service"
{"/", "X"} -> "Helo"
{"/", "Y"} -> "Yacht"
{"/", "Z"} -> "WinAPRS"
{"/", "["} -> "Jogger"
{"/", "\\"} -> "Triangle"
{"/", "]"} -> "PBBS"
{"/", "^"} -> "Aircraft"
{"/", "_"} -> "Weather Station"
{"/", "`"} -> "Dish Antenna"
{"/", "a"} -> "Ambulance"
{"/", "b"} -> "Bike"
{"/", "c"} -> "Incident Command Post"
{"/", "d"} -> "Fire Depts"
{"/", "e"} -> "Horse"
{"/", "f"} -> "Fire Truck"
{"/", "g"} -> "Glider"
{"/", "h"} -> "Hospital"
{"/", "i"} -> "IOTA"
{"/", "j"} -> "Jeep"
{"/", "k"} -> "Truck"
{"/", "l"} -> "Laptop"
{"/", "m"} -> "Mic-E"
{"/", "n"} -> "Node"
{"/", "o"} -> "EOC"
{"/", "p"} -> "Dog"
{"/", "q"} -> "Grid"
{"/", "r"} -> "Repeater"
{"/", "s"} -> "Ship"
{"/", "t"} -> "Truck Stop"
{"/", "u"} -> "Truck"
{"/", "v"} -> "Van"
{"/", "w"} -> "Water Station"
{"/", "x"} -> "X-APRS"
{"/", "y"} -> "Yagi"
{"/", "z"} -> "Shelter"
# Secondary Table (\)
{"\\", "!"} -> "Emergency"
{"\\", "\""} -> "Reserved"
{"\\", "#"} -> "Digipeater"
{"\\", "$"} -> "Bank"
{"\\", "%"} -> "Reserved"
{"\\", "&"} -> "Reserved"
{"\\", "'"} -> "Reserved"
{"\\", "("} -> "Reserved"
{"\\", ")"} -> "Reserved"
{"\\", "*"} -> "Reserved"
{"\\", "+"} -> "Reserved"
{"\\", ","} -> "Reserved"
{"\\", "-"} -> "Reserved"
{"\\", "."} -> "Reserved"
{"\\", "/"} -> "Triangle"
{"\\", "0"} -> "Reserved"
{"\\", "1"} -> "Reserved"
{"\\", "2"} -> "Reserved"
{"\\", "3"} -> "Reserved"
{"\\", "4"} -> "Reserved"
{"\\", "5"} -> "Reserved"
{"\\", "6"} -> "Reserved"
{"\\", "7"} -> "Reserved"
{"\\", "8"} -> "Reserved"
{"\\", "9"} -> "Reserved"
{"\\", ":"} -> "Reserved"
{"\\", ";"} -> "Reserved"
{"\\", "<"} -> "Reserved"
{"\\", "="} -> "Reserved"
{"\\", ">"} -> "Car (alternate)"
{"\\", "?"} -> "Reserved"
{"\\", "@"} -> "Reserved"
{"\\", "A"} -> "Reserved"
{"\\", "B"} -> "Reserved"
{"\\", "C"} -> "Reserved"
{"\\", "D"} -> "Reserved"
{"\\", "E"} -> "Reserved"
{"\\", "F"} -> "Reserved"
{"\\", "G"} -> "Reserved"
{"\\", "H"} -> "Reserved"
{"\\", "I"} -> "Reserved"
{"\\", "J"} -> "Reserved"
{"\\", "K"} -> "Reserved"
{"\\", "L"} -> "Reserved"
{"\\", "M"} -> "Reserved"
{"\\", "N"} -> "Reserved"
{"\\", "O"} -> "Reserved"
{"\\", "P"} -> "Reserved"
{"\\", "Q"} -> "Reserved"
{"\\", "R"} -> "Reserved"
{"\\", "S"} -> "Reserved"
{"\\", "T"} -> "Reserved"
{"\\", "U"} -> "Reserved"
{"\\", "V"} -> "Reserved"
{"\\", "W"} -> "Reserved"
{"\\", "X"} -> "Reserved"
{"\\", "Y"} -> "Reserved"
{"\\", "Z"} -> "Reserved"
{"\\", "["} -> "Reserved"
{"\\", "\\"} -> "Reserved"
{"\\", "]"} -> "Reserved"
{"\\", "^"} -> "Reserved"
{"\\", "_"} -> "Reserved"
{"\\", "`"} -> "Reserved"
{"\\", "a"} -> "Reserved"
{"\\", "b"} -> "Reserved"
{"\\", "c"} -> "Reserved"
{"\\", "d"} -> "Reserved"
{"\\", "e"} -> "Reserved"
{"\\", "f"} -> "Reserved"
{"\\", "g"} -> "Reserved"
{"\\", "h"} -> "Reserved"
{"\\", "i"} -> "Reserved"
{"\\", "j"} -> "Reserved"
{"\\", "k"} -> "Reserved"
{"\\", "l"} -> "Reserved"
{"\\", "m"} -> "Reserved"
{"\\", "n"} -> "Reserved"
{"\\", "o"} -> "Reserved"
{"\\", "p"} -> "Reserved"
{"\\", "q"} -> "Reserved"
{"\\", "r"} -> "Reserved"
{"\\", "s"} -> "Reserved"
{"\\", "t"} -> "Reserved"
{"\\", "u"} -> "Reserved"
{"\\", "v"} -> "Reserved"
{"\\", "w"} -> "Reserved"
{"\\", "x"} -> "Reserved"
{"\\", "y"} -> "Reserved"
{"\\", "z"} -> "Reserved"
# Unknown symbol
_ -> "Unknown symbol"
end
end end
end end

View file

@ -259,17 +259,23 @@ defmodule AprsWeb.MapLive.CallsignView do
"#{sanitized_packet.base_callsign}#{if sanitized_packet.ssid, do: "-#{sanitized_packet.ssid}", else: ""}" "#{sanitized_packet.base_callsign}#{if sanitized_packet.ssid, do: "-#{sanitized_packet.ssid}", else: ""}"
# Update visible packets tracking # Update visible packets tracking
visible_packets = Map.put(socket.assigns.visible_packets, callsign_key, sanitized_packet) visible_packets =
Map.put(socket.assigns.visible_packets, callsign_key, sanitized_packet)
# Update last known position # Update last known position
{lat, lng} = get_coordinates(sanitized_packet) {lat, lng} = get_coordinates(sanitized_packet)
last_known_position = if lat && lng, do: %{lat: lat, lng: lng}, else: socket.assigns.last_known_position
last_known_position =
if lat && lng, do: %{lat: lat, lng: lng}, else: socket.assigns.last_known_position
# Push the packet to the client-side JavaScript # Push the packet to the client-side JavaScript
socket = socket =
socket socket
|> push_event("new_packet", packet_data) |> push_event("new_packet", packet_data)
|> assign(visible_packets: visible_packets, last_known_position: last_known_position) |> assign(
visible_packets: visible_packets,
last_known_position: last_known_position
)
# If this is the first packet and map is ready, zoom to it # If this is the first packet and map is ready, zoom to it
# Or if this is a new position that's significantly different, update zoom # Or if this is a new position that's significantly different, update zoom
@ -282,7 +288,8 @@ defmodule AprsWeb.MapLive.CallsignView do
zoom: 12 zoom: 12
}) })
should_update_zoom?(socket.assigns.last_known_position, last_known_position) and socket.assigns.map_ready -> should_update_zoom?(socket.assigns.last_known_position, last_known_position) and
socket.assigns.map_ready ->
push_event(socket, "zoom_to_location", %{ push_event(socket, "zoom_to_location", %{
lat: last_known_position.lat, lat: last_known_position.lat,
lng: last_known_position.lng, lng: last_known_position.lng,
@ -306,21 +313,54 @@ defmodule AprsWeb.MapLive.CallsignView do
end end
defp handle_replay_next_packet(socket) do defp handle_replay_next_packet(socket) do
if socket.assigns.replay_active and not socket.assigns.replay_paused and if should_continue_replay?(socket) do
socket.assigns.replay_index < length(socket.assigns.replay_packets) do
packet = Enum.at(socket.assigns.replay_packets, socket.assigns.replay_index) packet = Enum.at(socket.assigns.replay_packets, socket.assigns.replay_index)
handle_replay_packet(packet, socket)
else
{:noreply, socket}
end
end
if packet do defp should_continue_replay?(socket) do
packet_data = build_packet_data(packet) socket.assigns.replay_active and
not socket.assigns.replay_paused and
socket.assigns.replay_index < length(socket.assigns.replay_packets)
end
if packet_data do defp handle_replay_packet(nil, socket) do
# Add to historical packets map socket =
assign(socket,
replay_active: false,
replay_paused: false,
replay_timer_ref: nil,
replay_index: 0
)
{:noreply, socket}
end
defp handle_replay_packet(packet, socket) do
case build_packet_data(packet) do
nil -> handle_invalid_replay_packet(socket)
packet_data -> handle_valid_replay_packet(packet, packet_data, socket)
end
end
defp handle_invalid_replay_packet(socket) do
timer_ref = Process.send_after(self(), :replay_next_packet, 10)
socket =
assign(socket,
replay_index: socket.assigns.replay_index + 1,
replay_timer_ref: timer_ref
)
{:noreply, socket}
end
defp handle_valid_replay_packet(packet, packet_data, socket) do
historical_packets = Map.put(socket.assigns.historical_packets, packet_data.id, packet) historical_packets = Map.put(socket.assigns.historical_packets, packet_data.id, packet)
# Push as historical packet
socket = push_event(socket, "historical_packet", Map.put(packet_data, :historical, true)) socket = push_event(socket, "historical_packet", Map.put(packet_data, :historical, true))
# Schedule next packet
delay = trunc(1000 / socket.assigns.replay_speed) delay = trunc(1000 / socket.assigns.replay_speed)
timer_ref = Process.send_after(self(), :replay_next_packet, delay) timer_ref = Process.send_after(self(), :replay_next_packet, delay)
@ -332,33 +372,6 @@ defmodule AprsWeb.MapLive.CallsignView do
) )
{:noreply, socket} {:noreply, socket}
else
# Skip invalid packet
timer_ref = Process.send_after(self(), :replay_next_packet, 10)
socket =
assign(socket,
replay_index: socket.assigns.replay_index + 1,
replay_timer_ref: timer_ref
)
{:noreply, socket}
end
else
# End of replay
socket =
assign(socket,
replay_active: false,
replay_paused: false,
replay_timer_ref: nil,
replay_index: 0
)
{:noreply, socket}
end
else
{:noreply, socket}
end
end end
def render(assigns) do def render(assigns) do
@ -735,29 +748,16 @@ defmodule AprsWeb.MapLive.CallsignView do
end end
end end
defp get_coordinates(packet) do defp get_coordinates(%{data_extended: %MicE{} = mic_e}), do: get_coordinates_from_mic_e(mic_e)
case packet.data_extended do defp get_coordinates(%{data_extended: %{latitude: lat, longitude: lon}}), do: {lat, lon}
%MicE{} = mic_e -> defp get_coordinates(_), do: {nil, nil}
# Convert MicE components to decimal degrees
defp get_coordinates_from_mic_e(mic_e) do
lat = mic_e.lat_degrees + mic_e.lat_minutes / 60.0 + mic_e.lat_fractional / 6000.0 lat = mic_e.lat_degrees + mic_e.lat_minutes / 60.0 + mic_e.lat_fractional / 6000.0
lat = if mic_e.lat_direction == :south, do: -lat, else: lat lat = if mic_e.lat_direction == :south, do: -lat, else: lat
lng = mic_e.lon_degrees + mic_e.lon_minutes / 60.0 + mic_e.lon_fractional / 6000.0 lng = mic_e.lon_degrees + mic_e.lon_minutes / 60.0 + mic_e.lon_fractional / 6000.0
lng = if mic_e.lon_direction == :west, do: -lng, else: lng lng = if mic_e.lon_direction == :west, do: -lng, else: lng
if lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180, do: {lat, lng}, else: {nil, nil}
# Validate coordinates are within valid ranges
if lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180 do
{lat, lng}
else
{nil, nil}
end
%{latitude: lat, longitude: lon} ->
{lat, lon}
_ ->
{nil, nil}
end
end end
defp build_packet_data(packet) do defp build_packet_data(packet) do
@ -766,23 +766,9 @@ defmodule AprsWeb.MapLive.CallsignView do
if lat && lng do if lat && lng do
callsign = "#{packet.base_callsign}#{if packet.ssid, do: "-#{packet.ssid}", else: ""}" callsign = "#{packet.base_callsign}#{if packet.ssid, do: "-#{packet.ssid}", else: ""}"
symbol_table_id = symbol_table_id = get_symbol_table_id(packet.data_extended)
case packet.data_extended do symbol_code = get_symbol_code(packet.data_extended)
%{symbol_table_id: id} -> id comment = get_comment(packet.data_extended)
_ -> "/"
end
symbol_code =
case packet.data_extended do
%{symbol_code: code} -> code
_ -> ">"
end
comment =
case packet.data_extended do
%{comment: comment} when is_binary(comment) -> comment
_ -> ""
end
%{ %{
id: "#{callsign}_#{:os.system_time(:millisecond)}", id: "#{callsign}_#{:os.system_time(:millisecond)}",
@ -798,6 +784,15 @@ defmodule AprsWeb.MapLive.CallsignView do
end end
end end
defp get_symbol_table_id(%{symbol_table_id: id}), do: id
defp get_symbol_table_id(_), do: "/"
defp get_symbol_code(%{symbol_code: code}), do: code
defp get_symbol_code(_), do: ">"
defp get_comment(%{comment: comment}) when is_binary(comment), do: comment
defp get_comment(_), do: ""
defp build_data_extended(nil), do: nil defp build_data_extended(nil), do: nil
defp build_data_extended(data_extended) do defp build_data_extended(data_extended) do
@ -824,12 +819,16 @@ defmodule AprsWeb.MapLive.CallsignView do
end end
defp packet_matches_callsign?(packet, target_callsign) do defp packet_matches_callsign?(packet, target_callsign) do
# Handle both struct and map formats normalized_packet = normalize_packet_callsign(packet)
normalized_target = normalize_target_callsign(target_callsign)
exact_or_base_match?(normalized_packet, normalized_target, packet)
end
defp normalize_packet_callsign(packet) do
base_callsign = packet[:base_callsign] || packet.base_callsign || "" base_callsign = packet[:base_callsign] || packet.base_callsign || ""
ssid = packet[:ssid] || packet.ssid ssid = packet[:ssid] || packet.ssid
# Build the full callsign case_result =
packet_callsign =
case ssid do case ssid do
nil -> base_callsign nil -> base_callsign
"" -> base_callsign "" -> base_callsign
@ -837,20 +836,28 @@ defmodule AprsWeb.MapLive.CallsignView do
_ -> "#{base_callsign}-#{ssid}" _ -> "#{base_callsign}-#{ssid}"
end end
# Normalize both callsigns for comparison case_result
normalized_packet = String.upcase(String.trim(packet_callsign)) |> String.upcase()
normalized_target = String.upcase(String.trim(target_callsign)) |> String.trim()
# Try exact match first
if normalized_packet == normalized_target do
true
else
# If target has no SSID, match against base callsign only
if String.contains?(normalized_target, "-") do
false
else
String.upcase(String.trim(base_callsign)) == normalized_target
end end
defp normalize_target_callsign(target_callsign) do
target_callsign
|> String.upcase()
|> String.trim()
end
defp exact_or_base_match?(normalized_packet, normalized_target, packet) do
cond do
normalized_packet == normalized_target ->
true
not String.contains?(normalized_target, "-") ->
base_callsign = packet[:base_callsign] || packet.base_callsign || ""
String.upcase(String.trim(base_callsign)) == normalized_target
true ->
false
end end
end end

View file

@ -66,15 +66,34 @@ defmodule AprsWeb.MapLive.Index do
@spec maybe_start_geolocation(Socket.t()) :: Socket.t() @spec maybe_start_geolocation(Socket.t()) :: Socket.t()
defp maybe_start_geolocation(socket) do defp maybe_start_geolocation(socket) do
if Application.get_env(:aprs, :disable_aprs_connection, false) != true do if geolocation_enabled?() do
ip = ip = extract_ip(socket)
if valid_ip_for_geolocation?(ip) do
start_geolocation_task(ip)
end
end
socket
end
defp geolocation_enabled? do
Application.get_env(:aprs, :disable_aprs_connection, false) != true
end
defp extract_ip(socket) do
case socket.private[:connect_info][:peer_data][:address] do case socket.private[:connect_info][:peer_data][:address] do
{a, b, c, d} -> "#{a}.#{b}.#{c}.#{d}" {a, b, c, d} -> "#{a}.#{b}.#{c}.#{d}"
{a, b, c, d, e, f, g, h} -> "#{a}:#{b}:#{c}:#{d}:#{e}:#{f}:#{g}:#{h}" {a, b, c, d, e, f, g, h} -> "#{a}:#{b}:#{c}:#{d}:#{e}:#{f}:#{g}:#{h}"
_ -> nil _ -> nil
end end
end
if ip && !String.starts_with?(ip, "127.") && !String.starts_with?(ip, "::1") do defp valid_ip_for_geolocation?(ip) do
ip && !String.starts_with?(ip, "127.") && !String.starts_with?(ip, "::1")
end
defp start_geolocation_task(ip) do
Task.start(fn -> Task.start(fn ->
try do try do
get_ip_location(ip) get_ip_location(ip)
@ -84,10 +103,6 @@ defmodule AprsWeb.MapLive.Index do
end end
end) end)
end end
end
socket
end
defp schedule_timers do defp schedule_timers do
Process.send_after(self(), :cleanup_old_packets, 60_000) Process.send_after(self(), :cleanup_old_packets, 60_000)
@ -299,19 +314,36 @@ defmodule AprsWeb.MapLive.Index do
end end
@impl true @impl true
def handle_info(msg, socket) do def handle_info({:process_bounds_update, map_bounds}, socket), do: handle_info_process_bounds_update(map_bounds, socket)
case msg do
{:process_bounds_update, map_bounds} -> def handle_info({:delayed_zoom, %{lat: lat, lng: lng}}, socket), do: handle_info_delayed_zoom(lat, lng, socket)
# Process the debounced bounds update
def handle_info({:ip_location, %{lat: lat, lng: lng}}, socket), do: handle_info_ip_location(lat, lng, socket)
def handle_info(:initialize_replay, socket), do: handle_info_initialize_replay(socket)
def handle_info(:replay_next_packet, socket), do: handle_replay_next_packet(socket)
def handle_info(:cleanup_old_packets, socket), do: handle_cleanup_old_packets(socket)
def handle_info({:postgres_packet, packet}, socket), do: handle_info_postgres_packet(packet, socket)
def handle_info(:flush_packet_buffer, socket), do: handle_info_flush_packet_buffer(socket)
def handle_info(%Phoenix.Socket.Broadcast{topic: "aprs_messages", event: "packet", payload: packet}, socket),
do: handle_info({:postgres_packet, packet}, socket)
# Private handler functions for each message type
defp handle_info_process_bounds_update(map_bounds, socket) do
socket = process_bounds_update(map_bounds, socket) socket = process_bounds_update(map_bounds, socket)
{:noreply, socket} {:noreply, socket}
end
{:delayed_zoom, %{lat: lat, lng: lng}} -> defp handle_info_delayed_zoom(lat, lng, socket) do
socket = push_event(socket, "zoom_to_location", %{lat: lat, lng: lng, zoom: 12}) socket = push_event(socket, "zoom_to_location", %{lat: lat, lng: lng, zoom: 12})
{:noreply, socket} {:noreply, socket}
end
{:ip_location, %{lat: lat, lng: lng}} -> defp handle_info_ip_location(lat, lng, socket) do
# Ensure we're using numeric values for coordinates
lat_float = lat_float =
cond do cond do
is_binary(lat) -> String.to_float(lat) is_binary(lat) -> String.to_float(lat)
@ -326,10 +358,8 @@ defmodule AprsWeb.MapLive.Index do
true -> lng true -> lng
end end
# Update map center first
socket = assign(socket, map_center: %{lat: lat_float, lng: lng_float}, map_zoom: 12) socket = assign(socket, map_center: %{lat: lat_float, lng: lng_float}, map_zoom: 12)
# If map is ready, zoom to location immediately, otherwise store for later
socket = socket =
if socket.assigns.map_ready do if socket.assigns.map_ready do
push_event(socket, "zoom_to_location", %{lat: lat_float, lng: lng_float, zoom: 12}) push_event(socket, "zoom_to_location", %{lat: lat_float, lng: lng_float, zoom: 12})
@ -338,34 +368,30 @@ defmodule AprsWeb.MapLive.Index do
end end
{:noreply, socket} {:noreply, socket}
end
:initialize_replay -> defp handle_info_initialize_replay(socket) do
# Only start replay if it hasn't been started yet
if not socket.assigns.replay_started and socket.assigns.map_ready do if not socket.assigns.replay_started and socket.assigns.map_ready do
socket = start_historical_replay(socket) socket = start_historical_replay(socket)
{:noreply, assign(socket, replay_started: true)} {:noreply, assign(socket, replay_started: true)}
else else
{:noreply, socket} {:noreply, socket}
end end
end
:replay_next_packet -> defp handle_info_postgres_packet(packet, socket) do
handle_replay_next_packet(socket)
:cleanup_old_packets ->
# Clean up packets older than 1 hour from the map display
handle_cleanup_old_packets(socket)
{:postgres_packet, packet} ->
{lat, lon, _data_extended} = get_coordinates(packet) {lat, lon, _data_extended} = get_coordinates(packet)
if is_nil(lat) or is_nil(lon) do if is_nil(lat) or is_nil(lon),
{:noreply, socket} do: {:noreply, socket},
else else: handle_valid_postgres_packet(packet, lat, lon, socket)
end
defp handle_valid_postgres_packet(packet, lat, lon, socket) do
if within_bounds?(%{lat: lat, lon: lon}, socket.assigns.map_bounds) do if within_bounds?(%{lat: lat, lon: lon}, socket.assigns.map_bounds) do
# Add to buffer for debounced batch update
buffer = [packet | socket.assigns.packet_buffer] buffer = [packet | socket.assigns.packet_buffer]
socket = assign(socket, packet_buffer: buffer) socket = assign(socket, packet_buffer: buffer)
# If no timer, start one
socket = socket =
if socket.assigns.buffer_timer == nil do if socket.assigns.buffer_timer == nil do
timer = Process.send_after(self(), :flush_packet_buffer, @debounce_interval) timer = Process.send_after(self(), :flush_packet_buffer, @debounce_interval)
@ -380,27 +406,12 @@ defmodule AprsWeb.MapLive.Index do
end end
end end
:flush_packet_buffer -> defp handle_info_flush_packet_buffer(socket) do
packets = Enum.reverse(socket.assigns.packet_buffer) packets = Enum.reverse(socket.assigns.packet_buffer)
visible_packets = socket.assigns.visible_packets visible_packets = socket.assigns.visible_packets
{new_visible_packets, events} = {new_visible_packets, events} = process_packet_buffer(packets, visible_packets)
Enum.reduce(packets, {visible_packets, []}, fn packet, {vis, evs} ->
packet_data = build_packet_data(packet)
if packet_data do
callsign_key =
if Map.has_key?(packet, "id"),
do: to_string(packet["id"]),
else: System.unique_integer([:positive])
{Map.put(vis, callsign_key, packet), [{:new_packet, packet_data} | evs]}
else
{vis, evs}
end
end)
# Push all new packets in one event (or as a batch)
socket = socket =
Enum.reduce(events, socket, fn {:new_packet, data}, acc -> Enum.reduce(events, socket, fn {:new_packet, data}, acc ->
push_event(acc, "new_packet", data) push_event(acc, "new_packet", data)
@ -414,9 +425,24 @@ defmodule AprsWeb.MapLive.Index do
) )
{:noreply, socket} {:noreply, socket}
end
%Phoenix.Socket.Broadcast{topic: "aprs_messages", event: "packet", payload: packet} -> defp process_packet_buffer([], visible_packets), do: {visible_packets, []}
handle_info({:postgres_packet, packet}, socket)
defp process_packet_buffer([packet | rest], visible_packets) do
{vis, evs} = process_packet_buffer(rest, visible_packets)
case build_packet_data(packet) do
nil ->
{vis, evs}
packet_data ->
callsign_key =
if Map.has_key?(packet, "id"),
do: to_string(packet["id"]),
else: System.unique_integer([:positive])
{Map.put(vis, callsign_key, packet), [{:new_packet, packet_data} | evs]}
end end
end end
@ -430,17 +456,19 @@ defmodule AprsWeb.MapLive.Index do
} = socket.assigns } = socket.assigns
if index < length(packets) do if index < length(packets) do
# Get the next packet and advance the index handle_next_replay_packet(socket, packets, index, speed, historical_packets)
else
handle_replay_end(socket)
end
end
defp handle_next_replay_packet(socket, packets, index, speed, historical_packets) do
packet = Enum.at(packets, index) packet = Enum.at(packets, index)
next_index = index + 1 next_index = index + 1
# Convert to a simple map structure for JSON encoding
packet_data = build_packet_data(packet) packet_data = build_packet_data(packet)
# Only process packets with valid position data
socket = socket =
if packet_data do if packet_data do
# Add is_historical flag and timestamp
packet_data = packet_data =
Map.merge(packet_data, %{ Map.merge(packet_data, %{
"is_historical" => true, "is_historical" => true,
@ -450,14 +478,11 @@ defmodule AprsWeb.MapLive.Index do
) )
}) })
# Generate a unique key for this packet
packet_id = packet_id =
"hist_#{if Map.has_key?(packet, :id), do: packet.id, else: System.unique_integer([:positive])}" "hist_#{if Map.has_key?(packet, :id), do: packet.id, else: System.unique_integer([:positive])}"
# Update historical packets tracking
new_historical_packets = Map.put(historical_packets, packet_id, packet) new_historical_packets = Map.put(historical_packets, packet_id, packet)
# Send packet data to client
socket socket
|> push_event("historical_packet", packet_data) |> push_event("historical_packet", packet_data)
|> assign( |> assign(
@ -465,21 +490,17 @@ defmodule AprsWeb.MapLive.Index do
historical_packets: new_historical_packets historical_packets: new_historical_packets
) )
else else
# Skip packets without position data, just advance the index
assign(socket, replay_index: next_index) assign(socket, replay_index: next_index)
end end
# Calculate delay for next packet (faster based on replay speed)
delay_ms = trunc(1000 / speed) delay_ms = trunc(1000 / speed)
# Schedule the next packet
timer_ref = Process.send_after(self(), :replay_next_packet, delay_ms) timer_ref = Process.send_after(self(), :replay_next_packet, delay_ms)
{:noreply, assign(socket, replay_timer_ref: timer_ref)} {:noreply, assign(socket, replay_timer_ref: timer_ref)}
else end
# All packets replayed, start over with a short delay
defp handle_replay_end(socket) do
Process.send_after(self(), :initialize_replay, 10_000) Process.send_after(self(), :initialize_replay, 10_000)
# Set active to false but don't clear packets - will auto-restart
socket = socket =
assign(socket, assign(socket,
replay_active: false, replay_active: false,
@ -489,7 +510,6 @@ defmodule AprsWeb.MapLive.Index do
{:noreply, socket} {:noreply, socket}
end end
end
@impl true @impl true
def render(assigns) do def render(assigns) do
@ -647,36 +667,45 @@ defmodule AprsWeb.MapLive.Index do
# Handle cleanup of old packets # Handle cleanup of old packets
defp handle_cleanup_old_packets(socket) do defp handle_cleanup_old_packets(socket) do
# Update the packet age threshold to current time minus one hour
one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second) one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second)
packets_to_remove = get_packets_to_remove(socket, one_hour_ago)
visible_packets = get_visible_packets(socket, one_hour_ago)
# Get packets that need to be removed (old or outside bounds) socket =
packets_to_remove = if map_size(visible_packets) == map_size(socket.assigns.visible_packets) do
assign(socket, packet_age_threshold: one_hour_ago)
else
update_socket_for_removed_packets(
socket,
visible_packets,
one_hour_ago,
packets_to_remove
)
end
if connected?(socket), do: Process.send_after(self(), :cleanup_old_packets, 60_000)
{:noreply, socket}
end
defp get_packets_to_remove(socket, one_hour_ago) do
socket.assigns.visible_packets socket.assigns.visible_packets
|> Enum.reject(fn {_key, packet} -> |> Enum.reject(fn {_key, packet} ->
packet_within_time_threshold?(packet, one_hour_ago) && packet_within_time_threshold?(packet, one_hour_ago) &&
within_bounds?(packet, socket.assigns.map_bounds) within_bounds?(packet, socket.assigns.map_bounds)
end) end)
|> Enum.map(fn {callsign, _packet} -> callsign end) |> Enum.map(fn {callsign, _packet} -> callsign end)
end
# Filter out packets older than one hour and outside bounds defp get_visible_packets(socket, one_hour_ago) do
visible_packets =
socket.assigns.visible_packets socket.assigns.visible_packets
|> Enum.filter(fn {_key, packet} -> |> Enum.filter(fn {_key, packet} ->
packet_within_time_threshold?(packet, one_hour_ago) && packet_within_time_threshold?(packet, one_hour_ago) &&
within_bounds?(packet, socket.assigns.map_bounds) within_bounds?(packet, socket.assigns.map_bounds)
end) end)
|> Map.new() |> Map.new()
end
# Only update if there are actual changes to prevent unnecessary events defp update_socket_for_removed_packets(socket, visible_packets, one_hour_ago, packets_to_remove) do
socket =
if map_size(visible_packets) == map_size(socket.assigns.visible_packets) do
# Just update the threshold without map changes
assign(socket, packet_age_threshold: one_hour_ago)
# Remove old and out-of-bounds markers from the map
# Explicitly remove markers for packets that are no longer valid
else
socket = socket =
socket socket
|> push_event("refresh_markers", %{}) |> push_event("refresh_markers", %{})
@ -694,12 +723,6 @@ defmodule AprsWeb.MapLive.Index do
end end
end end
# Schedule the next cleanup in 1 minute
if connected?(socket), do: Process.send_after(self(), :cleanup_old_packets, 60_000)
{:noreply, socket}
end
# Check if a packet is within the time threshold (not too old) # Check if a packet is within the time threshold (not too old)
@spec packet_within_time_threshold?(map(), DateTime.t()) :: boolean() @spec packet_within_time_threshold?(map(), DateTime.t()) :: boolean()
defp packet_within_time_threshold?(packet, threshold) do defp packet_within_time_threshold?(packet, threshold) do
@ -810,7 +833,7 @@ defmodule AprsWeb.MapLive.Index do
end end
end end
@spec get_coordinates(map() | struct()) :: {number() | nil, number() | nil} @spec get_coordinates(map() | struct()) :: {number() | nil, number() | nil, map() | nil}
defp get_coordinates(packet) do defp get_coordinates(packet) do
# Safely get data_extended for both atom and string keys # Safely get data_extended for both atom and string keys
data_extended = Map.get(packet, :data_extended) || Map.get(packet, "data_extended") data_extended = Map.get(packet, :data_extended) || Map.get(packet, "data_extended")
@ -887,55 +910,47 @@ defmodule AprsWeb.MapLive.Index do
@spec get_ip_location(String.t() | nil) :: {float(), float()} | nil @spec get_ip_location(String.t() | nil) :: {float(), float()} | nil
defp get_ip_location(nil), do: nil defp get_ip_location(nil), do: nil
@spec get_ip_location(String.t()) :: {float(), float()} | nil
defp get_ip_location(ip) do defp get_ip_location(ip) do
url = "#{@ip_api_url}#{ip}" url = "#{@ip_api_url}#{ip}"
# Add headers to make the request more likely to succeed
request = request =
Finch.build(:get, url, [ Finch.build(:get, url, [
{"User-Agent", "APRS.me/1.0"}, {"User-Agent", "APRS.me/1.0"},
{"Accept", "application/json"} {"Accept", "application/json"}
]) ])
# Add a small delay to ensure the client is connected
Process.sleep(2000) Process.sleep(2000)
case Finch.request(request, @finch_name, receive_timeout: 10_000) do case Finch.request(request, @finch_name, receive_timeout: 10_000) do
{:ok, %{status: 200, body: body}} -> {:ok, %{status: 200, body: body}} -> handle_ip_api_response(body)
{:ok, _response} -> send_default_ip_location()
{:error, %{reason: :timeout}} -> send_default_ip_location()
{:error, _error} -> send_default_ip_location()
end
end
defp handle_ip_api_response(body) do
case Jason.decode(body) do case Jason.decode(body) do
{:ok, %{"status" => "success", "lat" => lat, "lon" => lng}} {:ok, %{"status" => "success", "lat" => lat, "lon" => lng}}
when is_number(lat) and is_number(lng) -> when is_number(lat) and is_number(lng) ->
if lat >= -90 and lat <= 90 and lng >= -180 and lng <= 180 do if lat >= -90 and lat <= 90 and lng >= -180 and lng <= 180 do
# Force numeric values
lat_float = lat / 1.0 lat_float = lat / 1.0
lng_float = lng / 1.0 lng_float = lng / 1.0
send(self(), {:ip_location, %{lat: lat_float, lng: lng_float}}) send(self(), {:ip_location, %{lat: lat_float, lng: lng_float}})
else else
send(self(), {:ip_location, @default_center}) send_default_ip_location()
end end
{:ok, %{"status" => _status}} -> {:ok, _} ->
send(self(), {:ip_location, @default_center}) send_default_ip_location()
{:ok, _data} ->
send(self(), {:ip_location, @default_center})
{:error, _decode_error} -> {:error, _decode_error} ->
send(self(), {:ip_location, @default_center}) send_default_ip_location()
end
{:ok, _response} ->
send(self(), {:ip_location, @default_center})
{:error, %{reason: :timeout}} ->
send(self(), {:ip_location, @default_center})
{:error, _error} ->
send(self(), {:ip_location, @default_center})
end end
end end
defp send_default_ip_location, do: send(self(), {:ip_location, @default_center})
# Helper function to convert string or float to float # Helper function to convert string or float to float
@spec to_float(number() | String.t()) :: float() @spec to_float(number() | String.t()) :: float()
defp to_float(value) when is_float(value), do: value defp to_float(value) when is_float(value), do: value

View file

@ -53,39 +53,14 @@ defmodule AprsWeb.PacketsLive.CallsignView do
@impl true @impl true
def handle_info(%{event: "packet", payload: payload}, socket) do def handle_info(%{event: "packet", payload: payload}, socket) do
# Handle incoming live packets - only process if they match our callsign # Handle incoming live packets - only process if they match our callsign
# Only process packets for this specific callsign
if packet_matches_callsign?(payload, socket.assigns.callsign) do if packet_matches_callsign?(payload, socket.assigns.callsign) do
# Sanitize the packet to prevent JSON encoding errors
sanitized_payload = EncodingUtils.sanitize_packet(payload) sanitized_payload = EncodingUtils.sanitize_packet(payload)
# Add to live packets and maintain combined limit of 100
current_live = socket.assigns.live_packets current_live = socket.assigns.live_packets
current_stored = socket.assigns.packets current_stored = socket.assigns.packets
# Calculate how many total packets we have
total_count = length(current_live) + length(current_stored)
# If we're at or over the limit, remove the oldest stored packet
{updated_stored, updated_live} = {updated_stored, updated_live} =
if total_count >= 100 do update_packet_lists(current_stored, current_live, sanitized_payload)
# Remove the oldest stored packet if we have any, otherwise remove oldest live
case current_stored do
[] ->
# No stored packets, remove oldest live packet
live_without_oldest = Enum.drop(current_live, -1)
{current_stored, [sanitized_payload | live_without_oldest]}
_ ->
# Remove oldest stored packet
stored_without_oldest = Enum.drop(current_stored, -1)
{stored_without_oldest, [sanitized_payload | current_live]}
end
else
# Under limit, just add the new packet
{current_stored, [sanitized_payload | current_live]}
end
# Calculate combined packets for display
all_packets = get_all_packets_list(updated_stored, updated_live) all_packets = get_all_packets_list(updated_stored, updated_live)
socket = socket =
@ -101,9 +76,7 @@ defmodule AprsWeb.PacketsLive.CallsignView do
end end
@impl true @impl true
def handle_info(_message, socket) do def handle_info(_message, socket), do: {:noreply, socket}
{:noreply, socket}
end
# Private helper functions # Private helper functions
@ -189,6 +162,24 @@ defmodule AprsWeb.PacketsLive.CallsignView do
|> Enum.take(100) |> Enum.take(100)
end end
# Helper to update stored and live packet lists, keeping total <= 100
defp update_packet_lists(current_stored, current_live, sanitized_payload) do
total_count = length(current_live) + length(current_stored)
cond do
total_count >= 100 and current_stored == [] ->
live_without_oldest = Enum.drop(current_live, -1)
{current_stored, [sanitized_payload | live_without_oldest]}
total_count >= 100 ->
stored_without_oldest = Enum.drop(current_stored, -1)
{stored_without_oldest, [sanitized_payload | current_live]}
true ->
{current_stored, [sanitized_payload | current_live]}
end
end
# Validates if the callsign format is reasonable # Validates if the callsign format is reasonable
defp valid_callsign?(callsign) do defp valid_callsign?(callsign) do
# Basic validation for amateur radio callsign format # Basic validation for amateur radio callsign format

View file

@ -4,6 +4,7 @@ defmodule Parser do
""" """
# import Bitwise # import Bitwise
alias Aprs.Convert alias Aprs.Convert
alias Parser.Types.MicE
require Logger require Logger
@ -52,7 +53,15 @@ defmodule Parser do
@type position_ambiguity :: 0..4 @type position_ambiguity :: 0..4
@spec parse(String.t()) :: parse_result() @spec parse(String.t()) :: parse_result()
def parse(message) do def parse(message) when is_binary(message) do
do_parse(message)
rescue
error ->
Logger.debug("PARSE ERROR: #{inspect(error)} for message: #{message}")
{:error, :invalid_packet}
end
defp do_parse(message) do
with {:ok, [sender, path, data]} <- split_packet(message), with {:ok, [sender, path, data]} <- split_packet(message),
{:ok, [base_callsign, ssid]} <- parse_callsign(sender), {:ok, [base_callsign, ssid]} <- parse_callsign(sender),
{:ok, data_type} <- parse_datatype_safe(data), {:ok, data_type} <- parse_datatype_safe(data),
@ -61,7 +70,6 @@ defmodule Parser do
:ok <- validate_callsign(destination, :dst), :ok <- validate_callsign(destination, :dst),
:ok <- validate_path(path) do :ok <- validate_path(path) do
data_trimmed = String.trim(data) data_trimmed = String.trim(data)
# Strip the first character (datatype indicator) from the data
data_without_type = String.slice(data_trimmed, 1..-1//1) data_without_type = String.slice(data_trimmed, 1..-1//1)
data_extended = parse_data(data_type, destination, data_without_type) data_extended = parse_data(data_type, destination, data_without_type)
@ -76,7 +84,6 @@ defmodule Parser do
base_callsign: base_callsign, base_callsign: base_callsign,
ssid: ssid, ssid: ssid,
data_extended: data_extended, data_extended: data_extended,
# Set received_at when creating packet
received_at: DateTime.truncate(DateTime.utc_now(), :microsecond) received_at: DateTime.truncate(DateTime.utc_now(), :microsecond)
}} }}
else else
@ -86,9 +93,6 @@ defmodule Parser do
_ -> {:error, reason} _ -> {:error, reason}
end end
{:error, %{error_code: code}} ->
{:error, to_string(code)}
_ -> _ ->
{:error, "PARSE ERROR"} {:error, "PARSE ERROR"}
end end
@ -110,7 +114,7 @@ defmodule Parser do
defp validate_callsign(callsign, :dst) do defp validate_callsign(callsign, :dst) do
cond do cond do
is_nil(callsign) or callsign == "" -> {:error, "Missing destination callsign"} callsign == "" -> {:error, "Missing destination callsign"}
not String.match?(callsign, ~r/^[A-Z0-9\-]+$/) -> {:error, "Invalid destination callsign"} not String.match?(callsign, ~r/^[A-Z0-9\-]+$/) -> {:error, "Invalid destination callsign"}
true -> :ok true -> :ok
end end
@ -206,7 +210,7 @@ defmodule Parser do
def parse_data(:position, _destination, data) do def parse_data(:position, _destination, data) do
case data do case data do
<<"/", _::binary>> -> <<"/", _::binary>> ->
result = parse_position_without_timestamp(false, data) result = parse_position_without_timestamp(data)
if result.data_type == :malformed_position do if result.data_type == :malformed_position do
result result
@ -215,7 +219,7 @@ defmodule Parser do
end end
_ -> _ ->
result = parse_position_without_timestamp(false, data) result = parse_position_without_timestamp(data)
if result.data_type == :malformed_position do if result.data_type == :malformed_position do
result result
@ -226,7 +230,7 @@ defmodule Parser do
end end
def parse_data(:position_with_message, _destination, data) do def parse_data(:position_with_message, _destination, data) do
result = parse_position_without_timestamp(true, data) result = parse_position_with_message_without_timestamp(data)
%{result | data_type: :position} %{result | data_type: :position}
end end
@ -288,11 +292,7 @@ defmodule Parser do
def parse_data(:user_defined, _destination, data), do: parse_user_defined(data) def parse_data(:user_defined, _destination, data), do: parse_user_defined(data)
def parse_data(:third_party_traffic, _destination, data), do: parse_third_party_traffic(data) def parse_data(:third_party_traffic, _destination, data), do: parse_third_party_traffic(data)
def parse_data(:phg_data, _destination, data) do def parse_data(:phg_data, _destination, <<"PHG", p, h, g, d, rest::binary>>) when byte_size(rest) >= 0 do
cond do
String.starts_with?(data, "PHG") and byte_size(data) >= 7 ->
<<"PHG", p, h, g, d, rest::binary>> = data
%{ %{
power: parse_phg_power(p), power: parse_phg_power(p),
height: parse_phg_height(h), height: parse_phg_height(h),
@ -301,10 +301,9 @@ defmodule Parser do
comment: rest, comment: rest,
data_type: :phg_data data_type: :phg_data
} }
end
String.starts_with?(data, "DFS") and byte_size(data) >= 7 -> def parse_data(:phg_data, _destination, <<"DFS", s, h, g, d, rest::binary>>) when byte_size(rest) >= 0 do
<<"DFS", s, h, g, d, rest::binary>> = data
%{ %{
df_strength: parse_df_strength(s), df_strength: parse_df_strength(s),
height: parse_phg_height(h), height: parse_phg_height(h),
@ -313,14 +312,14 @@ defmodule Parser do
comment: rest, comment: rest,
data_type: :df_report data_type: :df_report
} }
end
true -> def parse_data(:phg_data, _destination, data) do
%{ %{
phg_data: data, phg_data: data,
data_type: :phg_data data_type: :phg_data
} }
end end
end
def parse_data(:peet_logging, _destination, data), do: parse_peet_logging(data) def parse_data(:peet_logging, _destination, data), do: parse_peet_logging(data)
def parse_data(:invalid_test_data, _destination, data), do: parse_invalid_test_data(data) def parse_data(:invalid_test_data, _destination, data), do: parse_invalid_test_data(data)
@ -380,7 +379,6 @@ defmodule Parser do
case date_time_position_data do case date_time_position_data do
<<time::binary-size(7), latitude::binary-size(8), sym_table_id::binary-size(1), longitude::binary-size(9)>> -> <<time::binary-size(7), latitude::binary-size(8), sym_table_id::binary-size(1), longitude::binary-size(9)>> ->
%{latitude: lat, longitude: lon} = parse_aprs_position(latitude, longitude) %{latitude: lat, longitude: lon} = parse_aprs_position(latitude, longitude)
weather_data = parse_weather_data(weather_report) weather_data = parse_weather_data(weather_report)
%{ %{
@ -431,8 +429,9 @@ defmodule Parser do
(v1 - 33) * 91 * 91 * 91 + (v2 - 33) * 91 * 91 + (v3 - 33) * 91 + v4 (v1 - 33) * 91 * 91 * 91 + (v2 - 33) * 91 * 91 + (v3 - 33) * 91 + v4
end end
@spec parse_position_without_timestamp(boolean(), String.t()) :: map() # For packets without APRS messaging
def parse_position_without_timestamp(aprs_messaging?, position_data) do @spec parse_position_without_timestamp(String.t()) :: map()
def parse_position_without_timestamp(position_data) do
case position_data do case position_data do
<<latitude::binary-size(8), sym_table_id::binary-size(1), longitude::binary-size(9), symbol_code::binary-size(1), <<latitude::binary-size(8), sym_table_id::binary-size(1), longitude::binary-size(9), symbol_code::binary-size(1),
comment::binary>> -> comment::binary>> ->
@ -448,7 +447,7 @@ defmodule Parser do
symbol_code: symbol_code, symbol_code: symbol_code,
comment: comment, comment: comment,
data_type: :position, data_type: :position,
aprs_messaging?: aprs_messaging?, aprs_messaging?: false,
compressed?: false, compressed?: false,
position_ambiguity: ambiguity, position_ambiguity: ambiguity,
dao: dao_data dao: dao_data
@ -465,7 +464,7 @@ defmodule Parser do
symbol_table_id: sym_table_id, symbol_table_id: sym_table_id,
symbol_code: "_", symbol_code: "_",
data_type: :position, data_type: :position,
aprs_messaging?: aprs_messaging?, aprs_messaging?: false,
compressed?: false, compressed?: false,
position_ambiguity: ambiguity, position_ambiguity: ambiguity,
dao: nil dao: nil
@ -490,13 +489,13 @@ defmodule Parser do
data_type: :position, data_type: :position,
compressed?: true, compressed?: true,
position_ambiguity: ambiguity, position_ambiguity: ambiguity,
dao: nil dao: nil,
aprs_messaging?: false
} }
Map.merge(base_data, compressed_cs) Map.merge(base_data, compressed_cs)
rescue rescue
_e -> _e ->
# Instead of returning malformed_position, relax: return nil lat/lon but still :position
%{ %{
latitude: nil, latitude: nil,
longitude: nil, longitude: nil,
@ -508,7 +507,8 @@ defmodule Parser do
data_type: :position, data_type: :position,
compressed?: true, compressed?: true,
position_ambiguity: calculate_compressed_ambiguity(compression_type), position_ambiguity: calculate_compressed_ambiguity(compression_type),
dao: nil dao: nil,
aprs_messaging?: false
} }
end end
@ -520,7 +520,7 @@ defmodule Parser do
symbol_table_id: nil, symbol_table_id: nil,
symbol_code: nil, symbol_code: nil,
data_type: :malformed_position, data_type: :malformed_position,
aprs_messaging?: aprs_messaging?, aprs_messaging?: false,
compressed?: false, compressed?: false,
comment: String.trim(position_data), comment: String.trim(position_data),
dao: nil dao: nil
@ -528,26 +528,26 @@ defmodule Parser do
end end
end end
# For packets with APRS messaging
@spec parse_position_with_message_without_timestamp(String.t()) :: map()
def parse_position_with_message_without_timestamp(position_data) do
result = parse_position_without_timestamp(position_data)
Map.put(result, :aprs_messaging?, true)
end
@ambiguity_levels %{
{0, 0} => 0,
{1, 1} => 1,
{2, 2} => 2,
{3, 3} => 3,
{4, 4} => 4
}
@spec calculate_position_ambiguity(String.t(), String.t()) :: position_ambiguity() @spec calculate_position_ambiguity(String.t(), String.t()) :: position_ambiguity()
defp calculate_position_ambiguity(latitude, longitude) do defp calculate_position_ambiguity(latitude, longitude) do
# Count spaces in latitude and longitude
lat_spaces = count_spaces(latitude) lat_spaces = count_spaces(latitude)
lon_spaces = count_spaces(longitude) lon_spaces = count_spaces(longitude)
Map.get(@ambiguity_levels, {lat_spaces, lon_spaces}, 0)
# Position ambiguity is determined by the number of spaces
# 0 = no ambiguity
# 1 = 1/60th of a degree
# 2 = 1/10th of a degree
# 3 = 1 degree
# 4 = 10 degrees
cond do
lat_spaces == 0 and lon_spaces == 0 -> 0
lat_spaces == 1 and lon_spaces == 1 -> 1
lat_spaces == 2 and lon_spaces == 2 -> 2
lat_spaces == 3 and lon_spaces == 3 -> 3
lat_spaces == 4 and lon_spaces == 4 -> 4
true -> 0
end
end end
@spec calculate_compressed_ambiguity(binary()) :: position_ambiguity() @spec calculate_compressed_ambiguity(binary()) :: position_ambiguity()
@ -664,7 +664,7 @@ defmodule Parser do
information_data = information_data =
parse_mic_e_information(information_field, destination_data.longitude_offset) parse_mic_e_information(information_field, destination_data.longitude_offset)
struct(Parser.Types.MicE, %{ struct(MicE, %{
lat_degrees: destination_data.lat_degrees, lat_degrees: destination_data.lat_degrees,
lat_minutes: destination_data.lat_minutes, lat_minutes: destination_data.lat_minutes,
lat_fractional: destination_data.lat_fractional, lat_fractional: destination_data.lat_fractional,
@ -1138,7 +1138,7 @@ defmodule Parser do
# Add non-nil weather values to result # Add non-nil weather values to result
Enum.reduce(weather_values, result, fn {key, value}, acc -> Enum.reduce(weather_values, result, fn {key, value}, acc ->
if value == nil, do: acc, else: Map.put(acc, key, value) if is_nil(value), do: acc, else: Map.put(acc, key, value)
end) end)
end end
@ -1328,25 +1328,19 @@ defmodule Parser do
defp parse_digital_values(values) do defp parse_digital_values(values) do
values values
|> Enum.map(fn value -> |> Enum.map(fn value ->
case value do cond do
"0" -> value == "1" ->
false
"1" ->
true true
binary when is_binary(binary) -> is_binary(value) ->
# Handle binary string format (e.g., "00000000") # Handle binary string format (e.g., "00000000")
binary value
|> String.graphemes() |> String.graphemes()
|> Enum.map(fn |> Enum.map(fn
"0" -> false
"1" -> true "1" -> true
"0" -> false
_ -> nil _ -> nil
end) end)
_ ->
nil
end end
end) end)
|> List.flatten() |> List.flatten()
@ -1461,37 +1455,15 @@ defmodule Parser do
end end
# Third Party Traffic parsing # Third Party Traffic parsing
@spec parse_third_party_traffic(String.t()) :: map() def parse_third_party_traffic(packet) do
def parse_third_party_traffic(third_party_packet) do if count_leading_braces(packet) + 1 > 3 do
# Count the total depth including this level and the one already stripped by parse()
# The } character has already been stripped by parse_data
depth = count_leading_braces(third_party_packet) + 1
# Check if depth exceeds maximum (3 levels)
if depth > 3 do
%{ %{
error: "Maximum tunnel depth exceeded" error: "Maximum tunnel depth exceeded"
} }
else else
# Third party traffic contains a complete APRS packet case parse_tunneled_packet(packet) do
case parse_tunneled_packet(third_party_packet) do
{:ok, parsed_packet} -> {:ok, parsed_packet} ->
# Check for nested tunnels build_third_party_traffic_result(packet, parsed_packet)
case parse_nested_tunnel(third_party_packet) do
{:ok, nested_packet} ->
%{
third_party_packet: nested_packet,
data_type: :third_party_traffic,
raw_data: third_party_packet
}
{:error, _} ->
%{
third_party_packet: parsed_packet,
data_type: :third_party_traffic,
raw_data: third_party_packet
}
end
{:error, reason} -> {:error, reason} ->
%{ %{
@ -1501,15 +1473,46 @@ defmodule Parser do
end end
end end
defp build_third_party_traffic_result(packet, parsed_packet) do
case parse_nested_tunnel(packet) do
{:ok, nested_packet} ->
%{
third_party_packet: nested_packet,
data_type: :third_party_traffic,
raw_data: packet
}
{:error, _} ->
%{
third_party_packet: parsed_packet,
data_type: :third_party_traffic,
raw_data: packet
}
end
end
@spec parse_tunneled_packet(String.t()) :: {:ok, map()} | {:error, String.t()} @spec parse_tunneled_packet(String.t()) :: {:ok, map()} | {:error, String.t()}
defp parse_tunneled_packet(packet) do defp parse_tunneled_packet(packet) do
# Split the packet into header and information field
case String.split(packet, ":", parts: 2) do case String.split(packet, ":", parts: 2) do
[header, information] -> [header, information] ->
# Parse the header (source>destination,digipeaters) parse_tunneled_packet_with_header(header, information)
_ ->
{:error, "Invalid tunneled packet format"}
end
end
defp parse_tunneled_packet_with_header(header, information) do
case parse_tunneled_header(header) do case parse_tunneled_header(header) do
{:ok, header_data} -> {:ok, header_data} ->
# Parse the information field based on its type parse_tunneled_packet_with_information(header_data, information)
{:error, reason} ->
{:error, "Invalid header: #{reason}"}
end
end
defp parse_tunneled_packet_with_information(header_data, information) do
case parse_datatype_safe(information) do case parse_datatype_safe(information) do
{:ok, data_type} -> {:ok, data_type} ->
data_without_type = String.slice(information, 1..-1//1) data_without_type = String.slice(information, 1..-1//1)
@ -1525,14 +1528,6 @@ defmodule Parser do
{:error, reason} -> {:error, reason} ->
{:error, "Invalid data type: #{reason}"} {:error, "Invalid data type: #{reason}"}
end end
{:error, reason} ->
{:error, "Invalid header: #{reason}"}
end
_ ->
{:error, "Invalid tunneled packet format"}
end
end end
@spec parse_tunneled_header(String.t()) :: {:ok, map()} | {:error, String.t()} @spec parse_tunneled_header(String.t()) :: {:ok, map()} | {:error, String.t()}
@ -1600,7 +1595,6 @@ defmodule Parser do
end end
# Add support for multiple levels of tunneling # Add support for multiple levels of tunneling
@spec parse_nested_tunnel(String.t(), non_neg_integer()) :: {:ok, map()} | {:error, String.t()}
defp parse_nested_tunnel(packet, depth \\ 0) do defp parse_nested_tunnel(packet, depth \\ 0) do
cond do cond do
depth > 3 -> depth > 3 ->
@ -1608,24 +1602,8 @@ defmodule Parser do
String.starts_with?(packet, "}") -> String.starts_with?(packet, "}") ->
case parse_network_tunnel(packet) do case parse_network_tunnel(packet) do
{:ok, parsed_packet} -> {:ok, parsed_packet} -> handle_parsed_network_tunnel(parsed_packet, depth)
# Recursively parse any nested tunnels {:error, reason} -> {:error, reason}
case Map.get(parsed_packet, :data_extended) do
%{raw_data: nested_data} when is_binary(nested_data) ->
case parse_nested_tunnel(nested_data, depth + 1) do
{:ok, nested_packet} ->
{:ok, Map.put(parsed_packet, :nested_packet, nested_packet)}
{:error, _} ->
{:ok, parsed_packet}
end
_ ->
{:ok, parsed_packet}
end
{:error, reason} ->
{:error, reason}
end end
true -> true ->
@ -1633,6 +1611,19 @@ defmodule Parser do
end end
end end
defp handle_parsed_network_tunnel(parsed_packet, depth) do
case Map.get(parsed_packet, :data_extended) do
%{raw_data: nested_data} when is_binary(nested_data) ->
case parse_nested_tunnel(nested_data, depth + 1) do
{:ok, nested_packet} -> {:ok, Map.put(parsed_packet, :nested_packet, nested_packet)}
{:error, _} -> {:ok, parsed_packet}
end
_ ->
{:ok, parsed_packet}
end
end
# PHG Data parsing (Power, Height, Gain, Directivity) # PHG Data parsing (Power, Height, Gain, Directivity)
def parse_phg_data(<<"PHG", p, h, g, d, rest::binary>>) do def parse_phg_data(<<"PHG", p, h, g, d, rest::binary>>) do
%{ %{
@ -1840,7 +1831,10 @@ defmodule Parser do
end end
end end
@spec parse_nmea_sentence(String.t()) :: {:ok, map()} | {:error, String.t()} @spec parse_nmea_sentence(String.t()) ::
{:ok, map()}
| {:error, String.t()}
| {:error, %{error: String.t(), nmea_type: String.t() | nil}}
defp parse_nmea_sentence(sentence) do defp parse_nmea_sentence(sentence) do
# Validate NMEA sentence format # Validate NMEA sentence format
with {:ok, sentence_type} <- validate_nmea_sentence(sentence), with {:ok, sentence_type} <- validate_nmea_sentence(sentence),

View file

@ -8,7 +8,7 @@ defmodule Parser.Position do
@doc """ @doc """
Parse an uncompressed APRS position string. Returns a Position struct or nil. Parse an uncompressed APRS position string. Returns a Position struct or nil.
""" """
@spec parse(String.t()) :: Position | nil @spec parse(String.t()) :: Position.t() | nil
def parse(position_str) do def parse(position_str) do
# Example: "4903.50N/07201.75W>comment" # Example: "4903.50N/07201.75W>comment"
case position_str do case position_str do
@ -67,19 +67,19 @@ defmodule Parser.Position do
%{latitude: lat, longitude: lon} %{latitude: lat, longitude: lon}
end end
@ambiguity_levels %{
{0, 0} => 0,
{1, 1} => 1,
{2, 2} => 2,
{3, 3} => 3,
{4, 4} => 4
}
@doc false @doc false
def calculate_position_ambiguity(latitude, longitude) do def calculate_position_ambiguity(latitude, longitude) do
lat_spaces = count_spaces(latitude) lat_spaces = count_spaces(latitude)
lon_spaces = count_spaces(longitude) lon_spaces = count_spaces(longitude)
Map.get(@ambiguity_levels, {lat_spaces, lon_spaces}, 0)
cond do
lat_spaces == 0 and lon_spaces == 0 -> 0
lat_spaces == 1 and lon_spaces == 1 -> 1
lat_spaces == 2 and lon_spaces == 2 -> 2
lat_spaces == 3 and lon_spaces == 3 -> 3
lat_spaces == 4 and lon_spaces == 4 -> 4
true -> 0
end
end end
@doc false @doc false

View file

@ -3,8 +3,13 @@ defmodule Parser.Types do
Core types and structs for APRS parser. Core types and structs for APRS parser.
""" """
alias Parser.Types.MicE
@type mice :: MicE.t()
defmodule Packet do defmodule Packet do
@moduledoc false @moduledoc false
@type t :: %__MODULE__{}
defstruct [ defstruct [
:id, :id,
:sender, :sender,
@ -21,6 +26,7 @@ defmodule Parser.Types do
defmodule Position do defmodule Position do
@moduledoc false @moduledoc false
@type t :: %__MODULE__{}
defstruct [ defstruct [
:latitude, :latitude,
:longitude, :longitude,
@ -73,6 +79,7 @@ defmodule Parser.Types do
defmodule ParseError do defmodule ParseError do
@moduledoc false @moduledoc false
@type t :: %__MODULE__{}
defstruct [ defstruct [
:error_code, :error_code,
:error_message, :error_message,