tons of refactoring
This commit is contained in:
parent
3fb11706d2
commit
c1a4bb736c
14 changed files with 909 additions and 873 deletions
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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, [
|
||||
|
|
|
|||
|
|
@ -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 """
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
292
lib/parser.ex
292
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
|
||||
<<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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue