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,21 +115,20 @@ 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
case :unicode.characters_to_binary(binary, :latin1, :utf8) do
result when is_binary(result) ->
result
_ -> defp try_convert_utf8(binary) do
# Last resort: keep only ASCII case :unicode.characters_to_binary(binary, :latin1, :utf8) do
binary result when is_binary(result) ->
|> :binary.bin_to_list() result
|> Enum.filter(fn byte -> byte >= 32 and byte <= 126 end)
|> :binary.list_to_bin() _ ->
end # Last resort: keep only ASCII
binary
|> :binary.bin_to_list()
|> Enum.filter(fn byte -> byte >= 32 and byte <= 126 end)
|> :binary.list_to_bin()
end end
end end

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 = Logger.debug("Packet has nil data_extended: #{inspect(packet.sender)}")
case packet do false
%{data_extended: nil} -> end
Logger.debug("Packet has nil data_extended: #{inspect(packet.sender)}")
false
%{data_extended: %{latitude: lat, longitude: lon}} defp check_lat_lon_coords(lat, lon) do
when not is_nil(lat) and not is_nil(lon) -> valid = are_valid_coords?(lat, lon)
# Check if coordinates are valid numbers
valid = are_valid_coords?(lat, lon)
if !valid do if !valid do
Logger.debug("Invalid coordinates: lat=#{inspect(lat)}, lon=#{inspect(lon)}") require Logger
end
valid Logger.debug("Invalid coordinates: lat=#{inspect(lat)}, lon=#{inspect(lon)}")
end
%{data_extended: %MicE{} = mic_e} -> valid
# MicE packets have lat/lon in separate components end
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 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
_other -> defp log_and_return_unrecognized(packet) do
Logger.debug("Unrecognized packet format: #{inspect(Map.keys(packet))}") require Logger
false
end
result 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,20 +367,24 @@ 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] ->
keys update_attrs_with_weather_matches(attrs, keys, matches)
|> Enum.zip(matches)
|> Enum.reduce(attrs, fn {key, value}, acc ->
case Integer.parse(value) do
{int_val, _} -> Map.put(acc, key, int_val)
:error -> acc
end
end)
_ -> _ ->
attrs attrs
end end
end end
defp update_attrs_with_weather_matches(attrs, keys, matches) do
keys
|> Enum.zip(matches)
|> Enum.reduce(attrs, fn {key, value}, acc ->
case Integer.parse(value) do
{int_val, _} -> Map.put(acc, key, int_val)
:error -> acc
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
defp maybe_put(map, _key, nil), do: map defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, _key, ""), do: map defp maybe_put(map, _key, ""), do: map
@ -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,48 +144,45 @@ 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 if not is_nil(packet_data[:lat]) and not is_nil(packet_data[:lon]) do
not is_nil(packet_data[:lat]) and not is_nil(packet_data[:lon]) -> {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])
# Check data_extended struct or map
not is_nil(packet_data[:data_extended]) ->
data_extended = packet_data.data_extended
cond do
# Standard position format
is_map(data_extended) and not is_nil(data_extended[:latitude]) and
not is_nil(data_extended[:longitude]) ->
{to_float(data_extended[:latitude]), to_float(data_extended[:longitude])}
# MicE packet format with components
is_map(data_extended) and data_extended.__struct__ == Parser.Types.MicE ->
mic_e = data_extended
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
# Convert MicE components to decimal degrees
lat = mic_e.lat_degrees + mic_e.lat_minutes / 60.0
lat = if mic_e.lat_direction == :south, do: -lat, else: lat
lon = mic_e.lon_degrees + mic_e.lon_minutes / 60.0
lon = if mic_e.lon_direction == :west, do: -lon, else: lon
{lat, lon}
else
{nil, nil}
end
true ->
{nil, nil}
end
true ->
{nil, nil}
end end
end end
defp extract_position_from_data_extended(nil), do: {nil, nil}
defp extract_position_from_data_extended(data_extended) when is_map(data_extended) do
# Standard position format
if not is_nil(data_extended[:latitude]) and not is_nil(data_extended[:longitude]) do
{to_float(data_extended[:latitude]), to_float(data_extended[:longitude])}
# MicE packet format with components
else
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
is_number(mic_e.lon_degrees) and is_number(mic_e.lon_minutes) do
lat = mic_e.lat_degrees + mic_e.lat_minutes / 60.0
lat = if mic_e.lat_direction == :south, do: -lat, else: lat
lon = mic_e.lon_degrees + mic_e.lon_minutes / 60.0
lon = if mic_e.lon_direction == :west, do: -lon, else: lon
{lat, lon}
else
{nil, nil}
end
end
defp extract_position_from_mic_e(_), do: {nil, nil}
@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,61 +313,67 @@ 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)
if packet do
packet_data = build_packet_data(packet)
if packet_data do
# Add to historical packets map
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))
# Schedule next packet
delay = trunc(1000 / socket.assigns.replay_speed)
timer_ref = Process.send_after(self(), :replay_next_packet, delay)
socket =
assign(socket,
replay_index: socket.assigns.replay_index + 1,
replay_timer_ref: timer_ref,
historical_packets: historical_packets
)
{: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 else
{:noreply, socket} {:noreply, socket}
end end
end end
defp should_continue_replay?(socket) do
socket.assigns.replay_active and
not socket.assigns.replay_paused and
socket.assigns.replay_index < length(socket.assigns.replay_packets)
end
defp handle_replay_packet(nil, socket) do
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)
socket = push_event(socket, "historical_packet", Map.put(packet_data, :historical, true))
delay = trunc(1000 / socket.assigns.replay_speed)
timer_ref = Process.send_after(self(), :replay_next_packet, delay)
socket =
assign(socket,
replay_index: socket.assigns.replay_index + 1,
replay_timer_ref: timer_ref,
historical_packets: historical_packets
)
{:noreply, socket}
end
def render(assigns) do def render(assigns) do
~H""" ~H"""
<link <link
@ -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
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
lng = mic_e.lon_degrees + mic_e.lon_minutes / 60.0 + mic_e.lon_fractional / 6000.0 defp get_coordinates_from_mic_e(mic_e) do
lng = if mic_e.lon_direction == :west, do: -lng, else: lng 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
# Validate coordinates are within valid ranges lng = mic_e.lon_degrees + mic_e.lon_minutes / 60.0 + mic_e.lon_fractional / 6000.0
if lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180 do lng = if mic_e.lon_direction == :west, do: -lng, else: lng
{lat, lng} if lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180, do: {lat, lng}, else: {nil, nil}
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()
end
# Try exact match first defp normalize_target_callsign(target_callsign) do
if normalized_packet == normalized_target do target_callsign
true |> String.upcase()
else |> String.trim()
# If target has no SSID, match against base callsign only end
if String.contains?(normalized_target, "-") do
false defp exact_or_base_match?(normalized_packet, normalized_target, packet) do
else 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 String.upcase(String.trim(base_callsign)) == normalized_target
end
true ->
false
end end
end end

