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,