tons of refactoring

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

View file

@ -14,8 +14,10 @@ defmodule Aprs.BadPacket do
timestamps(type: :utc_datetime_usec)
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])

View file

@ -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, [

View file

@ -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 """

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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"""
<link
@ -735,29 +748,16 @@ defmodule AprsWeb.MapLive.CallsignView do
end
end
defp get_coordinates(packet) do
case packet.data_extended do
%MicE{} = mic_e ->
# 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

View file

@ -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

View file

@ -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

View file

@ -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
<<time::binary-size(7), latitude::binary-size(8), sym_table_id::binary-size(1), longitude::binary-size(9)>> ->
%{latitude: lat, longitude: lon} = parse_aprs_position(latitude, longitude)
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
<<latitude::binary-size(8), sym_table_id::binary-size(1), longitude::binary-size(9), symbol_code::binary-size(1),
comment::binary>> ->
@ -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),

View file

@ -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

View file

@ -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,