View file

@ -66,29 +66,44 @@ 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)
case socket.private[:connect_info][:peer_data][:address] do
{a, b, c, d} -> "#{a}.#{b}.#{c}.#{d}"
{a, b, c, d, e, f, g, h} -> "#{a}:#{b}:#{c}:#{d}:#{e}:#{f}:#{g}:#{h}"
_ -> nil
end
if ip && !String.starts_with?(ip, "127.") && !String.starts_with?(ip, "::1") do if valid_ip_for_geolocation?(ip) do
Task.start(fn -> start_geolocation_task(ip)
try do
get_ip_location(ip)
rescue
_error ->
send(self(), {:ip_location, @default_center})
end
end)
end end
end end
socket socket
end 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
{a, b, c, d} -> "#{a}.#{b}.#{c}.#{d}"
{a, b, c, d, e, f, g, h} -> "#{a}:#{b}:#{c}:#{d}:#{e}:#{f}:#{g}:#{h}"
_ -> nil
end
end
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 ->
try do
get_ip_location(ip)
rescue
_error ->
send(self(), {:ip_location, @default_center})
end
end)
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)
Process.send_after(self(), :initialize_replay, 2000) Process.send_after(self(), :initialize_replay, 2000)
@ -299,124 +314,135 @@ 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} ->
# Process the debounced bounds update
socket = process_bounds_update(map_bounds, socket)
{:noreply, socket}
{:delayed_zoom, %{lat: lat, lng: lng}} -> def handle_info({:delayed_zoom, %{lat: lat, lng: lng}}, socket), do: handle_info_delayed_zoom(lat, lng, socket)
socket = push_event(socket, "zoom_to_location", %{lat: lat, lng: lng, zoom: 12})
{:noreply, socket}
{:ip_location, %{lat: lat, lng: lng}} -> def handle_info({:ip_location, %{lat: lat, lng: lng}}, socket), do: handle_info_ip_location(lat, lng, socket)
# Ensure we're using numeric values for coordinates
lat_float =
cond do
is_binary(lat) -> String.to_float(lat)
is_integer(lat) -> lat / 1.0
true -> lat
end
lng_float = def handle_info(:initialize_replay, socket), do: handle_info_initialize_replay(socket)
cond do def handle_info(:replay_next_packet, socket), do: handle_replay_next_packet(socket)
is_binary(lng) -> String.to_float(lng) def handle_info(:cleanup_old_packets, socket), do: handle_cleanup_old_packets(socket)
is_integer(lng) -> lng / 1.0
true -> lng
end
# Update map center first def handle_info({:postgres_packet, packet}, socket), do: handle_info_postgres_packet(packet, socket)
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 def handle_info(:flush_packet_buffer, socket), do: handle_info_flush_packet_buffer(socket)
socket =
if socket.assigns.map_ready do
push_event(socket, "zoom_to_location", %{lat: lat_float, lng: lng_float, zoom: 12})
else
assign(socket, pending_geolocation: %{lat: lat_float, lng: lng_float})
end
{:noreply, socket} def handle_info(%Phoenix.Socket.Broadcast{topic: "aprs_messages", event: "packet", payload: packet}, socket),
do: handle_info({:postgres_packet, packet}, socket)
:initialize_replay -> # Private handler functions for each message type
# Only start replay if it hasn't been started yet
if not socket.assigns.replay_started and socket.assigns.map_ready do defp handle_info_process_bounds_update(map_bounds, socket) do
socket = start_historical_replay(socket) socket = process_bounds_update(map_bounds, socket)
{:noreply, assign(socket, replay_started: true)} {:noreply, socket}
end
defp handle_info_delayed_zoom(lat, lng, socket) do
socket = push_event(socket, "zoom_to_location", %{lat: lat, lng: lng, zoom: 12})
{:noreply, socket}
end
defp handle_info_ip_location(lat, lng, socket) do
lat_float =
cond do
is_binary(lat) -> String.to_float(lat)
is_integer(lat) -> lat / 1.0
true -> lat
end
lng_float =
cond do
is_binary(lng) -> String.to_float(lng)
is_integer(lng) -> lng / 1.0
true -> lng
end
socket = assign(socket, map_center: %{lat: lat_float, lng: lng_float}, map_zoom: 12)
socket =
if socket.assigns.map_ready do
push_event(socket, "zoom_to_location", %{lat: lat_float, lng: lng_float, zoom: 12})
else
assign(socket, pending_geolocation: %{lat: lat_float, lng: lng_float})
end
{:noreply, socket}
end
defp handle_info_initialize_replay(socket) do
if not socket.assigns.replay_started and socket.assigns.map_ready do
socket = start_historical_replay(socket)
{:noreply, assign(socket, replay_started: true)}
else
{:noreply, socket}
end
end
defp handle_info_postgres_packet(packet, socket) do
{lat, lon, _data_extended} = get_coordinates(packet)
if is_nil(lat) or is_nil(lon),
do: {:noreply, socket},
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
buffer = [packet | socket.assigns.packet_buffer]
socket = assign(socket, packet_buffer: buffer)
socket =
if socket.assigns.buffer_timer == nil do
timer = Process.send_after(self(), :flush_packet_buffer, @debounce_interval)
assign(socket, buffer_timer: timer)
else else
{:noreply, socket} socket
end end
:replay_next_packet -> {:noreply, socket}
handle_replay_next_packet(socket) else
{:noreply, socket}
end
end
:cleanup_old_packets -> defp handle_info_flush_packet_buffer(socket) do
# Clean up packets older than 1 hour from the map display packets = Enum.reverse(socket.assigns.packet_buffer)
handle_cleanup_old_packets(socket) visible_packets = socket.assigns.visible_packets
{:postgres_packet, packet} -> {new_visible_packets, events} = process_packet_buffer(packets, visible_packets)
{lat, lon, _data_extended} = get_coordinates(packet)
if is_nil(lat) or is_nil(lon) do socket =
{:noreply, socket} Enum.reduce(events, socket, fn {:new_packet, data}, acc ->
else push_event(acc, "new_packet", data)
if within_bounds?(%{lat: lat, lon: lon}, socket.assigns.map_bounds) do end)
# Add to buffer for debounced batch update
buffer = [packet | socket.assigns.packet_buffer]
socket = assign(socket, packet_buffer: buffer)
# If no timer, start one
socket =
if socket.assigns.buffer_timer == nil do
timer = Process.send_after(self(), :flush_packet_buffer, @debounce_interval)
assign(socket, buffer_timer: timer)
else
socket
end
{:noreply, socket} socket =
else assign(socket,
{:noreply, socket} visible_packets: new_visible_packets,
end packet_buffer: [],
end buffer_timer: nil
)
:flush_packet_buffer -> {:noreply, socket}
packets = Enum.reverse(socket.assigns.packet_buffer) end
visible_packets = socket.assigns.visible_packets
{new_visible_packets, events} = defp process_packet_buffer([], visible_packets), do: {visible_packets, []}
Enum.reduce(packets, {visible_packets, []}, fn packet, {vis, evs} ->
packet_data = build_packet_data(packet)
if packet_data do defp process_packet_buffer([packet | rest], visible_packets) do
callsign_key = {vis, evs} = process_packet_buffer(rest, visible_packets)
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]} case build_packet_data(packet) do
else nil ->
{vis, evs} {vis, evs}
end
end)
# Push all new packets in one event (or as a batch) packet_data ->
socket = callsign_key =
Enum.reduce(events, socket, fn {:new_packet, data}, acc -> if Map.has_key?(packet, "id"),
push_event(acc, "new_packet", data) do: to_string(packet["id"]),
end) else: System.unique_integer([:positive])
socket = {Map.put(vis, callsign_key, packet), [{:new_packet, packet_data} | evs]}
assign(socket,
visible_packets: new_visible_packets,
packet_buffer: [],
buffer_timer: nil
)
{:noreply, socket}
%Phoenix.Socket.Broadcast{topic: "aprs_messages", event: "packet", payload: packet} ->
handle_info({:postgres_packet, packet}, socket)
end end
end end
@ -430,67 +456,61 @@ 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)
packet = Enum.at(packets, index)
next_index = index + 1
# Convert to a simple map structure for JSON encoding
packet_data = build_packet_data(packet)
# Only process packets with valid position data
socket =
if packet_data do
# Add is_historical flag and timestamp
packet_data =
Map.merge(packet_data, %{
"is_historical" => true,
"timestamp" =>
if(Map.has_key?(packet, :received_at),
do: DateTime.to_iso8601(packet.received_at)
)
})
# Generate a unique key for this packet
packet_id =
"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)
# Send packet data to client
socket
|> push_event("historical_packet", packet_data)
|> assign(
replay_index: next_index,
historical_packets: new_historical_packets
)
else
# Skip packets without position data, just advance the index
assign(socket, replay_index: next_index)
end
# Calculate delay for next packet (faster based on replay speed)
delay_ms = trunc(1000 / speed)
# Schedule the next packet
timer_ref = Process.send_after(self(), :replay_next_packet, delay_ms)
{:noreply, assign(socket, replay_timer_ref: timer_ref)}
else else
# All packets replayed, start over with a short delay handle_replay_end(socket)
Process.send_after(self(), :initialize_replay, 10_000)
# Set active to false but don't clear packets - will auto-restart
socket =
assign(socket,
replay_active: false,
replay_timer_ref: nil,
replay_started: false
)
{:noreply, socket}
end end
end end
defp handle_next_replay_packet(socket, packets, index, speed, historical_packets) do
packet = Enum.at(packets, index)
next_index = index + 1
packet_data = build_packet_data(packet)
socket =
if packet_data do
packet_data =
Map.merge(packet_data, %{
"is_historical" => true,
"timestamp" =>
if(Map.has_key?(packet, :received_at),
do: DateTime.to_iso8601(packet.received_at)
)
})
packet_id =
"hist_#{if Map.has_key?(packet, :id), do: packet.id, else: System.unique_integer([:positive])}"
new_historical_packets = Map.put(historical_packets, packet_id, packet)
socket
|> push_event("historical_packet", packet_data)
|> assign(
replay_index: next_index,
historical_packets: new_historical_packets
)
else
assign(socket, replay_index: next_index)
end
delay_ms = trunc(1000 / speed)
timer_ref = Process.send_after(self(), :replay_next_packet, delay_ms)
{:noreply, assign(socket, replay_timer_ref: timer_ref)}
end
defp handle_replay_end(socket) do
Process.send_after(self(), :initialize_replay, 10_000)
socket =
assign(socket,
replay_active: false,
replay_timer_ref: nil,
replay_started: false
)
{:noreply, socket}
end
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
@ -647,59 +667,62 @@ 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)
packets_to_remove =
socket.assigns.visible_packets
|> Enum.reject(fn {_key, packet} ->
packet_within_time_threshold?(packet, one_hour_ago) &&
within_bounds?(packet, socket.assigns.map_bounds)
end)
|> Enum.map(fn {callsign, _packet} -> callsign end)
# Filter out packets older than one hour and outside bounds
visible_packets =
socket.assigns.visible_packets
|> Enum.filter(fn {_key, packet} ->
packet_within_time_threshold?(packet, one_hour_ago) &&
within_bounds?(packet, socket.assigns.map_bounds)
end)
|> Map.new()
# Only update if there are actual changes to prevent unnecessary events
socket = socket =
if map_size(visible_packets) == map_size(socket.assigns.visible_packets) do 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) 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 else
socket = update_socket_for_removed_packets(
socket socket,
|> push_event("refresh_markers", %{}) visible_packets,
|> assign( one_hour_ago,
visible_packets: visible_packets, packets_to_remove
packet_age_threshold: one_hour_ago )
)
if Enum.any?(packets_to_remove) do
Enum.reduce(packets_to_remove, socket, fn callsign, acc_socket ->
push_event(acc_socket, "remove_marker", %{id: callsign})
end)
else
socket
end
end end
# Schedule the next cleanup in 1 minute
if connected?(socket), do: Process.send_after(self(), :cleanup_old_packets, 60_000) if connected?(socket), do: Process.send_after(self(), :cleanup_old_packets, 60_000)
{:noreply, socket} {:noreply, socket}
end end
defp get_packets_to_remove(socket, one_hour_ago) do
socket.assigns.visible_packets
|> Enum.reject(fn {_key, packet} ->
packet_within_time_threshold?(packet, one_hour_ago) &&
within_bounds?(packet, socket.assigns.map_bounds)
end)
|> Enum.map(fn {callsign, _packet} -> callsign end)
end
defp get_visible_packets(socket, one_hour_ago) do
socket.assigns.visible_packets
|> Enum.filter(fn {_key, packet} ->
packet_within_time_threshold?(packet, one_hour_ago) &&
within_bounds?(packet, socket.assigns.map_bounds)
end)
|> Map.new()
end
defp update_socket_for_removed_packets(socket, visible_packets, one_hour_ago, packets_to_remove) do
socket =
socket
|> push_event("refresh_markers", %{})
|> assign(
visible_packets: visible_packets,
packet_age_threshold: one_hour_ago
)
if Enum.any?(packets_to_remove) do
Enum.reduce(packets_to_remove, socket, fn callsign, acc_socket ->
push_event(acc_socket, "remove_marker", %{id: callsign})
end)
else
socket
end
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)
case Jason.decode(body) do {:ok, _response} -> send_default_ip_location()
{:ok, %{"status" => "success", "lat" => lat, "lon" => lng}} {:error, %{reason: :timeout}} -> send_default_ip_location()
when is_number(lat) and is_number(lng) -> {:error, _error} -> send_default_ip_location()
if lat >= -90 and lat <= 90 and lng >= -180 and lng <= 180 do
# Force numeric values
lat_float = lat / 1.0
lng_float = lng / 1.0
send(self(), {:ip_location, %{lat: lat_float, lng: lng_float}})
else
send(self(), {:ip_location, @default_center})
end
{:ok, %{"status" => _status}} ->
send(self(), {:ip_location, @default_center})
{:ok, _data} ->
send(self(), {:ip_location, @default_center})
{:error, _decode_error} ->
send(self(), {:ip_location, @default_center})
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 handle_ip_api_response(body) do
case Jason.decode(body) do
{:ok, %{"status" => "success", "lat" => lat, "lon" => lng}}
when is_number(lat) and is_number(lng) ->
if lat >= -90 and lat <= 90 and lng >= -180 and lng <= 180 do
lat_float = lat / 1.0
lng_float = lng / 1.0
send(self(), {:ip_location, %{lat: lat_float, lng: lng_float}})
else
send_default_ip_location()
end
{:ok, _} ->
send_default_ip_location()
{:error, _decode_error} ->
send_default_ip_location()
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,38 +292,33 @@ 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, <<"PHG", p, h, g, d, rest::binary>>) when byte_size(rest) >= 0 do
%{
power: parse_phg_power(p),
height: parse_phg_height(h),
gain: parse_phg_gain(g),
directivity: parse_phg_directivity(d),
comment: rest,
data_type: :phg_data
}
end
def parse_data(:phg_data, _destination, <<"DFS", s, h, g, d, rest::binary>>) when byte_size(rest) >= 0 do
%{
df_strength: parse_df_strength(s),
height: parse_phg_height(h),
gain: parse_phg_gain(g),
directivity: parse_phg_directivity(d),
comment: rest,
data_type: :df_report
}
end
def parse_data(:phg_data, _destination, data) do def parse_data(:phg_data, _destination, data) do
cond do %{
String.starts_with?(data, "PHG") and byte_size(data) >= 7 -> phg_data: data,
<<"PHG", p, h, g, d, rest::binary>> = data data_type: :phg_data
}
%{
power: parse_phg_power(p),
height: parse_phg_height(h),
gain: parse_phg_gain(g),
directivity: parse_phg_directivity(d),
comment: rest,
data_type: :phg_data
}
String.starts_with?(data, "DFS") and byte_size(data) >= 7 ->
<<"DFS", s, h, g, d, rest::binary>> = data
%{
df_strength: parse_df_strength(s),
height: parse_phg_height(h),
gain: parse_phg_gain(g),
directivity: parse_phg_directivity(d),
comment: rest,
data_type: :df_report
}
true ->
%{
phg_data: 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)
@ -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,40 +1473,63 @@ 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)
case parse_tunneled_header(header) do
{:ok, header_data} ->
# Parse the information field based on its type
case parse_datatype_safe(information) do
{:ok, data_type} ->
data_without_type = String.slice(information, 1..-1//1)
data_extended = parse_data(data_type, header_data.destination, data_without_type)
{:ok,
Map.merge(header_data, %{
information_field: information,
data_type: data_type,
data_extended: data_extended
})}
{:error, reason} ->
{:error, "Invalid data type: #{reason}"}
end
{:error, reason} ->
{:error, "Invalid header: #{reason}"}
end
_ -> _ ->
{:error, "Invalid tunneled packet format"} {:error, "Invalid tunneled packet format"}
end end
end end
defp parse_tunneled_packet_with_header(header, information) do
case parse_tunneled_header(header) do
{:ok, header_data} ->
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
{:ok, data_type} ->
data_without_type = String.slice(information, 1..-1//1)
data_extended = parse_data(data_type, header_data.destination, data_without_type)
{:ok,
Map.merge(header_data, %{
information_field: information,
data_type: data_type,
data_extended: data_extended
})}
{:error, reason} ->
{:error, "Invalid data type: #{reason}"}
end
end
@spec parse_tunneled_header(String.t()) :: {:ok, map()} | {:error, String.t()} @spec parse_tunneled_header(String.t()) :: {:ok, map()} | {:error, String.t()}
defp parse_tunneled_header(header) do defp parse_tunneled_header(header) do
case String.split(header, ">", parts: 2) do case String.split(header, ">", parts: 2) do
@ -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,