misc cleanup

This commit is contained in:
Graham McIntire 2025-06-22 09:38:05 -05:00
parent 51efdf34a2
commit cad5f4a380
No known key found for this signature in database
14 changed files with 755 additions and 624 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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

View file

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

View file

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