From c1a4bb736cac4600dbf9cb24ebf481904dd82e25 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 19 Jun 2025 12:57:13 -0500 Subject: [PATCH] tons of refactoring --- lib/aprs/bad_packet.ex | 4 +- lib/aprs/data_extended.ex | 4 +- lib/aprs/device_identification.ex | 62 ++- lib/aprs/encoding_utils.ex | 27 +- lib/aprs/is/is.ex | 60 ++- lib/aprs/packet.ex | 86 +-- lib/aprs/packets.ex | 78 ++- lib/aprs_web/helpers/aprs_symbols.ex | 363 ++++++------- lib/aprs_web/live/map_live/callsign_view.ex | 221 ++++---- lib/aprs_web/live/map_live/index.ex | 509 +++++++++--------- .../live/packets_live/callsign_view.ex | 49 +- lib/parser.ex | 292 +++++----- lib/parser/position.ex | 20 +- lib/parser/types.ex | 7 + 14 files changed, 909 insertions(+), 873 deletions(-) diff --git a/lib/aprs/bad_packet.ex b/lib/aprs/bad_packet.ex index f75cbf0..ba13b2c 100644 --- a/lib/aprs/bad_packet.ex +++ b/lib/aprs/bad_packet.ex @@ -14,8 +14,10 @@ defmodule Aprs.BadPacket do timestamps(type: :utc_datetime_usec) end + @type t :: %__MODULE__{} + @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 bad_packet |> cast(attrs, [:raw_packet, :error_message, :error_type, :attempted_at]) diff --git a/lib/aprs/data_extended.ex b/lib/aprs/data_extended.ex index e662d64..d37246e 100644 --- a/lib/aprs/data_extended.ex +++ b/lib/aprs/data_extended.ex @@ -16,8 +16,10 @@ defmodule Aprs.DataExtended do field :symbol_table_id, :string end + @type t :: %__MODULE__{} + @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 data_extended |> cast(attrs, [ diff --git a/lib/aprs/device_identification.ex b/lib/aprs/device_identification.ex index b836625..d483374 100644 --- a/lib/aprs/device_identification.ex +++ b/lib/aprs/device_identification.ex @@ -3,44 +3,42 @@ defmodule Aprs.DeviceIdentification do 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 """ 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. """ @spec identify_device(String.t()) :: String.t() def identify_device(symbols) do - case symbols do - # Original MIC-E devices - " " <> <<0, 0>> -> "Original MIC-E" - # 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 + Enum.find_value(@device_patterns, "Unknown", fn {regex, name} -> + if Regex.match?(regex, symbols), do: name + end) end @doc """ diff --git a/lib/aprs/encoding_utils.ex b/lib/aprs/encoding_utils.ex index 852e6c1..9471781 100644 --- a/lib/aprs/encoding_utils.ex +++ b/lib/aprs/encoding_utils.ex @@ -115,21 +115,20 @@ defmodule Aprs.EncodingUtils do # Ensure the final result is valid UTF-8 @spec ensure_valid_utf8(binary()) :: binary() defp ensure_valid_utf8(binary) do - if String.valid?(binary) do - binary - 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 + if String.valid?(binary), do: binary, else: try_convert_utf8(binary) + 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 + defp try_convert_utf8(binary) do + case :unicode.characters_to_binary(binary, :latin1, :utf8) do + result when is_binary(result) -> + result + + _ -> + # 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 diff --git a/lib/aprs/is/is.ex b/lib/aprs/is/is.ex index d0c47ff..56c02af 100644 --- a/lib/aprs/is/is.ex +++ b/lib/aprs/is/is.ex @@ -155,7 +155,8 @@ defmodule Aprs.Is do # 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 # Additional safeguard: prevent connections in test environment if Application.get_env(:aprs, :env) == :test or @@ -169,7 +170,8 @@ defmodule Aprs.Is do 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 login_string = "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 @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 - result = - case packet do - %{data_extended: nil} -> - Logger.debug("Packet has nil data_extended: #{inspect(packet.sender)}") - false + Logger.debug("Packet has nil data_extended: #{inspect(packet.sender)}") + false + end - %{data_extended: %{latitude: lat, longitude: lon}} - when not is_nil(lat) and not is_nil(lon) -> - # Check if coordinates are valid numbers - valid = are_valid_coords?(lat, lon) + defp check_lat_lon_coords(lat, lon) do + valid = are_valid_coords?(lat, lon) - if !valid do - Logger.debug("Invalid coordinates: lat=#{inspect(lat)}, lon=#{inspect(lon)}") - end + if !valid do + require Logger - valid + Logger.debug("Invalid coordinates: lat=#{inspect(lat)}, lon=#{inspect(lon)}") + end - %{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 + end - 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 -> - Logger.debug("Unrecognized packet format: #{inspect(Map.keys(packet))}") - false - end + defp log_and_return_unrecognized(packet) do + require Logger - result + Logger.debug("Unrecognized packet format: #{inspect(Map.keys(packet))}") + false end # Helper to validate coordinate values diff --git a/lib/aprs/packet.ex b/lib/aprs/packet.ex index f689afc..6c52810 100644 --- a/lib/aprs/packet.ex +++ b/lib/aprs/packet.ex @@ -60,8 +60,10 @@ defmodule Aprs.Packet do timestamps() end + @type t :: %__MODULE__{} + @doc false - @spec changeset(%Aprs.Packet{}, map()) :: Ecto.Changeset.t() + @spec changeset(Aprs.Packet.t(), map()) :: Ecto.Changeset.t() def changeset(packet, attrs) do # Convert atom data_type to string attrs = normalize_data_type(attrs) @@ -143,7 +145,7 @@ defmodule Aprs.Packet do coords end - if is_valid_coordinates?(lat, lon) do + if valid_coordinates?(lat, lon) do try do location = create_point(lat, lon) @@ -174,7 +176,7 @@ defmodule Aprs.Packet do lat = get_field(changeset, :lat) || get_change(changeset, :lat) 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) else changeset @@ -182,7 +184,7 @@ defmodule Aprs.Packet do end end - defp is_valid_coordinates?(lat, lon) do + defp valid_coordinates?(lat, lon) do is_number(lat) && is_number(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180 @@ -241,6 +243,15 @@ defmodule Aprs.Packet do # Extract data from standard map-based data_extended 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_table_id, @@ -252,21 +263,29 @@ defmodule Aprs.Packet do :aprs_messaging, data_extended[:aprs_messaging?] || data_extended["aprs_messaging?"] ) - |> maybe_put(:temperature, data_extended[:temperature] || data_extended["temperature"]) - |> maybe_put(:humidity, data_extended[:humidity] || data_extended["humidity"]) - |> maybe_put(:wind_speed, data_extended[:wind_speed] || data_extended["wind_speed"]) - |> maybe_put( + end + + defp put_weather_fields(map, data_extended) do + weather_fields = [ + :temperature, + :humidity, + :wind_speed, :wind_direction, - data_extended[:wind_direction] || data_extended["wind_direction"] - ) - |> maybe_put(:wind_gust, data_extended[:wind_gust] || data_extended["wind_gust"]) - |> maybe_put(:pressure, data_extended[:pressure] || data_extended["pressure"]) - |> maybe_put(:rain_1h, data_extended[:rain_1h] || data_extended["rain_1h"]) - |> maybe_put(:rain_24h, data_extended[:rain_24h] || data_extended["rain_24h"]) - |> maybe_put( - :rain_since_midnight, - data_extended[:rain_since_midnight] || data_extended["rain_since_midnight"] - ) + :wind_gust, + :pressure, + :rain_1h, + :rain_24h, + :rain_since_midnight + ] + + Enum.reduce(weather_fields, map, fn field, acc -> + 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( :equipment_type, @@ -275,13 +294,16 @@ defmodule Aprs.Packet do |> maybe_put(:course, data_extended[:course] || data_extended["course"]) |> maybe_put(:speed, data_extended[:speed] || data_extended["speed"]) |> 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(:message_text, data_extended[:message_text] || data_extended["message_text"]) |> maybe_put( :message_number, data_extended[:message_number] || data_extended["message_number"] ) - |> extract_weather_data(data_extended) end # Extract data from MicE packets @@ -345,20 +367,24 @@ defmodule Aprs.Packet do defp maybe_extract_weather_field(attrs, weather_string, regex, keys) do case Regex.run(regex, weather_string) do [_full | matches] -> - 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) + update_attrs_with_weather_matches(attrs, keys, matches) _ -> attrs 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 defp maybe_put(map, _key, nil), 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 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} end end @@ -468,14 +494,14 @@ defmodule Aprs.Packet do @doc """ 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(_), do: nil @doc """ 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(_), do: nil diff --git a/lib/aprs/packets.ex b/lib/aprs/packets.ex index c4bbac7..1198a51 100644 --- a/lib/aprs/packets.ex +++ b/lib/aprs/packets.ex @@ -115,7 +115,8 @@ defmodule Aprs.Packets do * `packet_data` - The original packet data that failed to parse * `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 error_type = case error do @@ -143,48 +144,45 @@ defmodule Aprs.Packets do # Extracts position data from packet, checking various possible locations defp extract_position(packet_data) do - cond do - # Check for lat/lon at top level - not is_nil(packet_data[:lat]) and not is_nil(packet_data[:lon]) -> - {to_float(packet_data.lat), to_float(packet_data.lon)} - - # 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} + # Check for lat/lon at top level + 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)} + else + extract_position_from_data_extended(packet_data[:data_extended]) 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 """ Gets packets for replay. diff --git a/lib/aprs_web/helpers/aprs_symbols.ex b/lib/aprs_web/helpers/aprs_symbols.ex index 528721d..28906bd 100644 --- a/lib/aprs_web/helpers/aprs_symbols.ex +++ b/lib/aprs_web/helpers/aprs_symbols.ex @@ -11,6 +11,182 @@ defmodule AprsWeb.Helpers.AprsSymbols do 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 """ 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() def symbol_description(symbol_table_id, symbol_code) do - case {symbol_table_id, symbol_code} do - # 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 + Map.get(@symbol_descriptions, {symbol_table_id, symbol_code}, "Unknown symbol") end end diff --git a/lib/aprs_web/live/map_live/callsign_view.ex b/lib/aprs_web/live/map_live/callsign_view.ex index ddf77af..9cf8265 100644 --- a/lib/aprs_web/live/map_live/callsign_view.ex +++ b/lib/aprs_web/live/map_live/callsign_view.ex @@ -259,17 +259,23 @@ defmodule AprsWeb.MapLive.CallsignView do "#{sanitized_packet.base_callsign}#{if sanitized_packet.ssid, do: "-#{sanitized_packet.ssid}", else: ""}" # 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 {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 socket = socket |> 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 # Or if this is a new position that's significantly different, update zoom @@ -282,7 +288,8 @@ defmodule AprsWeb.MapLive.CallsignView do 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", %{ lat: last_known_position.lat, lng: last_known_position.lng, @@ -306,61 +313,67 @@ defmodule AprsWeb.MapLive.CallsignView do end defp handle_replay_next_packet(socket) do - if socket.assigns.replay_active and not socket.assigns.replay_paused and - socket.assigns.replay_index < length(socket.assigns.replay_packets) do + if should_continue_replay?(socket) do packet = Enum.at(socket.assigns.replay_packets, socket.assigns.replay_index) - - 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 + handle_replay_packet(packet, socket) else {:noreply, socket} 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 ~H""" - # 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 + defp get_coordinates(%{data_extended: %MicE{} = mic_e}), do: get_coordinates_from_mic_e(mic_e) + defp get_coordinates(%{data_extended: %{latitude: lat, longitude: lon}}), do: {lat, lon} + defp get_coordinates(_), do: {nil, nil} - 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 - - # 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 + 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 = 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 = 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} end defp build_packet_data(packet) do @@ -766,23 +766,9 @@ defmodule AprsWeb.MapLive.CallsignView do if lat && lng do callsign = "#{packet.base_callsign}#{if packet.ssid, do: "-#{packet.ssid}", else: ""}" - symbol_table_id = - case packet.data_extended do - %{symbol_table_id: id} -> id - _ -> "/" - 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 + symbol_table_id = get_symbol_table_id(packet.data_extended) + symbol_code = get_symbol_code(packet.data_extended) + comment = get_comment(packet.data_extended) %{ id: "#{callsign}_#{:os.system_time(:millisecond)}", @@ -798,6 +784,15 @@ defmodule AprsWeb.MapLive.CallsignView do 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(data_extended) do @@ -824,12 +819,16 @@ defmodule AprsWeb.MapLive.CallsignView do end 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 || "" ssid = packet[:ssid] || packet.ssid - # Build the full callsign - packet_callsign = + case_result = case ssid do nil -> base_callsign "" -> base_callsign @@ -837,20 +836,28 @@ defmodule AprsWeb.MapLive.CallsignView do _ -> "#{base_callsign}-#{ssid}" end - # Normalize both callsigns for comparison - normalized_packet = String.upcase(String.trim(packet_callsign)) - normalized_target = String.upcase(String.trim(target_callsign)) + case_result + |> String.upcase() + |> String.trim() + end - # 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 + 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 - end + + true -> + false end end diff --git a/lib/aprs_web/live/map_live/index.ex b/lib/aprs_web/live/map_live/index.ex index b764c86..9db22ea 100644 --- a/lib/aprs_web/live/map_live/index.ex +++ b/lib/aprs_web/live/map_live/index.ex @@ -66,29 +66,44 @@ defmodule AprsWeb.MapLive.Index do @spec maybe_start_geolocation(Socket.t()) :: Socket.t() defp maybe_start_geolocation(socket) do - if Application.get_env(:aprs, :disable_aprs_connection, false) != true do - ip = - 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 geolocation_enabled?() do + ip = extract_ip(socket) - if ip && !String.starts_with?(ip, "127.") && !String.starts_with?(ip, "::1") do - Task.start(fn -> - try do - get_ip_location(ip) - rescue - _error -> - send(self(), {:ip_location, @default_center}) - end - end) + 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 + {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 Process.send_after(self(), :cleanup_old_packets, 60_000) Process.send_after(self(), :initialize_replay, 2000) @@ -299,124 +314,135 @@ defmodule AprsWeb.MapLive.Index do end @impl true - def handle_info(msg, socket) do - case msg do - {:process_bounds_update, map_bounds} -> - # Process the debounced bounds update - socket = process_bounds_update(map_bounds, socket) - {:noreply, socket} + def handle_info({:process_bounds_update, map_bounds}, socket), do: handle_info_process_bounds_update(map_bounds, socket) - {:delayed_zoom, %{lat: lat, lng: lng}} -> - socket = push_event(socket, "zoom_to_location", %{lat: lat, lng: lng, zoom: 12}) - {:noreply, socket} + def handle_info({:delayed_zoom, %{lat: lat, lng: lng}}, socket), do: handle_info_delayed_zoom(lat, lng, socket) - {:ip_location, %{lat: lat, lng: lng}} -> - # 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 + def handle_info({:ip_location, %{lat: lat, lng: lng}}, socket), do: handle_info_ip_location(lat, lng, socket) - lng_float = - cond do - is_binary(lng) -> String.to_float(lng) - is_integer(lng) -> lng / 1.0 - true -> lng - end + 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) - # Update map center first - socket = assign(socket, map_center: %{lat: lat_float, lng: lng_float}, map_zoom: 12) + def handle_info({:postgres_packet, packet}, socket), do: handle_info_postgres_packet(packet, socket) - # If map is ready, zoom to location immediately, otherwise store for later - 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 + def handle_info(:flush_packet_buffer, socket), do: handle_info_flush_packet_buffer(socket) - {: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 -> - # Only start replay if it hasn't been started yet - if not socket.assigns.replay_started and socket.assigns.map_ready do - socket = start_historical_replay(socket) - {:noreply, assign(socket, replay_started: true)} + # Private handler functions for each message type + + defp handle_info_process_bounds_update(map_bounds, socket) do + socket = process_bounds_update(map_bounds, socket) + {: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 - {:noreply, socket} + socket end - :replay_next_packet -> - handle_replay_next_packet(socket) + {:noreply, socket} + else + {:noreply, socket} + end + end - :cleanup_old_packets -> - # Clean up packets older than 1 hour from the map display - handle_cleanup_old_packets(socket) + defp handle_info_flush_packet_buffer(socket) do + packets = Enum.reverse(socket.assigns.packet_buffer) + visible_packets = socket.assigns.visible_packets - {:postgres_packet, packet} -> - {lat, lon, _data_extended} = get_coordinates(packet) + {new_visible_packets, events} = process_packet_buffer(packets, visible_packets) - if is_nil(lat) or is_nil(lon) do - {:noreply, socket} - else - 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] - 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 + socket = + Enum.reduce(events, socket, fn {:new_packet, data}, acc -> + push_event(acc, "new_packet", data) + end) - {:noreply, socket} - else - {:noreply, socket} - end - end + socket = + assign(socket, + visible_packets: new_visible_packets, + packet_buffer: [], + buffer_timer: nil + ) - :flush_packet_buffer -> - packets = Enum.reverse(socket.assigns.packet_buffer) - visible_packets = socket.assigns.visible_packets + {:noreply, socket} + end - {new_visible_packets, events} = - Enum.reduce(packets, {visible_packets, []}, fn packet, {vis, evs} -> - packet_data = build_packet_data(packet) + defp process_packet_buffer([], visible_packets), do: {visible_packets, []} - if packet_data do - callsign_key = - if Map.has_key?(packet, "id"), - do: to_string(packet["id"]), - else: System.unique_integer([:positive]) + defp process_packet_buffer([packet | rest], visible_packets) do + {vis, evs} = process_packet_buffer(rest, visible_packets) - {Map.put(vis, callsign_key, packet), [{:new_packet, packet_data} | evs]} - else - {vis, evs} - end - end) + case build_packet_data(packet) do + nil -> + {vis, evs} - # Push all new packets in one event (or as a batch) - socket = - Enum.reduce(events, socket, fn {:new_packet, data}, acc -> - push_event(acc, "new_packet", data) - end) + packet_data -> + callsign_key = + if Map.has_key?(packet, "id"), + do: to_string(packet["id"]), + else: System.unique_integer([:positive]) - socket = - 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) + {Map.put(vis, callsign_key, packet), [{:new_packet, packet_data} | evs]} end end @@ -430,67 +456,61 @@ defmodule AprsWeb.MapLive.Index do } = socket.assigns if index < length(packets) do - # Get the next packet and advance the index - 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)} + handle_next_replay_packet(socket, packets, index, speed, historical_packets) else - # All packets replayed, start over with a short delay - 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} + handle_replay_end(socket) 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 def render(assigns) do ~H""" @@ -647,59 +667,62 @@ defmodule AprsWeb.MapLive.Index do # Handle cleanup of old packets 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) + 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 = 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 - |> 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 + update_socket_for_removed_packets( + socket, + visible_packets, + one_hour_ago, + packets_to_remove + ) end - # Schedule the next cleanup in 1 minute 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 + |> 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) @spec packet_within_time_threshold?(map(), DateTime.t()) :: boolean() defp packet_within_time_threshold?(packet, threshold) do @@ -810,7 +833,7 @@ defmodule AprsWeb.MapLive.Index do 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 # Safely get data_extended for both atom and string keys 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 defp get_ip_location(nil), do: nil - @spec get_ip_location(String.t()) :: {float(), float()} | nil defp get_ip_location(ip) do url = "#{@ip_api_url}#{ip}" - # Add headers to make the request more likely to succeed request = Finch.build(:get, url, [ {"User-Agent", "APRS.me/1.0"}, {"Accept", "application/json"} ]) - # Add a small delay to ensure the client is connected Process.sleep(2000) case Finch.request(request, @finch_name, receive_timeout: 10_000) do - {:ok, %{status: 200, body: body}} -> - 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 - # 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}) + {: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 + {: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 @spec to_float(number() | String.t()) :: float() defp to_float(value) when is_float(value), do: value diff --git a/lib/aprs_web/live/packets_live/callsign_view.ex b/lib/aprs_web/live/packets_live/callsign_view.ex index c315a7d..151d13c 100644 --- a/lib/aprs_web/live/packets_live/callsign_view.ex +++ b/lib/aprs_web/live/packets_live/callsign_view.ex @@ -53,39 +53,14 @@ defmodule AprsWeb.PacketsLive.CallsignView do @impl true def handle_info(%{event: "packet", payload: payload}, socket) do # 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 - # Sanitize the packet to prevent JSON encoding errors sanitized_payload = EncodingUtils.sanitize_packet(payload) - - # Add to live packets and maintain combined limit of 100 current_live = socket.assigns.live_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} = - if total_count >= 100 do - # 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]} + update_packet_lists(current_stored, current_live, sanitized_payload) - _ -> - # 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) socket = @@ -101,9 +76,7 @@ defmodule AprsWeb.PacketsLive.CallsignView do end @impl true - def handle_info(_message, socket) do - {:noreply, socket} - end + def handle_info(_message, socket), do: {:noreply, socket} # Private helper functions @@ -189,6 +162,24 @@ defmodule AprsWeb.PacketsLive.CallsignView do |> Enum.take(100) 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 defp valid_callsign?(callsign) do # Basic validation for amateur radio callsign format diff --git a/lib/parser.ex b/lib/parser.ex index d8b1c77..01375c5 100644 --- a/lib/parser.ex +++ b/lib/parser.ex @@ -4,6 +4,7 @@ defmodule Parser do """ # import Bitwise alias Aprs.Convert + alias Parser.Types.MicE require Logger @@ -52,7 +53,15 @@ defmodule Parser do @type position_ambiguity :: 0..4 @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), {:ok, [base_callsign, ssid]} <- parse_callsign(sender), {:ok, data_type} <- parse_datatype_safe(data), @@ -61,7 +70,6 @@ defmodule Parser do :ok <- validate_callsign(destination, :dst), :ok <- validate_path(path) do 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_extended = parse_data(data_type, destination, data_without_type) @@ -76,7 +84,6 @@ defmodule Parser do base_callsign: base_callsign, ssid: ssid, data_extended: data_extended, - # Set received_at when creating packet received_at: DateTime.truncate(DateTime.utc_now(), :microsecond) }} else @@ -86,9 +93,6 @@ defmodule Parser do _ -> {:error, reason} end - {:error, %{error_code: code}} -> - {:error, to_string(code)} - _ -> {:error, "PARSE ERROR"} end @@ -110,7 +114,7 @@ defmodule Parser do defp validate_callsign(callsign, :dst) 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"} true -> :ok end @@ -206,7 +210,7 @@ defmodule Parser do def parse_data(:position, _destination, data) do case data do <<"/", _::binary>> -> - result = parse_position_without_timestamp(false, data) + result = parse_position_without_timestamp(data) if result.data_type == :malformed_position do result @@ -215,7 +219,7 @@ defmodule Parser do end _ -> - result = parse_position_without_timestamp(false, data) + result = parse_position_without_timestamp(data) if result.data_type == :malformed_position do result @@ -226,7 +230,7 @@ defmodule Parser do end 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} end @@ -288,38 +292,33 @@ defmodule Parser do 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(: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 - 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), - 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 + %{ + phg_data: data, + data_type: :phg_data + } end 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 <> -> %{latitude: lat, longitude: lon} = parse_aprs_position(latitude, longitude) - 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 end - @spec parse_position_without_timestamp(boolean(), String.t()) :: map() - def parse_position_without_timestamp(aprs_messaging?, position_data) do + # For packets without APRS messaging + @spec parse_position_without_timestamp(String.t()) :: map() + def parse_position_without_timestamp(position_data) do case position_data do <> -> @@ -448,7 +447,7 @@ defmodule Parser do symbol_code: symbol_code, comment: comment, data_type: :position, - aprs_messaging?: aprs_messaging?, + aprs_messaging?: false, compressed?: false, position_ambiguity: ambiguity, dao: dao_data @@ -465,7 +464,7 @@ defmodule Parser do symbol_table_id: sym_table_id, symbol_code: "_", data_type: :position, - aprs_messaging?: aprs_messaging?, + aprs_messaging?: false, compressed?: false, position_ambiguity: ambiguity, dao: nil @@ -490,13 +489,13 @@ defmodule Parser do data_type: :position, compressed?: true, position_ambiguity: ambiguity, - dao: nil + dao: nil, + aprs_messaging?: false } Map.merge(base_data, compressed_cs) rescue _e -> - # Instead of returning malformed_position, relax: return nil lat/lon but still :position %{ latitude: nil, longitude: nil, @@ -508,7 +507,8 @@ defmodule Parser do data_type: :position, compressed?: true, position_ambiguity: calculate_compressed_ambiguity(compression_type), - dao: nil + dao: nil, + aprs_messaging?: false } end @@ -520,7 +520,7 @@ defmodule Parser do symbol_table_id: nil, symbol_code: nil, data_type: :malformed_position, - aprs_messaging?: aprs_messaging?, + aprs_messaging?: false, compressed?: false, comment: String.trim(position_data), dao: nil @@ -528,26 +528,26 @@ defmodule Parser do 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() defp calculate_position_ambiguity(latitude, longitude) do - # Count spaces in latitude and longitude lat_spaces = count_spaces(latitude) lon_spaces = count_spaces(longitude) - - # 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 + Map.get(@ambiguity_levels, {lat_spaces, lon_spaces}, 0) end @spec calculate_compressed_ambiguity(binary()) :: position_ambiguity() @@ -664,7 +664,7 @@ defmodule Parser do information_data = parse_mic_e_information(information_field, destination_data.longitude_offset) - struct(Parser.Types.MicE, %{ + struct(MicE, %{ lat_degrees: destination_data.lat_degrees, lat_minutes: destination_data.lat_minutes, lat_fractional: destination_data.lat_fractional, @@ -1138,7 +1138,7 @@ defmodule Parser do # Add non-nil weather values to result 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 @@ -1328,25 +1328,19 @@ defmodule Parser do defp parse_digital_values(values) do values |> Enum.map(fn value -> - case value do - "0" -> - false - - "1" -> + cond do + value == "1" -> true - binary when is_binary(binary) -> + is_binary(value) -> # Handle binary string format (e.g., "00000000") - binary + value |> String.graphemes() |> Enum.map(fn - "0" -> false "1" -> true + "0" -> false _ -> nil end) - - _ -> - nil end end) |> List.flatten() @@ -1461,37 +1455,15 @@ defmodule Parser do end # Third Party Traffic parsing - @spec parse_third_party_traffic(String.t()) :: map() - def parse_third_party_traffic(third_party_packet) 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 + def parse_third_party_traffic(packet) do + if count_leading_braces(packet) + 1 > 3 do %{ error: "Maximum tunnel depth exceeded" } else - # Third party traffic contains a complete APRS packet - case parse_tunneled_packet(third_party_packet) do + case parse_tunneled_packet(packet) do {:ok, parsed_packet} -> - # Check for nested tunnels - 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 + build_third_party_traffic_result(packet, parsed_packet) {:error, reason} -> %{ @@ -1501,40 +1473,63 @@ defmodule Parser do 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()} defp parse_tunneled_packet(packet) do - # Split the packet into header and information field case String.split(packet, ":", parts: 2) do [header, information] -> - # Parse the header (source>destination,digipeaters) - 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 + 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 + {: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()} defp parse_tunneled_header(header) do case String.split(header, ">", parts: 2) do @@ -1600,7 +1595,6 @@ defmodule Parser do end # 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 cond do depth > 3 -> @@ -1608,24 +1602,8 @@ defmodule Parser do String.starts_with?(packet, "}") -> case parse_network_tunnel(packet) do - {:ok, parsed_packet} -> - # Recursively parse any nested tunnels - 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} + {:ok, parsed_packet} -> handle_parsed_network_tunnel(parsed_packet, depth) + {:error, reason} -> {:error, reason} end true -> @@ -1633,6 +1611,19 @@ defmodule Parser do 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) def parse_phg_data(<<"PHG", p, h, g, d, rest::binary>>) do %{ @@ -1840,7 +1831,10 @@ defmodule Parser do 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 # Validate NMEA sentence format with {:ok, sentence_type} <- validate_nmea_sentence(sentence), diff --git a/lib/parser/position.ex b/lib/parser/position.ex index c46c9f9..dda57b5 100644 --- a/lib/parser/position.ex +++ b/lib/parser/position.ex @@ -8,7 +8,7 @@ defmodule Parser.Position do @doc """ 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 # Example: "4903.50N/07201.75W>comment" case position_str do @@ -67,19 +67,19 @@ defmodule Parser.Position do %{latitude: lat, longitude: lon} end + @ambiguity_levels %{ + {0, 0} => 0, + {1, 1} => 1, + {2, 2} => 2, + {3, 3} => 3, + {4, 4} => 4 + } + @doc false def calculate_position_ambiguity(latitude, longitude) do lat_spaces = count_spaces(latitude) lon_spaces = count_spaces(longitude) - - 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 + Map.get(@ambiguity_levels, {lat_spaces, lon_spaces}, 0) end @doc false diff --git a/lib/parser/types.ex b/lib/parser/types.ex index b2c94ef..eb29981 100644 --- a/lib/parser/types.ex +++ b/lib/parser/types.ex @@ -3,8 +3,13 @@ defmodule Parser.Types do Core types and structs for APRS parser. """ + alias Parser.Types.MicE + + @type mice :: MicE.t() + defmodule Packet do @moduledoc false + @type t :: %__MODULE__{} defstruct [ :id, :sender, @@ -21,6 +26,7 @@ defmodule Parser.Types do defmodule Position do @moduledoc false + @type t :: %__MODULE__{} defstruct [ :latitude, :longitude, @@ -73,6 +79,7 @@ defmodule Parser.Types do defmodule ParseError do @moduledoc false + @type t :: %__MODULE__{} defstruct [ :error_code, :error_message,