misc cleanup
This commit is contained in:
parent
51efdf34a2
commit
cad5f4a380
14 changed files with 755 additions and 624 deletions
|
|
@ -24,126 +24,13 @@ defmodule Aprs.Packets do
|
|||
require Logger
|
||||
|
||||
try do
|
||||
# Convert to map if it's a struct, or use as is if already a map
|
||||
packet_attrs =
|
||||
case packet_data do
|
||||
%Packet{} = packet ->
|
||||
packet
|
||||
|> Map.from_struct()
|
||||
|> Map.delete(:__meta__)
|
||||
|
||||
%{} ->
|
||||
packet_data
|
||||
end
|
||||
|
||||
# Sanitize all string fields to prevent UTF-8 encoding errors
|
||||
packet_attrs = sanitize_packet_strings(packet_attrs)
|
||||
|
||||
# Convert data_type to string if it's an atom
|
||||
packet_attrs =
|
||||
case packet_attrs do
|
||||
%{data_type: data_type} when is_atom(data_type) ->
|
||||
Map.put(packet_attrs, :data_type, to_string(data_type))
|
||||
|
||||
_ ->
|
||||
packet_attrs
|
||||
end
|
||||
|
||||
# Make sure received_at is set with explicit UTC DateTime
|
||||
current_time = DateTime.truncate(DateTime.utc_now(), :microsecond)
|
||||
packet_attrs = Map.put(packet_attrs, :received_at, current_time)
|
||||
|
||||
# Patch: If data_extended has latitude/longitude, set them as lat/lon as Decimal
|
||||
packet_attrs =
|
||||
case packet_attrs[:data_extended] do
|
||||
%{} = ext ->
|
||||
# Convert struct to map if needed
|
||||
ext_map = if Map.has_key?(ext, :__struct__), do: Map.from_struct(ext), else: ext
|
||||
|
||||
lat =
|
||||
ext_map[:latitude] || ext_map["latitude"] ||
|
||||
(Map.has_key?(ext_map, :position) &&
|
||||
(ext_map[:position][:latitude] || ext_map[:position]["latitude"])) ||
|
||||
(Map.has_key?(ext_map, "position") &&
|
||||
(ext_map["position"][:latitude] || ext_map["position"]["latitude"]))
|
||||
|
||||
lon =
|
||||
ext_map[:longitude] || ext_map["longitude"] ||
|
||||
(Map.has_key?(ext_map, :position) &&
|
||||
(ext_map[:position][:longitude] || ext_map[:position]["longitude"])) ||
|
||||
(Map.has_key?(ext_map, "position") &&
|
||||
(ext_map["position"][:longitude] || ext_map["position"]["longitude"]))
|
||||
|
||||
latd = to_decimal(lat)
|
||||
lond = to_decimal(lon)
|
||||
|
||||
if not is_nil(latd) and not is_nil(lond) do
|
||||
packet_attrs
|
||||
|> Map.put(:lat, latd)
|
||||
|> Map.put(:lon, lond)
|
||||
else
|
||||
packet_attrs
|
||||
end
|
||||
|
||||
_ ->
|
||||
packet_attrs
|
||||
end
|
||||
|
||||
# Extract position data with error handling
|
||||
packet_attrs = normalize_packet_attrs(packet_data)
|
||||
packet_attrs = set_received_at(packet_attrs)
|
||||
packet_attrs = patch_lat_lon_from_data_extended(packet_attrs)
|
||||
{lat, lon} = extract_position(packet_attrs)
|
||||
|
||||
# Helper to round to 6 decimal places
|
||||
round6 = fn
|
||||
nil ->
|
||||
nil
|
||||
|
||||
%Decimal{} = d ->
|
||||
Decimal.round(d, 6)
|
||||
|
||||
n when is_float(n) ->
|
||||
Float.round(n, 6)
|
||||
|
||||
n when is_integer(n) ->
|
||||
n * 1.0
|
||||
|
||||
n when is_binary(n) ->
|
||||
case Float.parse(n) do
|
||||
{f, _} -> Float.round(f, 6)
|
||||
:error -> nil
|
||||
end
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
|
||||
# Set position fields if found (this will overwrite above if extract_position finds something different)
|
||||
packet_attrs =
|
||||
packet_attrs
|
||||
|> Map.put(:lat, round6.(lat))
|
||||
|> Map.put(:lon, round6.(lon))
|
||||
|
||||
# Normalize ssid to string if present and not nil
|
||||
packet_attrs =
|
||||
case Map.get(packet_attrs, :ssid) do
|
||||
nil -> packet_attrs
|
||||
ssid -> Map.put(packet_attrs, :ssid, to_string(ssid))
|
||||
end
|
||||
|
||||
# Insert the packet
|
||||
case %Packet{}
|
||||
|> Packet.changeset(packet_attrs)
|
||||
|> Repo.insert() do
|
||||
{:ok, packet} ->
|
||||
{:ok, packet}
|
||||
|
||||
{:error, changeset} ->
|
||||
# Store validation errors as bad packets
|
||||
error_message =
|
||||
Enum.map_join(changeset.errors, ", ", fn {field, {msg, _}} -> "#{field}: #{msg}" end)
|
||||
|
||||
store_bad_packet(packet_data, %{message: error_message, type: "ValidationError"})
|
||||
{:error, :validation_error}
|
||||
end
|
||||
packet_attrs = set_lat_lon(packet_attrs, lat, lon)
|
||||
packet_attrs = normalize_ssid(packet_attrs)
|
||||
insert_packet(packet_attrs, packet_data)
|
||||
rescue
|
||||
error ->
|
||||
Logger.error("Exception in store_packet for #{inspect(packet_data[:sender])}: #{inspect(error)}")
|
||||
|
|
@ -153,6 +40,126 @@ defmodule Aprs.Packets do
|
|||
end
|
||||
end
|
||||
|
||||
defp normalize_packet_attrs(packet_data) do
|
||||
case_result =
|
||||
case packet_data do
|
||||
%Packet{} = packet ->
|
||||
packet
|
||||
|> Map.from_struct()
|
||||
|> Map.delete(:__meta__)
|
||||
|
||||
%{} ->
|
||||
packet_data
|
||||
end
|
||||
|
||||
case_result
|
||||
|> sanitize_packet_strings()
|
||||
|> normalize_data_type()
|
||||
end
|
||||
|
||||
defp normalize_data_type(attrs) do
|
||||
case attrs do
|
||||
%{data_type: data_type} when is_atom(data_type) ->
|
||||
Map.put(attrs, :data_type, to_string(data_type))
|
||||
|
||||
_ ->
|
||||
attrs
|
||||
end
|
||||
end
|
||||
|
||||
defp set_received_at(attrs) do
|
||||
current_time = DateTime.truncate(DateTime.utc_now(), :microsecond)
|
||||
Map.put(attrs, :received_at, current_time)
|
||||
end
|
||||
|
||||
defp patch_lat_lon_from_data_extended(attrs) do
|
||||
case attrs[:data_extended] do
|
||||
%{} = ext ->
|
||||
ext_map = if Map.has_key?(ext, :__struct__), do: Map.from_struct(ext), else: ext
|
||||
lat = extract_lat_from_ext_map(ext_map)
|
||||
lon = extract_lon_from_ext_map(ext_map)
|
||||
latd = to_decimal(lat)
|
||||
lond = to_decimal(lon)
|
||||
|
||||
if not is_nil(latd) and not is_nil(lond) do
|
||||
attrs
|
||||
|> Map.put(:lat, latd)
|
||||
|> Map.put(:lon, lond)
|
||||
else
|
||||
attrs
|
||||
end
|
||||
|
||||
_ ->
|
||||
attrs
|
||||
end
|
||||
end
|
||||
|
||||
defp extract_lat_from_ext_map(ext_map) do
|
||||
ext_map[:latitude] || ext_map["latitude"] ||
|
||||
(Map.has_key?(ext_map, :position) &&
|
||||
(ext_map[:position][:latitude] || ext_map[:position]["latitude"])) ||
|
||||
(Map.has_key?(ext_map, "position") &&
|
||||
(ext_map["position"][:latitude] || ext_map["position"]["latitude"]))
|
||||
end
|
||||
|
||||
defp extract_lon_from_ext_map(ext_map) do
|
||||
ext_map[:longitude] || ext_map["longitude"] ||
|
||||
(Map.has_key?(ext_map, :position) &&
|
||||
(ext_map[:position][:longitude] || ext_map[:position]["longitude"])) ||
|
||||
(Map.has_key?(ext_map, "position") &&
|
||||
(ext_map["position"][:longitude] || ext_map["position"]["longitude"]))
|
||||
end
|
||||
|
||||
defp set_lat_lon(attrs, lat, lon) do
|
||||
round6 = fn
|
||||
nil ->
|
||||
nil
|
||||
|
||||
%Decimal{} = d ->
|
||||
Decimal.round(d, 6)
|
||||
|
||||
n when is_float(n) ->
|
||||
Float.round(n, 6)
|
||||
|
||||
n when is_integer(n) ->
|
||||
n * 1.0
|
||||
|
||||
n when is_binary(n) ->
|
||||
case Float.parse(n) do
|
||||
{f, _} -> Float.round(f, 6)
|
||||
:error -> nil
|
||||
end
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
|
||||
attrs
|
||||
|> Map.put(:lat, round6.(lat))
|
||||
|> Map.put(:lon, round6.(lon))
|
||||
end
|
||||
|
||||
defp normalize_ssid(attrs) do
|
||||
case Map.get(attrs, :ssid) do
|
||||
nil -> attrs
|
||||
ssid -> Map.put(attrs, :ssid, to_string(ssid))
|
||||
end
|
||||
end
|
||||
|
||||
defp insert_packet(attrs, packet_data) do
|
||||
case %Packet{} |> Packet.changeset(attrs) |> Repo.insert() do
|
||||
{:ok, packet} ->
|
||||
{:ok, packet}
|
||||
|
||||
{:error, changeset} ->
|
||||
error_message =
|
||||
Enum.map_join(changeset.errors, ", ", fn {field, {msg, _}} -> "#{field}: #{msg}" end)
|
||||
|
||||
store_bad_packet(packet_data, %{message: error_message, type: "ValidationError"})
|
||||
{:error, :validation_error}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Stores a bad packet in the database.
|
||||
|
||||
|
|
@ -200,46 +207,58 @@ defmodule Aprs.Packets do
|
|||
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
|
||||
if has_standard_position?(data_extended) do
|
||||
extract_standard_position(data_extended)
|
||||
else
|
||||
case data_extended do
|
||||
%{__struct__: MicE} = mic_e ->
|
||||
lat = mic_e[:latitude]
|
||||
lon = mic_e[:longitude]
|
||||
|
||||
if is_number(lat) and is_number(lon) do
|
||||
{lat, lon}
|
||||
else
|
||||
{nil, nil}
|
||||
end
|
||||
|
||||
_ ->
|
||||
extract_position_from_mic_e(data_extended)
|
||||
end
|
||||
extract_position_from_data_extended_case(data_extended)
|
||||
end
|
||||
end
|
||||
|
||||
defp extract_position_from_data_extended(_), do: {nil, nil}
|
||||
|
||||
defp extract_position_from_mic_e(%{__struct__: 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
|
||||
defp has_standard_position?(data_extended) do
|
||||
not is_nil(data_extended[:latitude]) and not is_nil(data_extended[:longitude])
|
||||
end
|
||||
|
||||
lon = mic_e.lon_degrees + mic_e.lon_minutes / 60.0
|
||||
lon = if mic_e.lon_direction == :west, do: -lon, else: lon
|
||||
defp extract_standard_position(data_extended) do
|
||||
{to_float(data_extended[:latitude]), to_float(data_extended[:longitude])}
|
||||
end
|
||||
|
||||
defp extract_position_from_data_extended_case(data_extended) do
|
||||
case data_extended do
|
||||
%{__struct__: MicE} = mic_e ->
|
||||
extract_position_from_mic_e_struct(mic_e)
|
||||
|
||||
_ ->
|
||||
extract_position_from_mic_e(data_extended)
|
||||
end
|
||||
end
|
||||
|
||||
defp extract_position_from_mic_e_struct(mic_e) do
|
||||
lat = mic_e[:latitude]
|
||||
lon = mic_e[:longitude]
|
||||
|
||||
if is_number(lat) and is_number(lon) do
|
||||
{lat, lon}
|
||||
else
|
||||
{nil, nil}
|
||||
end
|
||||
end
|
||||
|
||||
defp extract_position_from_mic_e(_), do: {nil, nil}
|
||||
defp extract_position_from_mic_e(data_extended) do
|
||||
if is_number(data_extended[:lat_degrees]) and is_number(data_extended[:lat_minutes]) and
|
||||
is_number(data_extended[:lon_degrees]) and is_number(data_extended[:lon_minutes]) do
|
||||
lat = data_extended[:lat_degrees] + data_extended[:lat_minutes] / 60.0
|
||||
lat = if data_extended[:lat_direction] == :south, do: -lat, else: lat
|
||||
|
||||
lon = data_extended[:lon_degrees] + data_extended[:lon_minutes] / 60.0
|
||||
lon = if data_extended[:lon_direction] == :west, do: -lon, else: lon
|
||||
|
||||
{lat, lon}
|
||||
else
|
||||
{nil, nil}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets packets for replay.
|
||||
|
|
|
|||
|
|
@ -1049,119 +1049,107 @@ defmodule AprsWeb.MapLive.Index do
|
|||
# Helper function to start historical replay
|
||||
@spec start_historical_replay(Socket.t()) :: Socket.t()
|
||||
defp start_historical_replay(socket) do
|
||||
# Only fetch historical packets if map is ready and bounds are available
|
||||
if socket.assigns.map_ready and socket.assigns.map_bounds do
|
||||
# Get time range for historical data
|
||||
now = DateTime.utc_now()
|
||||
# Use the user's selected historical hours setting
|
||||
historical_hours = String.to_integer(socket.assigns.historical_hours)
|
||||
start_time = DateTime.add(now, -historical_hours * 3600, :second)
|
||||
|
||||
# Convert map bounds to the format expected by the database query
|
||||
bounds = [
|
||||
socket.assigns.map_bounds.west,
|
||||
socket.assigns.map_bounds.south,
|
||||
socket.assigns.map_bounds.east,
|
||||
socket.assigns.map_bounds.north
|
||||
]
|
||||
|
||||
# Fetch historical packets with position data within the current map bounds
|
||||
historical_packets = fetch_historical_packets(bounds, start_time, now)
|
||||
|
||||
if Enum.empty?(historical_packets) do
|
||||
# No historical packets found - silently continue
|
||||
socket
|
||||
else
|
||||
# Clear any previous historical packets from the map
|
||||
socket = push_event(socket, "clear_historical_packets", %{})
|
||||
|
||||
# Group packets by callsign-ssid and process all positions
|
||||
packet_data_list =
|
||||
historical_packets
|
||||
|> Enum.group_by(&generate_callsign/1)
|
||||
|> Enum.flat_map(fn {callsign, packets} ->
|
||||
# Sort packets by inserted_at to identify the most recent
|
||||
# Convert NaiveDateTime to DateTime if needed for proper comparison
|
||||
sorted_packets =
|
||||
Enum.sort_by(
|
||||
packets,
|
||||
fn packet ->
|
||||
case packet.inserted_at do
|
||||
%NaiveDateTime{} = naive_dt ->
|
||||
DateTime.from_naive!(naive_dt, "Etc/UTC")
|
||||
|
||||
%DateTime{} = dt ->
|
||||
dt
|
||||
|
||||
_other ->
|
||||
DateTime.utc_now()
|
||||
end
|
||||
end,
|
||||
{:desc, DateTime}
|
||||
)
|
||||
|
||||
# Filter out packets with unchanged positions (only keep if lat/lon changed)
|
||||
unique_position_packets = filter_unique_positions(sorted_packets)
|
||||
|
||||
# Process all packets with unique positions, marking which is the most recent
|
||||
unique_position_packets
|
||||
|> Enum.with_index()
|
||||
|> Enum.map(fn {packet, index} ->
|
||||
case build_packet_data(packet) do
|
||||
nil ->
|
||||
nil
|
||||
|
||||
packet_data ->
|
||||
# Generate a unique ID for this historical packet
|
||||
packet_id =
|
||||
"hist_#{if Map.has_key?(packet, :id), do: packet.id, else: System.unique_integer([:positive])}_#{index}"
|
||||
|
||||
is_most_recent = index == 0
|
||||
|
||||
packet_data
|
||||
|> Map.put("id", packet_id)
|
||||
|> Map.put("is_historical", true)
|
||||
|> Map.put("is_most_recent_for_callsign", is_most_recent)
|
||||
|> Map.put("callsign_group", callsign)
|
||||
|> Map.put(
|
||||
"timestamp",
|
||||
case packet.inserted_at do
|
||||
%NaiveDateTime{} = naive_dt ->
|
||||
DateTime.to_unix(DateTime.from_naive!(naive_dt, "Etc/UTC"), :millisecond)
|
||||
|
||||
%DateTime{} = dt ->
|
||||
DateTime.to_unix(dt, :millisecond)
|
||||
|
||||
_other ->
|
||||
DateTime.to_unix(DateTime.utc_now(), :millisecond)
|
||||
end
|
||||
)
|
||||
end
|
||||
end)
|
||||
|> Enum.filter(& &1)
|
||||
end)
|
||||
|
||||
# Send all historical packets at once
|
||||
socket = push_event(socket, "add_historical_packets", %{packets: packet_data_list})
|
||||
|
||||
# Store historical packets in assigns for reference
|
||||
historical_packets_map =
|
||||
packet_data_list
|
||||
|> Enum.zip(historical_packets)
|
||||
|> Enum.reduce(%{}, fn {packet_data, packet}, acc ->
|
||||
Map.put(acc, packet_data["id"], packet)
|
||||
end)
|
||||
|
||||
assign(socket,
|
||||
historical_packets: historical_packets_map,
|
||||
historical_loaded: true
|
||||
)
|
||||
end
|
||||
start_historical_replay_with_bounds(socket)
|
||||
else
|
||||
socket
|
||||
end
|
||||
end
|
||||
|
||||
defp start_historical_replay_with_bounds(socket) do
|
||||
now = DateTime.utc_now()
|
||||
historical_hours = String.to_integer(socket.assigns.historical_hours)
|
||||
start_time = DateTime.add(now, -historical_hours * 3600, :second)
|
||||
|
||||
bounds = [
|
||||
socket.assigns.map_bounds.west,
|
||||
socket.assigns.map_bounds.south,
|
||||
socket.assigns.map_bounds.east,
|
||||
socket.assigns.map_bounds.north
|
||||
]
|
||||
|
||||
historical_packets = fetch_historical_packets(bounds, start_time, now)
|
||||
|
||||
if Enum.empty?(historical_packets) do
|
||||
socket
|
||||
else
|
||||
process_historical_packets(socket, historical_packets)
|
||||
end
|
||||
end
|
||||
|
||||
defp process_historical_packets(socket, historical_packets) do
|
||||
socket = push_event(socket, "clear_historical_packets", %{})
|
||||
|
||||
packet_data_list =
|
||||
historical_packets
|
||||
|> Enum.group_by(&generate_callsign/1)
|
||||
|> Enum.flat_map(fn {callsign, packets} ->
|
||||
sorted_packets =
|
||||
Enum.sort_by(
|
||||
packets,
|
||||
fn packet ->
|
||||
case packet.inserted_at do
|
||||
%NaiveDateTime{} = naive_dt -> DateTime.from_naive!(naive_dt, "Etc/UTC")
|
||||
%DateTime{} = dt -> dt
|
||||
_other -> DateTime.utc_now()
|
||||
end
|
||||
end,
|
||||
{:desc, DateTime}
|
||||
)
|
||||
|
||||
unique_position_packets = filter_unique_positions(sorted_packets)
|
||||
|
||||
unique_position_packets
|
||||
|> Enum.with_index()
|
||||
|> Enum.map(fn {packet, index} ->
|
||||
case build_packet_data(packet) do
|
||||
nil ->
|
||||
nil
|
||||
|
||||
packet_data ->
|
||||
packet_id =
|
||||
"hist_#{if Map.has_key?(packet, :id), do: packet.id, else: System.unique_integer([:positive])}_#{index}"
|
||||
|
||||
is_most_recent = index == 0
|
||||
|
||||
packet_data
|
||||
|> Map.put("id", packet_id)
|
||||
|> Map.put("is_historical", true)
|
||||
|> Map.put("is_most_recent_for_callsign", is_most_recent)
|
||||
|> Map.put("callsign_group", callsign)
|
||||
|> Map.put(
|
||||
"timestamp",
|
||||
case packet.inserted_at do
|
||||
%NaiveDateTime{} = naive_dt ->
|
||||
DateTime.to_unix(DateTime.from_naive!(naive_dt, "Etc/UTC"), :millisecond)
|
||||
|
||||
%DateTime{} = dt ->
|
||||
DateTime.to_unix(dt, :millisecond)
|
||||
|
||||
_other ->
|
||||
DateTime.to_unix(DateTime.utc_now(), :millisecond)
|
||||
end
|
||||
)
|
||||
end
|
||||
end)
|
||||
|> Enum.filter(& &1)
|
||||
end)
|
||||
|
||||
socket = push_event(socket, "add_historical_packets", %{packets: packet_data_list})
|
||||
|
||||
historical_packets_map =
|
||||
packet_data_list
|
||||
|> Enum.zip(historical_packets)
|
||||
|> Enum.reduce(%{}, fn {packet_data, packet}, acc ->
|
||||
Map.put(acc, packet_data["id"], packet)
|
||||
end)
|
||||
|
||||
assign(socket,
|
||||
historical_packets: historical_packets_map,
|
||||
historical_loaded: true
|
||||
)
|
||||
end
|
||||
|
||||
# Filter packets to only include those with unique positions (lat/lon changed)
|
||||
@spec filter_unique_positions([struct()]) :: [struct()]
|
||||
defp filter_unique_positions(packets) do
|
||||
|
|
@ -1223,13 +1211,10 @@ defmodule AprsWeb.MapLive.Index do
|
|||
|
||||
@spec load_historical_packets_for_bounds(Socket.t(), map()) :: Socket.t()
|
||||
defp load_historical_packets_for_bounds(socket, map_bounds) do
|
||||
# Get time range for historical data
|
||||
now = DateTime.utc_now()
|
||||
# Use the user's selected historical hours setting
|
||||
historical_hours = String.to_integer(socket.assigns.historical_hours)
|
||||
start_time = DateTime.add(now, -historical_hours * 3600, :second)
|
||||
|
||||
# Convert map bounds to the format expected by the database query
|
||||
bounds = [
|
||||
map_bounds.west,
|
||||
map_bounds.south,
|
||||
|
|
@ -1237,91 +1222,81 @@ defmodule AprsWeb.MapLive.Index do
|
|||
map_bounds.north
|
||||
]
|
||||
|
||||
# Fetch historical packets with position data within the current map bounds
|
||||
historical_packets = fetch_historical_packets(bounds, start_time, now)
|
||||
|
||||
if Enum.empty?(historical_packets) do
|
||||
# No historical packets found
|
||||
socket
|
||||
else
|
||||
# Group packets by callsign-ssid and process all positions
|
||||
packet_data_list =
|
||||
historical_packets
|
||||
|> Enum.group_by(&generate_callsign/1)
|
||||
|> Enum.flat_map(fn {callsign, packets} ->
|
||||
# Sort packets by inserted_at to identify the most recent
|
||||
sorted_packets =
|
||||
Enum.sort_by(
|
||||
packets,
|
||||
fn packet ->
|
||||
process_historical_packets_for_bounds(socket, historical_packets)
|
||||
end
|
||||
end
|
||||
|
||||
defp process_historical_packets_for_bounds(socket, historical_packets) do
|
||||
packet_data_list =
|
||||
historical_packets
|
||||
|> Enum.group_by(&generate_callsign/1)
|
||||
|> Enum.flat_map(fn {callsign, packets} ->
|
||||
sorted_packets =
|
||||
Enum.sort_by(
|
||||
packets,
|
||||
fn packet ->
|
||||
case packet.inserted_at do
|
||||
%NaiveDateTime{} = naive_dt -> DateTime.from_naive!(naive_dt, "Etc/UTC")
|
||||
%DateTime{} = dt -> dt
|
||||
_other -> DateTime.utc_now()
|
||||
end
|
||||
end,
|
||||
{:desc, DateTime}
|
||||
)
|
||||
|
||||
unique_position_packets = filter_unique_positions(sorted_packets)
|
||||
|
||||
unique_position_packets
|
||||
|> Enum.with_index()
|
||||
|> Enum.map(fn {packet, index} ->
|
||||
case build_packet_data(packet) do
|
||||
nil ->
|
||||
nil
|
||||
|
||||
packet_data ->
|
||||
packet_id =
|
||||
"hist_#{if Map.has_key?(packet, :id), do: packet.id, else: System.unique_integer([:positive])}_#{index}"
|
||||
|
||||
is_most_recent = index == 0
|
||||
|
||||
packet_data
|
||||
|> Map.put("id", packet_id)
|
||||
|> Map.put("is_historical", true)
|
||||
|> Map.put("is_most_recent_for_callsign", is_most_recent)
|
||||
|> Map.put("callsign_group", callsign)
|
||||
|> Map.put(
|
||||
"timestamp",
|
||||
case packet.inserted_at do
|
||||
%NaiveDateTime{} = naive_dt ->
|
||||
DateTime.from_naive!(naive_dt, "Etc/UTC")
|
||||
DateTime.to_unix(DateTime.from_naive!(naive_dt, "Etc/UTC"), :millisecond)
|
||||
|
||||
%DateTime{} = dt ->
|
||||
dt
|
||||
DateTime.to_unix(dt, :millisecond)
|
||||
|
||||
_other ->
|
||||
DateTime.utc_now()
|
||||
DateTime.to_unix(DateTime.utc_now(), :millisecond)
|
||||
end
|
||||
end,
|
||||
{:desc, DateTime}
|
||||
)
|
||||
|
||||
# Filter out packets with unchanged positions
|
||||
unique_position_packets = filter_unique_positions(sorted_packets)
|
||||
|
||||
# Process all packets with unique positions, marking which is the most recent
|
||||
unique_position_packets
|
||||
|> Enum.with_index()
|
||||
|> Enum.map(fn {packet, index} ->
|
||||
case build_packet_data(packet) do
|
||||
nil ->
|
||||
nil
|
||||
|
||||
packet_data ->
|
||||
# Generate a unique ID for this historical packet
|
||||
packet_id =
|
||||
"hist_#{if Map.has_key?(packet, :id), do: packet.id, else: System.unique_integer([:positive])}_#{index}"
|
||||
|
||||
is_most_recent = index == 0
|
||||
|
||||
packet_data
|
||||
|> Map.put("id", packet_id)
|
||||
|> Map.put("is_historical", true)
|
||||
|> Map.put("is_most_recent_for_callsign", is_most_recent)
|
||||
|> Map.put("callsign_group", callsign)
|
||||
|> Map.put(
|
||||
"timestamp",
|
||||
case packet.inserted_at do
|
||||
%NaiveDateTime{} = naive_dt ->
|
||||
DateTime.to_unix(DateTime.from_naive!(naive_dt, "Etc/UTC"), :millisecond)
|
||||
|
||||
%DateTime{} = dt ->
|
||||
DateTime.to_unix(dt, :millisecond)
|
||||
|
||||
_other ->
|
||||
DateTime.to_unix(DateTime.utc_now(), :millisecond)
|
||||
end
|
||||
)
|
||||
end
|
||||
end)
|
||||
|> Enum.filter(& &1)
|
||||
)
|
||||
end
|
||||
end)
|
||||
|> Enum.filter(& &1)
|
||||
end)
|
||||
|
||||
# Send all historical packets at once
|
||||
socket = push_event(socket, "add_historical_packets", %{packets: packet_data_list})
|
||||
socket = push_event(socket, "add_historical_packets", %{packets: packet_data_list})
|
||||
|
||||
# Store historical packets in assigns for reference
|
||||
historical_packets_map =
|
||||
packet_data_list
|
||||
|> Enum.zip(historical_packets)
|
||||
|> Enum.reduce(%{}, fn {packet_data, packet}, acc ->
|
||||
Map.put(acc, packet_data["id"], packet)
|
||||
end)
|
||||
historical_packets_map =
|
||||
packet_data_list
|
||||
|> Enum.zip(historical_packets)
|
||||
|> Enum.reduce(%{}, fn {packet_data, packet}, acc ->
|
||||
Map.put(acc, packet_data["id"], packet)
|
||||
end)
|
||||
|
||||
assign(socket, historical_packets: historical_packets_map)
|
||||
end
|
||||
assign(socket, historical_packets: historical_packets_map)
|
||||
end
|
||||
|
||||
@spec within_bounds?(map() | struct(), map()) :: boolean()
|
||||
|
|
@ -1377,7 +1352,7 @@ defmodule AprsWeb.MapLive.Index do
|
|||
timestamp: PacketUtils.get_timestamp(packet),
|
||||
comment: PacketUtils.get_packet_field(packet, :comment, ""),
|
||||
safe_data_extended: PacketUtils.convert_tuples_to_strings(data_extended),
|
||||
is_weather_packet: PacketUtils.is_weather_packet?(packet)
|
||||
is_weather_packet: PacketUtils.weather_packet?(packet)
|
||||
}
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -52,61 +52,20 @@ defmodule AprsWeb.MapLive.MapHelpers do
|
|||
|
||||
@spec within_bounds?(map() | tuple(), map()) :: boolean()
|
||||
def within_bounds?(packet_or_coords, bounds) do
|
||||
# Handle nil bounds
|
||||
if is_nil(bounds) do
|
||||
false
|
||||
else
|
||||
to_float = fn
|
||||
n when is_float(n) ->
|
||||
n
|
||||
|
||||
n when is_integer(n) ->
|
||||
n * 1.0
|
||||
|
||||
%Decimal{} = d ->
|
||||
Decimal.to_float(d)
|
||||
|
||||
n when is_binary(n) ->
|
||||
case Float.parse(n) do
|
||||
{f, _} -> f
|
||||
:error -> 0.0
|
||||
end
|
||||
|
||||
_ ->
|
||||
0.0
|
||||
end
|
||||
|
||||
{lat, lon} =
|
||||
cond do
|
||||
is_map(packet_or_coords) and Map.has_key?(packet_or_coords, :lat) and
|
||||
Map.has_key?(packet_or_coords, :lon) ->
|
||||
{packet_or_coords.lat, packet_or_coords.lon}
|
||||
|
||||
is_map(packet_or_coords) and Map.has_key?(packet_or_coords, "lat") and
|
||||
Map.has_key?(packet_or_coords, "lon") ->
|
||||
{packet_or_coords["lat"], packet_or_coords["lon"]}
|
||||
|
||||
is_map(packet_or_coords) and Map.has_key?(packet_or_coords, :latitude) and
|
||||
Map.has_key?(packet_or_coords, :longitude) ->
|
||||
{packet_or_coords.latitude, packet_or_coords.longitude}
|
||||
|
||||
is_tuple(packet_or_coords) and tuple_size(packet_or_coords) == 2 ->
|
||||
packet_or_coords
|
||||
|
||||
true ->
|
||||
{nil, nil}
|
||||
end
|
||||
{lat, lon} = extract_lat_lon(packet_or_coords)
|
||||
|
||||
if is_nil(lat) or is_nil(lon) do
|
||||
false
|
||||
else
|
||||
lat = to_float.(lat)
|
||||
lon = to_float.(lon)
|
||||
south = to_float.(bounds.south)
|
||||
north = to_float.(bounds.north)
|
||||
west = to_float.(bounds.west)
|
||||
east = to_float.(bounds.east)
|
||||
|
||||
lat = to_float(lat)
|
||||
lon = to_float(lon)
|
||||
south = to_float(bounds.south)
|
||||
north = to_float(bounds.north)
|
||||
west = to_float(bounds.west)
|
||||
east = to_float(bounds.east)
|
||||
lat_in_bounds = lat >= south && lat <= north
|
||||
|
||||
lng_in_bounds =
|
||||
|
|
@ -120,4 +79,43 @@ defmodule AprsWeb.MapLive.MapHelpers do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp extract_lat_lon(packet_or_coords) do
|
||||
cond do
|
||||
is_map(packet_or_coords) and Map.has_key?(packet_or_coords, :lat) and
|
||||
Map.has_key?(packet_or_coords, :lon) ->
|
||||
extract_lat_lon_atom(packet_or_coords)
|
||||
|
||||
is_map(packet_or_coords) and Map.has_key?(packet_or_coords, "lat") and
|
||||
Map.has_key?(packet_or_coords, "lon") ->
|
||||
extract_lat_lon_string(packet_or_coords)
|
||||
|
||||
is_map(packet_or_coords) and Map.has_key?(packet_or_coords, :latitude) and
|
||||
Map.has_key?(packet_or_coords, :longitude) ->
|
||||
extract_lat_lon_atom_alt(packet_or_coords)
|
||||
|
||||
is_tuple(packet_or_coords) and tuple_size(packet_or_coords) == 2 ->
|
||||
packet_or_coords
|
||||
|
||||
true ->
|
||||
{nil, nil}
|
||||
end
|
||||
end
|
||||
|
||||
defp extract_lat_lon_atom(packet), do: {packet.lat, packet.lon}
|
||||
defp extract_lat_lon_string(packet), do: {packet["lat"], packet["lon"]}
|
||||
defp extract_lat_lon_atom_alt(packet), do: {packet.latitude, packet.longitude}
|
||||
|
||||
defp to_float(n) when is_float(n), do: n
|
||||
defp to_float(n) when is_integer(n), do: n * 1.0
|
||||
defp to_float(%Decimal{} = d), do: Decimal.to_float(d)
|
||||
|
||||
defp to_float(n) when is_binary(n) do
|
||||
case Float.parse(n) do
|
||||
{f, _} -> f
|
||||
:error -> 0.0
|
||||
end
|
||||
end
|
||||
|
||||
defp to_float(_), do: 0.0
|
||||
end
|
||||
|
|
|
|||
|
|
@ -90,8 +90,8 @@ defmodule AprsWeb.MapLive.PacketUtils do
|
|||
@doc """
|
||||
Determines if a packet is a weather packet.
|
||||
"""
|
||||
@spec is_weather_packet?(map()) :: boolean()
|
||||
def is_weather_packet?(packet) do
|
||||
@spec weather_packet?(map()) :: boolean()
|
||||
def weather_packet?(packet) do
|
||||
data_type = get_packet_field(packet, :data_type, "")
|
||||
{symbol_table_id, symbol_code} = get_symbol_info(packet)
|
||||
|
||||
|
|
|
|||
|
|
@ -9,10 +9,8 @@ defmodule AprsWeb.UserForgotPasswordLive do
|
|||
<div class="mx-auto max-w-sm">
|
||||
<.header class="text-center" />
|
||||
|
||||
<.header class="text-center">
|
||||
Forgot your password?
|
||||
<:subtitle>We'll send a password reset link to your inbox</:subtitle>
|
||||
</.header>
|
||||
<h2 class="text-center text-2xl font-bold mt-6 mb-2">Forgot your password?</h2>
|
||||
<p class="text-center text-gray-600 mb-6">We'll send a password reset link to your inbox</p>
|
||||
|
||||
<.simple_form :let={f} id="reset_password_form" for={%{}} as={:user} phx-submit="send_email">
|
||||
<.input field={{f, :email}} type="email" placeholder="Email" required />
|
||||
|
|
|
|||
|
|
@ -10,16 +10,14 @@ defmodule AprsWeb.UserLoginLive do
|
|||
<div class="mx-auto max-w-sm">
|
||||
<.header class="text-center" />
|
||||
|
||||
<.header class="text-center">
|
||||
Sign in to account
|
||||
<:subtitle>
|
||||
Don't have an account?
|
||||
<.link navigate={~p"/users/register"} class="font-semibold text-brand hover:underline">
|
||||
Sign up
|
||||
</.link>
|
||||
for an account now.
|
||||
</:subtitle>
|
||||
</.header>
|
||||
<h2 class="text-center text-2xl font-bold mt-6 mb-2">Sign in to account</h2>
|
||||
<p class="text-center text-gray-600 mb-6">
|
||||
Don't have an account?
|
||||
<.link navigate={~p"/users/register"} class="font-semibold text-brand hover:underline">
|
||||
Sign up
|
||||
</.link>
|
||||
for an account now.
|
||||
</p>
|
||||
|
||||
<.simple_form
|
||||
:let={f}
|
||||
|
|
|
|||
|
|
@ -10,16 +10,14 @@ defmodule AprsWeb.UserRegistrationLive do
|
|||
<div class="mx-auto max-w-sm">
|
||||
<.header class="text-center" />
|
||||
|
||||
<.header class="text-center">
|
||||
Register for an account
|
||||
<:subtitle>
|
||||
Already registered?
|
||||
<.link navigate={~p"/users/log_in"} class="font-semibold text-brand hover:underline">
|
||||
Sign in
|
||||
</.link>
|
||||
to your account now.
|
||||
</:subtitle>
|
||||
</.header>
|
||||
<h2 class="text-center text-2xl font-bold mt-6 mb-2">Register for an account</h2>
|
||||
<p class="text-center text-gray-600 mb-6">
|
||||
Already registered?
|
||||
<.link navigate={~p"/users/log_in"} class="font-semibold text-brand hover:underline">
|
||||
Sign in
|
||||
</.link>
|
||||
to your account now.
|
||||
</p>
|
||||
|
||||
<.simple_form
|
||||
:let={f}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,6 @@ defmodule AprsWeb.WeatherLive.CallsignView do
|
|||
# Get the most recent packet for this callsign that is a weather report
|
||||
%{callsign: callsign, limit: 10}
|
||||
|> Packets.get_recent_packets()
|
||||
|> Enum.find(&PacketUtils.is_weather_packet?/1)
|
||||
|> Enum.find(&PacketUtils.weather_packet?/1)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
320
lib/parser.ex
320
lib/parser.ex
|
|
@ -226,7 +226,6 @@ defmodule Parser do
|
|||
end
|
||||
|
||||
def parse_data(:timestamped_position_with_message, _destination, data) do
|
||||
# Correct binary pattern match for APRS position with weather
|
||||
case data do
|
||||
<<time::binary-size(7), latitude::binary-size(8), sym_table_id::binary-size(1), longitude::binary-size(9),
|
||||
symbol_code::binary-size(1), rest::binary>> ->
|
||||
|
|
@ -244,32 +243,15 @@ defmodule Parser do
|
|||
rest
|
||||
)
|
||||
|
||||
Map.put(
|
||||
result,
|
||||
:has_location,
|
||||
(is_number(result[:latitude]) or is_struct(result[:latitude], Decimal)) and
|
||||
(is_number(result[:longitude]) or is_struct(result[:longitude], Decimal))
|
||||
)
|
||||
add_has_location(result)
|
||||
else
|
||||
result = parse_position_with_timestamp(true, data, :timestamped_position_with_message)
|
||||
|
||||
Map.put(
|
||||
result,
|
||||
:has_location,
|
||||
(is_number(result[:latitude]) or is_struct(result[:latitude], Decimal)) and
|
||||
(is_number(result[:longitude]) or is_struct(result[:longitude], Decimal))
|
||||
)
|
||||
add_has_location(result)
|
||||
end
|
||||
|
||||
_ ->
|
||||
result = parse_position_with_timestamp(true, data, :timestamped_position_with_message)
|
||||
|
||||
Map.put(
|
||||
result,
|
||||
:has_location,
|
||||
(is_number(result[:latitude]) or is_struct(result[:latitude], Decimal)) and
|
||||
(is_number(result[:longitude]) or is_struct(result[:longitude], Decimal))
|
||||
)
|
||||
add_has_location(result)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -304,7 +286,6 @@ defmodule Parser do
|
|||
def parse_data(:query, _destination, data), do: parse_query(data)
|
||||
def parse_data(:user_defined, _destination, data), do: parse_user_defined(data)
|
||||
def parse_data(:third_party_traffic, _destination, data), do: parse_third_party_traffic(data)
|
||||
|
||||
def parse_data(:peet_logging, _destination, data), do: Parser.Helpers.parse_peet_logging(data)
|
||||
|
||||
def parse_data(:invalid_test_data, _destination, data), do: Parser.Helpers.parse_invalid_test_data(data)
|
||||
|
|
@ -354,9 +335,17 @@ defmodule Parser do
|
|||
end
|
||||
|
||||
def parse_data(:phg_data, _destination, data), do: parse_phg_data(data)
|
||||
|
||||
def parse_data(_type, _destination, _data), do: nil
|
||||
|
||||
defp add_has_location(result) do
|
||||
Map.put(
|
||||
result,
|
||||
:has_location,
|
||||
(is_number(result[:latitude]) or is_struct(result[:latitude], Decimal)) and
|
||||
(is_number(result[:longitude]) or is_struct(result[:longitude], Decimal))
|
||||
)
|
||||
end
|
||||
|
||||
@spec parse_position_with_datetime_and_weather(
|
||||
boolean(),
|
||||
binary(),
|
||||
|
|
@ -427,131 +416,149 @@ defmodule Parser 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>> ->
|
||||
%{latitude: lat, longitude: lon} = parse_aprs_position(latitude, longitude)
|
||||
ambiguity = Parser.Helpers.calculate_position_ambiguity(latitude, longitude)
|
||||
dao_data = parse_dao_extension(comment)
|
||||
{course, speed} = extract_course_and_speed(comment)
|
||||
|
||||
has_position =
|
||||
(is_number(lat) or is_struct(lat, Decimal)) and
|
||||
(is_number(lon) or is_struct(lon, Decimal))
|
||||
|
||||
base_map = %{
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
timestamp: nil,
|
||||
symbol_table_id: sym_table_id,
|
||||
symbol_code: symbol_code,
|
||||
comment: comment,
|
||||
data_type: :position,
|
||||
aprs_messaging?: false,
|
||||
compressed?: false,
|
||||
position_ambiguity: ambiguity,
|
||||
dao: dao_data,
|
||||
course: course,
|
||||
speed: speed,
|
||||
has_position: has_position
|
||||
}
|
||||
|
||||
if sym_table_id == "/" and symbol_code == "_" do
|
||||
weather_map = Parser.Weather.parse_weather_data(comment)
|
||||
Map.merge(base_map, weather_map)
|
||||
else
|
||||
base_map
|
||||
end
|
||||
parse_position_uncompressed(latitude, sym_table_id, longitude, symbol_code, comment)
|
||||
|
||||
<<latitude::binary-size(8), sym_table_id::binary-size(1), longitude::binary-size(9)>> ->
|
||||
%{latitude: lat, longitude: lon} = parse_aprs_position(latitude, longitude)
|
||||
ambiguity = Parser.Helpers.calculate_position_ambiguity(latitude, longitude)
|
||||
|
||||
has_position =
|
||||
(is_number(lat) or is_struct(lat, Decimal)) and
|
||||
(is_number(lon) or is_struct(lon, Decimal))
|
||||
|
||||
%{
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
timestamp: nil,
|
||||
symbol_table_id: sym_table_id,
|
||||
symbol_code: "_",
|
||||
data_type: :position,
|
||||
aprs_messaging?: false,
|
||||
compressed?: false,
|
||||
position_ambiguity: ambiguity,
|
||||
dao: nil,
|
||||
course: nil,
|
||||
speed: nil,
|
||||
has_position: has_position
|
||||
}
|
||||
parse_position_short_uncompressed(latitude, sym_table_id, longitude)
|
||||
|
||||
<<"/", latitude_compressed::binary-size(4), longitude_compressed::binary-size(4), symbol_code::binary-size(1),
|
||||
cs::binary-size(2), compression_type::binary-size(1), comment::binary>> ->
|
||||
try do
|
||||
converted_lat = Parser.Helpers.convert_compressed_lat(latitude_compressed)
|
||||
converted_lon = Parser.Helpers.convert_compressed_lon(longitude_compressed)
|
||||
compressed_cs = Parser.Helpers.convert_compressed_cs(cs)
|
||||
ambiguity = Parser.Helpers.calculate_compressed_ambiguity(compression_type)
|
||||
|
||||
has_position =
|
||||
(is_number(converted_lat) or is_struct(converted_lat, Decimal)) and
|
||||
(is_number(converted_lon) or is_struct(converted_lon, Decimal))
|
||||
|
||||
base_data = %{
|
||||
latitude: converted_lat,
|
||||
longitude: converted_lon,
|
||||
symbol_table_id: "/",
|
||||
symbol_code: symbol_code,
|
||||
comment: comment,
|
||||
position_format: :compressed,
|
||||
compression_type: compression_type,
|
||||
data_type: :position,
|
||||
compressed?: true,
|
||||
position_ambiguity: ambiguity,
|
||||
dao: nil,
|
||||
has_position: has_position
|
||||
}
|
||||
|
||||
# Merge in course/speed from compressed_cs
|
||||
Map.merge(base_data, compressed_cs)
|
||||
rescue
|
||||
_e ->
|
||||
%{
|
||||
latitude: nil,
|
||||
longitude: nil,
|
||||
symbol_table_id: "/",
|
||||
symbol_code: symbol_code,
|
||||
comment: comment,
|
||||
position_format: :compressed,
|
||||
compression_type: compression_type,
|
||||
data_type: :position,
|
||||
compressed?: true,
|
||||
position_ambiguity: Parser.Helpers.calculate_compressed_ambiguity(compression_type),
|
||||
dao: nil,
|
||||
course: nil,
|
||||
speed: nil,
|
||||
has_position: false
|
||||
}
|
||||
end
|
||||
parse_position_compressed(
|
||||
latitude_compressed,
|
||||
longitude_compressed,
|
||||
symbol_code,
|
||||
cs,
|
||||
compression_type,
|
||||
comment
|
||||
)
|
||||
|
||||
_ ->
|
||||
%{
|
||||
latitude: nil,
|
||||
longitude: nil,
|
||||
timestamp: nil,
|
||||
symbol_table_id: nil,
|
||||
symbol_code: nil,
|
||||
data_type: :malformed_position,
|
||||
aprs_messaging?: false,
|
||||
compressed?: false,
|
||||
comment: String.trim(position_data),
|
||||
dao: nil,
|
||||
course: nil,
|
||||
speed: nil,
|
||||
has_position: false
|
||||
}
|
||||
parse_position_malformed(position_data)
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_position_uncompressed(latitude, sym_table_id, longitude, symbol_code, comment) do
|
||||
%{latitude: lat, longitude: lon} = parse_aprs_position(latitude, longitude)
|
||||
ambiguity = Parser.Helpers.calculate_position_ambiguity(latitude, longitude)
|
||||
dao_data = parse_dao_extension(comment)
|
||||
{course, speed} = extract_course_and_speed(comment)
|
||||
|
||||
has_position =
|
||||
(is_number(lat) or is_struct(lat, Decimal)) and (is_number(lon) or is_struct(lon, Decimal))
|
||||
|
||||
base_map = %{
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
timestamp: nil,
|
||||
symbol_table_id: sym_table_id,
|
||||
symbol_code: symbol_code,
|
||||
comment: comment,
|
||||
data_type: :position,
|
||||
aprs_messaging?: false,
|
||||
compressed?: false,
|
||||
position_ambiguity: ambiguity,
|
||||
dao: dao_data,
|
||||
course: course,
|
||||
speed: speed,
|
||||
has_position: has_position
|
||||
}
|
||||
|
||||
if sym_table_id == "/" and symbol_code == "_" do
|
||||
weather_map = Parser.Weather.parse_weather_data(comment)
|
||||
Map.merge(base_map, weather_map)
|
||||
else
|
||||
base_map
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_position_short_uncompressed(latitude, sym_table_id, longitude) do
|
||||
%{latitude: lat, longitude: lon} = parse_aprs_position(latitude, longitude)
|
||||
ambiguity = Parser.Helpers.calculate_position_ambiguity(latitude, longitude)
|
||||
|
||||
has_position =
|
||||
(is_number(lat) or is_struct(lat, Decimal)) and (is_number(lon) or is_struct(lon, Decimal))
|
||||
|
||||
%{
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
timestamp: nil,
|
||||
symbol_table_id: sym_table_id,
|
||||
symbol_code: "_",
|
||||
data_type: :position,
|
||||
aprs_messaging?: false,
|
||||
compressed?: false,
|
||||
position_ambiguity: ambiguity,
|
||||
dao: nil,
|
||||
course: nil,
|
||||
speed: nil,
|
||||
has_position: has_position
|
||||
}
|
||||
end
|
||||
|
||||
defp parse_position_compressed(latitude_compressed, longitude_compressed, symbol_code, cs, compression_type, comment) do
|
||||
converted_lat = Parser.Helpers.convert_compressed_lat(latitude_compressed)
|
||||
converted_lon = Parser.Helpers.convert_compressed_lon(longitude_compressed)
|
||||
compressed_cs = Parser.Helpers.convert_compressed_cs(cs)
|
||||
ambiguity = Parser.Helpers.calculate_compressed_ambiguity(compression_type)
|
||||
|
||||
has_position =
|
||||
(is_number(converted_lat) or is_struct(converted_lat, Decimal)) and
|
||||
(is_number(converted_lon) or is_struct(converted_lon, Decimal))
|
||||
|
||||
base_data = %{
|
||||
latitude: converted_lat,
|
||||
longitude: converted_lon,
|
||||
symbol_table_id: "/",
|
||||
symbol_code: symbol_code,
|
||||
comment: comment,
|
||||
position_format: :compressed,
|
||||
compression_type: compression_type,
|
||||
data_type: :position,
|
||||
compressed?: true,
|
||||
position_ambiguity: ambiguity,
|
||||
dao: nil,
|
||||
has_position: has_position
|
||||
}
|
||||
|
||||
Map.merge(base_data, compressed_cs)
|
||||
rescue
|
||||
_e ->
|
||||
%{
|
||||
latitude: nil,
|
||||
longitude: nil,
|
||||
symbol_table_id: "/",
|
||||
symbol_code: symbol_code,
|
||||
comment: comment,
|
||||
position_format: :compressed,
|
||||
compression_type: compression_type,
|
||||
data_type: :position,
|
||||
compressed?: true,
|
||||
position_ambiguity: Parser.Helpers.calculate_compressed_ambiguity(compression_type),
|
||||
dao: nil,
|
||||
course: nil,
|
||||
speed: nil,
|
||||
has_position: false
|
||||
}
|
||||
end
|
||||
|
||||
defp parse_position_malformed(position_data) do
|
||||
%{
|
||||
latitude: nil,
|
||||
longitude: nil,
|
||||
timestamp: nil,
|
||||
symbol_table_id: nil,
|
||||
symbol_code: nil,
|
||||
data_type: :malformed_position,
|
||||
aprs_messaging?: false,
|
||||
compressed?: false,
|
||||
comment: String.trim(position_data),
|
||||
dao: nil,
|
||||
course: nil,
|
||||
speed: nil,
|
||||
has_position: false
|
||||
}
|
||||
end
|
||||
|
||||
# Patch parse_position_with_message_without_timestamp to propagate course/speed
|
||||
@spec parse_position_with_message_without_timestamp(String.t()) :: map()
|
||||
def parse_position_with_message_without_timestamp(position_data) do
|
||||
|
|
@ -1168,26 +1175,9 @@ defmodule Parser do
|
|||
case parse_callsign(sender) do
|
||||
{:ok, callsign_parts} ->
|
||||
base_callsign = List.first(callsign_parts)
|
||||
ssid = extract_ssid(List.last(callsign_parts))
|
||||
|
||||
ssid =
|
||||
case List.last(callsign_parts) do
|
||||
nil ->
|
||||
0
|
||||
|
||||
s when is_binary(s) ->
|
||||
case Integer.parse(s) do
|
||||
{i, _} -> i
|
||||
:error -> 0
|
||||
end
|
||||
|
||||
i when is_integer(i) ->
|
||||
i
|
||||
|
||||
_ ->
|
||||
0
|
||||
end
|
||||
|
||||
case split_path(path) do
|
||||
case split_path_for_tunnel(path) do
|
||||
{:ok, [destination, digi_path]} ->
|
||||
{:ok,
|
||||
%{
|
||||
|
|
@ -1211,6 +1201,22 @@ defmodule Parser do
|
|||
end
|
||||
end
|
||||
|
||||
defp extract_ssid(nil), do: 0
|
||||
|
||||
defp extract_ssid(s) when is_binary(s) do
|
||||
case Integer.parse(s) do
|
||||
{i, _} -> i
|
||||
:error -> 0
|
||||
end
|
||||
end
|
||||
|
||||
defp extract_ssid(i) when is_integer(i), do: i
|
||||
defp extract_ssid(_), do: 0
|
||||
|
||||
defp split_path_for_tunnel(path) do
|
||||
split_path(path)
|
||||
end
|
||||
|
||||
# Add network tunneling support
|
||||
@spec parse_network_tunnel(String.t()) :: {:ok, map()} | {:error, String.t()}
|
||||
defp parse_network_tunnel(packet) do
|
||||
|
|
|
|||
|
|
@ -15,16 +15,7 @@ defmodule Parser.Helpers do
|
|||
case Float.parse(value) do
|
||||
{coord, _} ->
|
||||
coord = coord / 100.0
|
||||
|
||||
coord =
|
||||
case direction do
|
||||
"N" -> coord
|
||||
"S" -> -coord
|
||||
"E" -> coord
|
||||
"W" -> -coord
|
||||
_ -> {:error, "Invalid coordinate direction"}
|
||||
end
|
||||
|
||||
coord = apply_nmea_direction(coord, direction)
|
||||
if is_tuple(coord), do: coord, else: {:ok, coord}
|
||||
|
||||
:error ->
|
||||
|
|
@ -35,6 +26,16 @@ defmodule Parser.Helpers do
|
|||
@spec parse_nmea_coordinate(any(), any()) :: {:error, String.t()}
|
||||
def parse_nmea_coordinate(_, _), do: {:error, "Invalid coordinate format"}
|
||||
|
||||
defp apply_nmea_direction(coord, direction) do
|
||||
case direction do
|
||||
"N" -> coord
|
||||
"S" -> -coord
|
||||
"E" -> coord
|
||||
"W" -> -coord
|
||||
_ -> {:error, "Invalid coordinate direction"}
|
||||
end
|
||||
end
|
||||
|
||||
# PHG/DF helpers
|
||||
@spec parse_phg_power(integer()) :: phg_power
|
||||
def parse_phg_power(?0), do: {1, "1 watt"}
|
||||
|
|
@ -193,40 +194,38 @@ defmodule Parser.Helpers do
|
|||
end
|
||||
|
||||
def parse_analog_values(values) do
|
||||
Enum.map(values, fn value ->
|
||||
case value do
|
||||
"" ->
|
||||
nil
|
||||
Enum.map(values, &parse_analog_value/1)
|
||||
end
|
||||
|
||||
value ->
|
||||
case Float.parse(value) do
|
||||
{float_val, _} -> float_val
|
||||
:error -> nil
|
||||
end
|
||||
end
|
||||
end)
|
||||
defp parse_analog_value("") do
|
||||
nil
|
||||
end
|
||||
|
||||
defp parse_analog_value(value) do
|
||||
case Float.parse(value) do
|
||||
{float_val, _} -> float_val
|
||||
:error -> nil
|
||||
end
|
||||
end
|
||||
|
||||
def parse_digital_values(values) do
|
||||
values
|
||||
|> Enum.map(fn value ->
|
||||
cond do
|
||||
value == "1" ->
|
||||
true
|
||||
|
||||
is_binary(value) ->
|
||||
value
|
||||
|> String.graphemes()
|
||||
|> Enum.map(fn
|
||||
"1" -> true
|
||||
"0" -> false
|
||||
_ -> nil
|
||||
end)
|
||||
end
|
||||
end)
|
||||
|> List.flatten()
|
||||
Enum.flat_map(values, &parse_digital_value/1)
|
||||
end
|
||||
|
||||
defp parse_digital_value("1"), do: [true]
|
||||
|
||||
defp parse_digital_value(value) when is_binary(value) do
|
||||
value
|
||||
|> String.graphemes()
|
||||
|> Enum.map(fn
|
||||
"1" -> true
|
||||
"0" -> false
|
||||
_ -> nil
|
||||
end)
|
||||
end
|
||||
|
||||
defp parse_digital_value(_), do: [nil]
|
||||
|
||||
def parse_coefficient(coeff) do
|
||||
case Float.parse(coeff) do
|
||||
{float_val, _} ->
|
||||
|
|
|
|||
|
|
@ -77,14 +77,14 @@ defmodule Parser.MicE do
|
|||
end
|
||||
end
|
||||
|
||||
defp decode_destination_digits([c1, c2, c3, c4, c5, c6]) do
|
||||
defp decode_destination_digits([c1, c2, c3, d4, d5, d6]) do
|
||||
[
|
||||
decode_digit(c1),
|
||||
decode_digit(c2),
|
||||
decode_digit(c3),
|
||||
decode_digit(c4),
|
||||
decode_digit(c5),
|
||||
decode_digit(c6)
|
||||
decode_digit(d4),
|
||||
decode_digit(d5),
|
||||
decode_digit(d6)
|
||||
]
|
||||
end
|
||||
|
||||
|
|
@ -173,32 +173,11 @@ defmodule Parser.MicE do
|
|||
else
|
||||
<<lon_deg_c, lon_min_c, lon_hmin_c, sp_c, dc_c, se_c, symbol_code, symbol_table_id, comment::binary>> = data
|
||||
|
||||
lon_deg =
|
||||
case lon_deg_c - 28 do
|
||||
d when d >= 108 and d <= 117 -> d - 80
|
||||
d when d >= 118 and d <= 127 -> d - 190
|
||||
d -> d
|
||||
end + lon_offset
|
||||
|
||||
lon_min =
|
||||
case lon_min_c - 28 do
|
||||
m when m >= 60 -> m - 60
|
||||
m -> m
|
||||
end
|
||||
|
||||
lon_deg = decode_lon_deg(lon_deg_c, lon_offset)
|
||||
lon_min = decode_lon_min(lon_min_c)
|
||||
lon_hmin = lon_hmin_c - 28
|
||||
|
||||
sp = sp_c - 28
|
||||
dc = dc_c - 28
|
||||
se = se_c - 28
|
||||
|
||||
speed = div(sp, 10) * 100 + rem(sp, 10) * 10 + div(dc, 10)
|
||||
speed = if speed >= 800, do: speed - 800, else: speed
|
||||
# Convert to knots from mph
|
||||
speed = speed * 0.868976
|
||||
|
||||
course = rem(dc, 10) * 100 + se
|
||||
course = if course >= 400, do: course - 400, else: course
|
||||
speed = decode_speed(sp_c, dc_c)
|
||||
course = decode_course(dc_c, se_c)
|
||||
|
||||
{:ok,
|
||||
%{
|
||||
|
|
@ -213,4 +192,34 @@ defmodule Parser.MicE do
|
|||
}}
|
||||
end
|
||||
end
|
||||
|
||||
defp decode_lon_deg(lon_deg_c, lon_offset) do
|
||||
case lon_deg_c - 28 do
|
||||
d when d >= 108 and d <= 117 -> d - 80
|
||||
d when d >= 118 and d <= 127 -> d - 190
|
||||
d -> d
|
||||
end + lon_offset
|
||||
end
|
||||
|
||||
defp decode_lon_min(lon_min_c) do
|
||||
case lon_min_c - 28 do
|
||||
m when m >= 60 -> m - 60
|
||||
m -> m
|
||||
end
|
||||
end
|
||||
|
||||
defp decode_speed(sp_c, dc_c) do
|
||||
sp = sp_c - 28
|
||||
dc = dc_c - 28
|
||||
speed = div(sp, 10) * 100 + rem(sp, 10) * 10 + div(dc, 10)
|
||||
speed = if speed >= 800, do: speed - 800, else: speed
|
||||
speed * 0.868976
|
||||
end
|
||||
|
||||
defp decode_course(dc_c, se_c) do
|
||||
dc = dc_c - 28
|
||||
se = se_c - 28
|
||||
course = rem(dc, 10) * 100 + se
|
||||
if course >= 400, do: course - 400, else: course
|
||||
end
|
||||
end
|
||||
|
|
|
|||
47
test/parser/mic_e_test.exs
Normal file
47
test/parser/mic_e_test.exs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
defmodule Parser.MicETest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Parser.MicE
|
||||
|
||||
describe "parse/2" do
|
||||
test "returns parsed map for valid Mic-E destination and data" do
|
||||
# Example valid destination and data (values are illustrative)
|
||||
destination = "ABCD12"
|
||||
data = <<40, 41, 42, 43, 44, 45, 46, 47>> <> "rest"
|
||||
result = MicE.parse(data, destination)
|
||||
assert is_map(result)
|
||||
assert result[:data_type] == :mic_e or result[:data_type] == :mic_e_error
|
||||
end
|
||||
|
||||
test "returns error map for invalid destination length" do
|
||||
destination = "SHORT"
|
||||
data = <<40, 41, 42, 43, 44, 45, 46, 47>>
|
||||
result = MicE.parse(data, destination)
|
||||
assert result[:data_type] == :mic_e_error
|
||||
assert result[:latitude] == nil
|
||||
assert result[:longitude] == nil
|
||||
end
|
||||
|
||||
test "returns error map for invalid information field length" do
|
||||
destination = "ABCDEF"
|
||||
data = <<1, 2, 3>>
|
||||
result = MicE.parse(data, destination)
|
||||
assert result[:data_type] == :mic_e_error
|
||||
assert result[:latitude] == nil
|
||||
assert result[:longitude] == nil
|
||||
end
|
||||
|
||||
test "returns error map for invalid characters in destination" do
|
||||
destination = "!!!!!!"
|
||||
data = <<40, 41, 42, 43, 44, 45, 46, 47>>
|
||||
result = MicE.parse(data, destination)
|
||||
assert result[:data_type] == :mic_e_error
|
||||
end
|
||||
|
||||
test "returns error map for nil destination" do
|
||||
data = <<40, 41, 42, 43, 44, 45, 46, 47>>
|
||||
result = MicE.parse(data, nil)
|
||||
assert result[:data_type] == :mic_e_error
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -18,5 +18,41 @@ defmodule Parser.ObjectTest do
|
|||
assert result[:data_type] == :object
|
||||
end
|
||||
end
|
||||
|
||||
test "parses uncompressed object position" do
|
||||
# 9-char name, 1 live/killed, 7 timestamp, 8 lat, 1 sym_table, 9 lon, 1 sym_code, comment
|
||||
data = ";OBJECTNAM*1234567" <> "4903.50N/" <> "07201.75W" <> ">Test object"
|
||||
result = Object.parse(data)
|
||||
assert is_map(result)
|
||||
assert result[:data_type] == :object
|
||||
assert result[:position_format] == :uncompressed
|
||||
assert result[:latitude] != nil
|
||||
assert result[:longitude] != nil
|
||||
end
|
||||
|
||||
test "parses compressed object position" do
|
||||
# 9-char name, 1 live/killed, 7 timestamp, compressed position
|
||||
data = ";OBJECTNAM*1234567/abcdabcd>12!cTest compressed"
|
||||
result = Object.parse(data)
|
||||
assert is_map(result)
|
||||
assert result[:data_type] == :object
|
||||
assert result[:position_format] == :compressed
|
||||
end
|
||||
|
||||
test "parses unknown/fallback object position" do
|
||||
data = ";OBJECTNAM*1234567unknownformat"
|
||||
result = Object.parse(data)
|
||||
assert is_map(result)
|
||||
assert result[:data_type] == :object
|
||||
assert result[:position_format] == :unknown
|
||||
end
|
||||
|
||||
test "parses fallback/other data" do
|
||||
data = "not an object"
|
||||
result = Object.parse(data)
|
||||
assert is_map(result)
|
||||
assert result[:data_type] == :object
|
||||
assert result[:raw_data] == data
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -18,5 +18,53 @@ defmodule Parser.TelemetryTest do
|
|||
assert Map.has_key?(result, :data_type)
|
||||
end
|
||||
end
|
||||
|
||||
test "parses T# telemetry string with analog and digital values" do
|
||||
result = Telemetry.parse("T#123,1,2,3,4,5,1,0,1,0,1,0,1")
|
||||
assert is_map(result)
|
||||
assert result[:data_type] == :telemetry
|
||||
assert result[:sequence_number] == 123
|
||||
assert is_list(result[:analog_values])
|
||||
assert is_list(result[:digital_values])
|
||||
end
|
||||
|
||||
test "parses :PARM. telemetry parameter names" do
|
||||
result = Telemetry.parse(":PARM.A,B,C,D,E")
|
||||
assert result[:data_type] == :telemetry_parameters
|
||||
assert result[:parameter_names] == ["A", "B", "C", "D", "E"]
|
||||
end
|
||||
|
||||
test "parses :EQNS. telemetry equations" do
|
||||
result = Telemetry.parse(":EQNS.1,2,3,4,5,6,7,8,9")
|
||||
assert result[:data_type] == :telemetry_equations
|
||||
assert is_list(result[:equations])
|
||||
assert Enum.all?(result[:equations], &is_map/1)
|
||||
end
|
||||
|
||||
test "parses :UNIT. telemetry units" do
|
||||
result = Telemetry.parse(":UNIT.V,A,degC")
|
||||
assert result[:data_type] == :telemetry_units
|
||||
assert result[:units] == ["V", "A", "degC"]
|
||||
end
|
||||
|
||||
test "parses :BITS. telemetry bits sense and project names" do
|
||||
result = Telemetry.parse(":BITS.101010,foo,bar")
|
||||
assert result[:data_type] == :telemetry_bits
|
||||
assert is_list(result[:bits_sense])
|
||||
assert result[:project_names] == ["foo", "bar"]
|
||||
end
|
||||
|
||||
test "parses :BITS. with no project names" do
|
||||
result = Telemetry.parse(":BITS.101010")
|
||||
assert result[:data_type] == :telemetry_bits
|
||||
assert is_list(result[:bits_sense])
|
||||
assert result[:project_names] == []
|
||||
end
|
||||
|
||||
test "parses unknown/fallback data" do
|
||||
result = Telemetry.parse("random data")
|
||||
assert result[:data_type] == :telemetry
|
||||
assert result[:raw_data] == "random data"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue