major refactor and fixes with streaming

This commit is contained in:
Graham McIntire 2025-06-20 14:09:43 -05:00
parent d8454d6267
commit ecee5fd677
No known key found for this signature in database
24 changed files with 713 additions and 347 deletions

View file

@ -120,4 +120,9 @@ defmodule Aprs.AprsIsConnection do
error
end
end
@impl true
def code_change(_old_vsn, state, _extra) do
{:ok, state}
end
end

View file

@ -35,10 +35,18 @@ defmodule Aprs.DataExtended do
:aprs_messaging,
:comment,
:data_type,
:latitude,
:longitude,
:symbol_code,
:symbol_table_id
])
|> validate_required_if_present(:latitude)
|> validate_required_if_present(:longitude)
end
defp validate_required_if_present(changeset, field) do
if Map.has_key?(changeset.changes, field) do
validate_required(changeset, [field])
else
changeset
end
end
end

View file

@ -369,6 +369,11 @@ defmodule Aprs.Is do
:normal
end
@impl true
def code_change(_old_vsn, state, _extra) do
{:ok, state}
end
@spec dispatch(binary) :: nil | :ok
def dispatch("#" <> comment_text) do
Logger.debug("COMMENT: " <> String.trim(comment_text))

View file

@ -17,8 +17,8 @@ defmodule Aprs.Packet do
field(:ssid, :string)
field(:received_at, :utc_datetime_usec)
field(:region, :string)
field(:lat, :float, virtual: true)
field(:lon, :float, virtual: true)
field(:lat, :decimal)
field(:lon, :decimal)
field(:location, Geo.PostGIS.Geometry)
field(:has_position, :boolean, default: false)
@ -185,6 +185,9 @@ defmodule Aprs.Packet do
end
defp valid_coordinates?(lat, lon) do
lat = if is_struct(lat, Decimal), do: Decimal.to_float(lat), else: lat
lon = if is_struct(lon, Decimal), do: Decimal.to_float(lon), else: lon
is_number(lat) && is_number(lon) &&
lat >= -90 && lat <= 90 &&
lon >= -180 && lon <= 180
@ -309,6 +312,8 @@ defmodule Aprs.Packet do
# Extract data from MicE packets
defp extract_from_mic_e(mic_e) do
%{}
|> maybe_put(:lat, mic_e[:latitude])
|> maybe_put(:lon, mic_e[:longitude])
|> maybe_put(:comment, mic_e.message)
|> maybe_put(:manufacturer, mic_e.manufacturer)
|> maybe_put(:course, mic_e.heading)
@ -320,6 +325,8 @@ defmodule Aprs.Packet do
# Extract data from converted MicE map (from struct_to_map conversion)
defp extract_from_mic_e_map(mic_e_map) do
%{}
|> maybe_put(:lat, mic_e_map[:latitude])
|> maybe_put(:lon, mic_e_map[:longitude])
|> maybe_put(:comment, mic_e_map[:message])
|> maybe_put(:manufacturer, mic_e_map[:manufacturer])
|> maybe_put(:course, mic_e_map[:heading])
@ -476,7 +483,11 @@ defmodule Aprs.Packet do
Create a geometry point from lat/lon coordinates.
"""
@spec create_point(number() | nil, number() | nil) :: Geo.Point.t() | nil
def create_point(lat, lon) when is_number(lat) and is_number(lon) do
def create_point(lat, lon)
when (is_number(lat) or is_struct(lat, Decimal)) and (is_number(lon) or is_struct(lon, Decimal)) do
lat = if is_struct(lat, Decimal), do: Decimal.to_float(lat), else: lat
lon = if is_struct(lon, Decimal), do: Decimal.to_float(lon), else: lon
if valid_coordinates?(lat, lon) do
%Geo.Point{coordinates: {lon, lat}, srid: 4326}
end

View file

@ -9,6 +9,7 @@ defmodule Aprs.Packets do
alias Aprs.EncodingUtils
alias Aprs.Packet
alias Aprs.Repo
alias Parser.Types.MicE
@doc """
Stores a packet in the database.
@ -50,38 +51,80 @@ defmodule Aprs.Packets do
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
{lat, lon} = extract_position(packet_attrs)
# Set position fields if found
packet_attrs =
if lat && lon do
# Validate coordinates before creating geometry
if are_valid_coordinates?(lat, lon) do
packet_attrs
|> Map.put(:lat, lat)
|> Map.put(:lon, lon)
|> Map.put(:has_position, true)
|> Map.put(:region, "#{Float.round(lat, 1)},#{Float.round(lon, 1)}")
else
Logger.debug("Invalid coordinates for packet from #{packet_attrs[:sender]}: lat=#{lat}, lon=#{lon}")
# Helper to round to 6 decimal places
round6 = fn
nil ->
nil
# Set region based on callsign if coordinates are invalid
sender_region =
if packet_attrs[:sender],
do: String.slice(packet_attrs.sender || "", 0, 3),
else: "unknown"
%Decimal{} = d ->
Decimal.round(d, 6)
Map.put(packet_attrs, :region, "call:#{sender_region}")
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
else
# Set region based on callsign if no position
sender_region =
if packet_attrs[:sender],
do: String.slice(packet_attrs.sender || "", 0, 3),
else: "unknown"
Map.put(packet_attrs, :region, "call:#{sender_region}")
_ ->
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
@ -89,16 +132,6 @@ defmodule Aprs.Packets do
|> Packet.changeset(packet_attrs)
|> Repo.insert() do
{:ok, packet} ->
symbol_table =
Map.get(packet_attrs, :symbol_table_id) ||
get_in(packet_attrs, [:data_extended, :symbol_table_id]) || "/"
symbol_code =
Map.get(packet_attrs, :symbol_code) ||
get_in(packet_attrs, [:data_extended, :symbol_code]) || ">"
Logger.debug("SYMBOL TABLE: #{inspect(symbol_table)}")
Logger.debug("SYMBOL CODE: #{inspect(symbol_code)}")
{:ok, packet}
{:error, changeset} ->
@ -170,13 +203,26 @@ defmodule Aprs.Packets do
{to_float(data_extended[:latitude]), to_float(data_extended[:longitude])}
# MicE packet format with components
else
extract_position_from_mic_e(data_extended)
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
end
end
defp extract_position_from_data_extended(_), do: {nil, nil}
defp extract_position_from_mic_e(%{__struct__: Parser.Types.MicE} = mic_e) do
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
@ -431,12 +477,26 @@ defmodule Aprs.Packets do
defp to_float(_), do: nil
# Helper to validate coordinate values
defp are_valid_coordinates?(lat, lon) do
is_number(lat) and is_number(lon) and
lat >= -90 and lat <= 90 and lon >= -180 and lon <= 180
# Helper to convert various types to Decimal
defp to_decimal(%Decimal{} = d), do: d
defp to_decimal(f) when is_float(f), do: Decimal.from_float(f)
defp to_decimal(i) when is_integer(i), do: Decimal.new(i)
defp to_decimal(s) when is_binary(s) do
case Decimal.parse(s) do
{d, _} ->
d
:error ->
case Float.parse(s) do
{f, _} -> Decimal.from_float(f)
:error -> nil
end
end
end
defp to_decimal(_), do: nil
# Helper to sanitize all string fields in packet data before database storage
defp sanitize_packet_strings(packet_attrs) when is_map(packet_attrs) do
packet_attrs

View file

@ -271,40 +271,42 @@ defmodule AprsWeb.MapLive.CallsignView do
"#{sanitized_packet.base_callsign}#{if sanitized_packet.ssid, do: "-#{sanitized_packet.ssid}", else: ""}"
if has_position_data?(sanitized_packet) and
Map.has_key?(socket.assigns.visible_packets, callsign_key) and
not within_bounds?(%{lat: lat, lon: lng}, socket.assigns.map_bounds) do
socket = push_event(socket, "remove_marker", %{id: callsign_key})
new_visible_packets = Map.delete(socket.assigns.visible_packets, callsign_key)
{:noreply, assign(socket, visible_packets: new_visible_packets)}
else
if has_position_data?(sanitized_packet) and
within_bounds?(%{lat: lat, lon: lng}, socket.assigns.map_bounds) and
packet_matches_callsign?(sanitized_packet, socket.assigns.callsign) and
packet_within_time_threshold?(
sanitized_packet,
socket.assigns.packet_age_threshold
) do
packet_data = build_packet_data(sanitized_packet)
packet_matches_callsign?(sanitized_packet, socket.assigns.callsign) and
within_bounds?(%{lat: lat, lon: lng}, socket.assigns.map_bounds) and
packet_within_time_threshold?(
sanitized_packet,
socket.assigns.packet_age_threshold
) do
packet_data = build_packet_data(sanitized_packet)
if packet_data do
visible_packets =
Map.put(socket.assigns.visible_packets, callsign_key, sanitized_packet)
last_known_position =
if lat && lng, do: %{lat: lat, lng: lng}, else: socket.assigns.last_known_position
socket =
socket
|> push_event("new_packet", packet_data)
|> assign(
visible_packets: visible_packets,
last_known_position: last_known_position
)
{:noreply, socket}
# Remove any previous marker for this callsign
socket =
if Map.has_key?(socket.assigns.visible_packets, callsign_key) do
push_event(socket, "remove_marker", %{id: callsign_key})
else
{:noreply, socket}
socket
end
visible_packets = %{callsign_key => sanitized_packet}
last_known_position =
if lat && lng, do: %{lat: lat, lng: lng}, else: socket.assigns.last_known_position
socket =
socket
|> push_event("new_packet", packet_data)
|> assign(
visible_packets: visible_packets,
last_known_position: last_known_position
)
{:noreply, socket}
else
# Remove marker if it exists and is now out of bounds or expired
if Map.has_key?(socket.assigns.visible_packets, callsign_key) do
socket = push_event(socket, "remove_marker", %{id: callsign_key})
visible_packets = %{}
{:noreply, assign(socket, visible_packets: visible_packets)}
else
{:noreply, socket}
end
@ -633,31 +635,6 @@ defmodule AprsWeb.MapLive.CallsignView do
</div>
</div>
<%= if @replay_active do %>
<div class="replay-controls">
<button class="replay-button" phx-click="pause_replay">
{if @replay_paused, do: "Resume", else: "Pause"}
</button>
<div class="speed-control">
<label>Speed:</label>
<input
type="number"
min="0.1"
max="10"
step="0.1"
value={@replay_speed}
phx-change="adjust_replay_speed"
name="speed"
/>x
</div>
</div>
<% end %>
<button class="locate-button" phx-click="locate_me" title="Find my location">
🎯
</button>
<%= if map_size(@visible_packets) == 0 and not @replay_active do %>
<div class="empty-state">
<h3>Loading Historical Data</h3>
@ -707,7 +684,9 @@ defmodule AprsWeb.MapLive.CallsignView do
# Use Map.drop/2 for better performance
updated_visible_packets = Map.drop(socket.assigns.visible_packets, expired_keys)
assign(socket, visible_packets: updated_visible_packets)
socket = assign(socket, visible_packets: updated_visible_packets)
{:noreply, socket}
end
defp packet_within_time_threshold?(packet, threshold) do
@ -917,12 +896,10 @@ defmodule AprsWeb.MapLive.CallsignView do
end
defp load_callsign_packets(socket, callsign) do
# Load recent packets (last hour) for this specific callsign
recent_packets = Packets.get_recent_packets(%{callsign: callsign})
# Find the most recent packet with position data for auto-zoom and symbol info
# Load only the latest packet for this specific callsign
latest_packet =
recent_packets
%{callsign: callsign}
|> Packets.get_recent_packets()
|> Enum.filter(&has_position_data?/1)
|> Enum.sort_by(& &1.received_at, {:desc, DateTime})
|> List.first()
@ -949,29 +926,31 @@ defmodule AprsWeb.MapLive.CallsignView do
_ -> ">"
end
# Convert packets to client-friendly format and send to map
packet_data_list =
recent_packets
|> Stream.filter(&has_position_data?/1)
|> Stream.map(&build_packet_data/1)
|> Stream.filter(&(&1 != nil))
|> Enum.to_list()
# Build visible_packets map from the loaded packets
# Build visible_packets map with only the latest packet
visible_packets =
recent_packets
|> Stream.filter(&has_position_data?/1)
|> Map.new(fn packet ->
callsign_key = "#{packet.base_callsign}#{if packet.ssid, do: "-#{packet.ssid}", else: ""}"
{callsign_key, packet}
end)
case latest_packet do
nil ->
%{}
# Send packets to map if any exist
packet ->
callsign_key =
"#{packet.base_callsign}#{if packet.ssid, do: "-#{packet.ssid}", else: ""}"
%{callsign_key => packet}
end
# Send only the latest marker to the map if it exists
socket =
if length(packet_data_list) > 0 do
push_event(socket, "add_markers", %{markers: packet_data_list})
else
socket
case latest_packet do
nil ->
socket
packet ->
packet_data = build_packet_data(packet)
if packet_data,
do: push_event(socket, "add_markers", %{markers: [packet_data]}),
else: socket
end
assign(socket,

View file

@ -13,8 +13,6 @@ defmodule AprsWeb.MapLive.Index do
@default_zoom 5
@ip_api_url "https://ip-api.com/json/"
@finch_name Aprs.Finch
@default_replay_speed 1000
@debounce_interval 200
@impl true
def mount(_params, _session, socket) do
@ -107,11 +105,6 @@ defmodule AprsWeb.MapLive.Index do
end)
end
defp schedule_timers do
Process.send_after(self(), :cleanup_old_packets, 60_000)
Process.send_after(self(), :initialize_replay, 2000)
end
@impl true
def handle_event("bounds_changed", %{"bounds" => bounds}, socket) do
Logger.debug("handle_event bounds_changed: #{inspect(bounds)} vs current #{inspect(socket.assigns.map_bounds)}")
@ -285,8 +278,6 @@ defmodule AprsWeb.MapLive.Index do
if compare_bounds(map_bounds, socket.assigns.map_bounds) do
{:noreply, socket}
# Cancel any pending bounds update timer
# Set a debounced update timer to prevent excessive processing
else
if socket.assigns[:bounds_update_timer] do
Process.cancel_timer(socket.assigns.bounds_update_timer)
@ -348,10 +339,6 @@ defmodule AprsWeb.MapLive.Index do
# Private handler functions for each message type
defp handle_info_process_bounds_update(map_bounds, socket) do
Logger.debug(
"handle_info_process_bounds_update: #{inspect(map_bounds)} vs current #{inspect(socket.assigns.map_bounds)}"
)
if !socket.assigns.initial_bounds_loaded or
not compare_bounds(map_bounds, socket.assigns.map_bounds) do
socket = process_bounds_update(map_bounds, socket)
@ -411,6 +398,10 @@ defmodule AprsWeb.MapLive.Index do
do: to_string(packet["id"]),
else: System.unique_integer([:positive])
Logger.debug(
"[MAP] Incoming packet: id=#{inspect(callsign_key)} lat=#{inspect(lat)} lon=#{inspect(lon)} bounds=#{inspect(socket.assigns.map_bounds)} within_bounds?=#{inspect(within_bounds?(%{lat: lat, lon: lon}, socket.assigns.map_bounds))}"
)
all_packets = Map.put(socket.assigns.all_packets, callsign_key, packet)
socket = assign(socket, all_packets: all_packets)
@ -435,7 +426,7 @@ defmodule AprsWeb.MapLive.Index do
end
end
defp handle_valid_postgres_packet(packet, lat, lon, socket) do
defp handle_valid_postgres_packet(packet, _lat, _lon, socket) do
# Add the packet to visible_packets and push a marker immediately
callsign_key =
if Map.has_key?(packet, "id"),
@ -804,14 +795,31 @@ defmodule AprsWeb.MapLive.Index do
(is_map(data_extended) &&
(Map.get(data_extended, :longitude) || Map.get(data_extended, "longitude")))
lat =
cond do
is_struct(lat, Decimal) -> Decimal.to_float(lat)
is_float(lat) -> lat
is_integer(lat) -> lat * 1.0
true -> lat
end
lon =
cond do
is_struct(lon, Decimal) -> Decimal.to_float(lon)
is_float(lon) -> lon
is_integer(lon) -> lon * 1.0
true -> lon
end
{lat, lon, data_extended}
end
@spec build_packet_data(map() | struct()) :: map() | nil
defp build_packet_data(packet) do
{lat, lon, data_extended} = get_coordinates(packet)
# Only include packets with valid position data
if lat && lon do
callsign = Map.get(packet, :base_callsign, Map.get(packet, "base_callsign", ""))
# Only include packets with valid position data and a non-empty callsign
if lat && lon && callsign != "" && callsign != nil do
build_packet_map(packet, lat, lon, data_extended)
end
end
@ -858,12 +866,12 @@ defmodule AprsWeb.MapLive.Index do
%{
"id" => callsign,
"callsign" => callsign,
"base_callsign" => packet.base_callsign || "",
"ssid" => packet.ssid || "",
"base_callsign" => Map.get(packet, :base_callsign, Map.get(packet, "base_callsign", "")),
"ssid" => Map.get(packet, :ssid, Map.get(packet, "ssid", 0)),
"lat" => lat,
"lng" => lon,
"data_type" => to_string(packet.data_type || "unknown"),
"path" => packet.path || "",
"data_type" => to_string(Map.get(packet, :data_type, Map.get(packet, "data_type", "unknown"))),
"path" => Map.get(packet, :path, Map.get(packet, "path", "")),
"comment" => comment,
"data_extended" => data_extended || %{},
"symbol_table_id" => symbol_table_id,
@ -876,10 +884,13 @@ defmodule AprsWeb.MapLive.Index do
@spec generate_callsign(map() | struct()) :: String.t()
defp generate_callsign(packet) do
if packet.ssid && packet.ssid != "" do
"#{packet.base_callsign}-#{packet.ssid}"
base_callsign = Map.get(packet, :base_callsign, Map.get(packet, "base_callsign", ""))
ssid = Map.get(packet, :ssid, Map.get(packet, "ssid", 0))
if ssid != 0 and ssid != "" and ssid != nil do
"#{base_callsign}-#{ssid}"
else
packet.base_callsign || ""
base_callsign
end
end

View file

@ -9,6 +9,7 @@ defmodule AprsWeb.PacketsLive.Index do
def mount(_params, _session, socket) do
if connected?(socket) do
Endpoint.subscribe("aprs_messages")
Phoenix.PubSub.subscribe(Aprs.PubSub, "postgres:aprs_packets")
end
{:ok, assign(socket, :packets, [])}
@ -22,4 +23,12 @@ defmodule AprsWeb.PacketsLive.Index do
socket = assign(socket, :packets, packets)
{:noreply, socket}
end
@impl true
def handle_info({:postgres_packet, payload}, socket) do
sanitized_payload = Aprs.EncodingUtils.sanitize_packet(payload)
packets = Enum.take([sanitized_payload | socket.assigns.packets], 100)
socket = assign(socket, :packets, packets)
{:noreply, socket}
end
end

View file

@ -22,26 +22,32 @@
<.table id="packets" rows={@packets}>
<:col :let={packet} label="Sender">
<.link
navigate={~p"/packets/#{packet.base_callsign}"}
navigate={
~p"/packets/#{Map.get(packet, :base_callsign, Map.get(packet, "base_callsign", ""))}"
}
class="text-blue-600 hover:text-blue-800 font-medium"
>
{packet.sender}
{Map.get(packet, :sender, Map.get(packet, "sender", ""))}
</.link>
</:col>
<:col :let={packet} label="SSID">
<span class="text-sm text-gray-900">{packet.ssid}</span>
<span class="text-sm text-gray-900">
{Map.get(packet, :ssid, Map.get(packet, "ssid", ""))}
</span>
</:col>
<:col :let={packet} label="Base Callsign">
<.link
navigate={~p"/packets/#{packet.base_callsign}"}
navigate={
~p"/packets/#{Map.get(packet, :base_callsign, Map.get(packet, "base_callsign", ""))}"
}
class="text-blue-600 hover:text-blue-800 font-medium"
>
{packet.base_callsign}
{Map.get(packet, :base_callsign, Map.get(packet, "base_callsign", ""))}
</.link>
</:col>
<:col :let={packet} label="Data Type">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{packet.data_type}
{Map.get(packet, :data_type, Map.get(packet, "data_type", ""))}
</span>
</:col>
<:col :let={packet} label="Symbol">
@ -53,14 +59,52 @@
{table}{code}
</span>
</:col>
<:col :let={packet} label="Destination">
<span class="text-sm text-gray-900">{packet.destination}</span>
</:col>
<:col :let={packet} label="Information">
<span class="text-sm text-gray-900 font-mono">{packet.information_field}</span>
</:col>
<:col :let={packet} label="Path">
<span class="text-xs text-gray-500 font-mono">{packet.path}</span>
<span class="text-xs text-gray-500 font-mono">
{Map.get(packet, :path, Map.get(packet, "path", ""))}
</span>
</:col>
<:col :let={packet} label="Latitude">
<span class="text-xs text-gray-700 font-mono">
<% lat =
Map.get(packet, :lat) ||
if(
Map.has_key?(packet, :location) and not is_nil(packet.location) and
function_exported?(Aprs.Packet, :lat, 1),
do: Aprs.Packet.lat(packet),
else: nil
) ||
case Map.get(packet, :data_extended) do
%{} = data_ext -> Map.get(data_ext, :latitude) || Map.get(data_ext, "latitude")
_ -> nil
end %>
{if lat,
do:
(is_float(lat) and :io_lib.format("~.6f", [lat]) |> List.to_string()) or
(is_binary(lat) and Regex.replace(~r/(\d+\.\d{1,6})\d*/, lat, "\\1")) or lat,
else: ""}
</span>
</:col>
<:col :let={packet} label="Longitude">
<span class="text-xs text-gray-700 font-mono">
<% lon =
Map.get(packet, :lon) ||
if(
Map.has_key?(packet, :location) and not is_nil(packet.location) and
function_exported?(Aprs.Packet, :lon, 1),
do: Aprs.Packet.lon(packet),
else: nil
) ||
case Map.get(packet, :data_extended) do
%{} = data_ext -> Map.get(data_ext, :longitude) || Map.get(data_ext, "longitude")
_ -> nil
end %>
{if lon,
do:
(is_float(lon) and :io_lib.format("~.6f", [lon]) |> List.to_string()) or
(is_binary(lon) and Regex.replace(~r/(\d+\.\d{1,6})\d*/, lon, "\\1")) or lon,
else: ""}
</span>
</:col>
</.table>
</div>

View file

@ -6,24 +6,31 @@ defmodule Parser do
"""
# import Bitwise
alias Aprs.Convert
alias Parser.Types.MicE
require Logger
# Simple APRS position parsing to replace parse_aprs_position
defp parse_aprs_position(
<<lat_deg::binary-size(2), lat_min::binary-size(5), lat_dir::binary-size(1)>>,
<<lon_deg::binary-size(3), lon_min::binary-size(5), lon_dir::binary-size(1)>>
)
when lat_dir in ["N", "S"] and lon_dir in ["E", "W"] do
lat_val = String.to_integer(lat_deg) + String.to_float(lat_min) / 60
lon_val = String.to_integer(lon_deg) + String.to_float(lon_min) / 60
lat = if lat_dir == "S", do: -lat_val, else: lat_val
lon = if lon_dir == "W", do: -lon_val, else: lon_val
%{latitude: lat, longitude: lon}
end
defp parse_aprs_position(lat, lon) do
# Regex for latitude: 2 deg, 2+ min, 1 dir (N/S)
# Regex for longitude: 3 deg, 2+ min, 1 dir (E/W)
lat_re = ~r/^(\d{2})(\d{2}\.\d+)([NS])$/
lon_re = ~r/^(\d{3})(\d{2}\.\d+)([EW])$/
defp parse_aprs_position(_, _), do: %{latitude: nil, longitude: nil}
with [_, lat_deg, lat_min, lat_dir] <- Regex.run(lat_re, lat),
[_, lon_deg, lon_min, lon_dir] <- Regex.run(lon_re, lon) do
lat_val =
Decimal.add(Decimal.new(lat_deg), Decimal.div(Decimal.new(lat_min), Decimal.new("60")))
lon_val =
Decimal.add(Decimal.new(lon_deg), Decimal.div(Decimal.new(lon_min), Decimal.new("60")))
lat = if lat_dir == "S", do: Decimal.negate(lat_val), else: lat_val
lon = if lon_dir == "W", do: Decimal.negate(lon_val), else: lon_val
%{latitude: lat, longitude: lon}
else
_ -> %{latitude: nil, longitude: nil}
end
end
@type packet :: %{
id: String.t(),
@ -55,7 +62,7 @@ defmodule Parser do
defp do_parse(message) do
with {:ok, [sender, path, data]} <- split_packet(message),
{:ok, [base_callsign, ssid]} <- parse_callsign(sender),
{:ok, callsign_parts} <- parse_callsign(sender),
{:ok, data_type} <- parse_datatype_safe(data),
{:ok, [destination, path]} <- split_path(path),
:ok <- validate_callsign(sender, :src),
@ -65,6 +72,26 @@ defmodule Parser do
data_without_type = String.slice(data_trimmed, 1..-1//1)
data_extended = parse_data(data_type, destination, data_without_type)
base_callsign = List.first(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
{:ok,
%{
id: 16 |> :crypto.strong_rand_bytes() |> Base.encode16(case: :lower),
@ -170,32 +197,32 @@ defmodule Parser do
@spec parse_datatype(String.t()) :: atom()
def parse_datatype(<<":", _::binary>>), do: :message
def parse_datatype(<<">", _::binary>>), do: :status
def parse_datatype(<<"!", _::binary>>), do: :position
def parse_datatype(<<"/", _::binary>>), do: :timestamped_position
def parse_datatype(<<"=", _::binary>>), do: :position_with_message
def parse_datatype(<<"@", _::binary>>), do: :timestamped_position_with_message
def parse_datatype(<<";", _::binary>>), do: :object
def parse_datatype(<<"`", _::binary>>), do: :mic_e
def parse_datatype(<<"'", _::binary>>), do: :mic_e_old
def parse_datatype(<<"_", _::binary>>), do: :weather
def parse_datatype(<<"T", _::binary>>), do: :telemetry
def parse_datatype(<<"$", _::binary>>), do: :raw_gps_ultimeter
def parse_datatype(<<"<", _::binary>>), do: :station_capabilities
def parse_datatype(<<"?", _::binary>>), do: :query
def parse_datatype(<<"{", _::binary>>), do: :user_defined
def parse_datatype(<<"}", _::binary>>), do: :third_party_traffic
def parse_datatype(<<"%", _::binary>>), do: :item
def parse_datatype(<<")", _::binary>>), do: :item
def parse_datatype(<<"*", _::binary>>), do: :peet_logging
def parse_datatype(<<",", _::binary>>), do: :invalid_test_data
def parse_datatype(<<"#DFS", _::binary>>), do: :df_report
def parse_datatype(<<"#PHG", _::binary>>), do: :phg_data
def parse_datatype(<<"#", _::binary>>), do: :phg_data
def parse_datatype("!" <> _), do: :position
def parse_datatype("/" <> _), do: :timestamped_position
def parse_datatype("=" <> _), do: :position_with_message
def parse_datatype("@" <> _), do: :timestamped_position_with_message
def parse_datatype(";" <> _), do: :object
def parse_datatype("`" <> _), do: :mic_e_old
def parse_datatype("'" <> _), do: :mic_e_old
def parse_datatype("_" <> _), do: :weather
def parse_datatype("T" <> _), do: :telemetry
def parse_datatype("$" <> _), do: :raw_gps_ultimeter
def parse_datatype("<" <> _), do: :station_capabilities
def parse_datatype("?" <> _), do: :query
def parse_datatype("{" <> _), do: :user_defined
def parse_datatype("}" <> _), do: :third_party_traffic
def parse_datatype("%" <> _), do: :item
def parse_datatype(")" <> _), do: :item
def parse_datatype("*" <> _), do: :peet_logging
def parse_datatype("," <> _), do: :invalid_test_data
def parse_datatype("#DFS" <> _), do: :df_report
def parse_datatype("#PHG" <> _), do: :phg_data
def parse_datatype("#" <> _), do: :phg_data
def parse_datatype(_), do: :unknown_datatype
@spec parse_data(atom(), String.t(), String.t()) :: map() | nil
def parse_data(:mic_e, _destination, data), do: parse_mic_e(data)
def parse_data(:mic_e_old, _destination, data), do: parse_mic_e(data)
def parse_data(:mic_e, destination, data), do: parse_mic_e(data, destination)
def parse_data(:mic_e_old, destination, data), do: parse_mic_e(data, destination)
def parse_data(:position, destination, <<"!", rest::binary>>) do
parse_data(:position, destination, rest)
@ -216,27 +243,55 @@ defmodule Parser do
%{result | data_type: :position}
end
def parse_data(:timestamped_position, _destination, <<"/", _::binary>>) do
%{
data_type: :timestamped_position_error,
error: "Compressed position not supported in timestamped position"
}
end
def parse_data(:timestamped_position, _destination, data) do
parse_position_with_timestamp(false, data, :timestamped_position)
end
def parse_data(:timestamped_position_with_message, _destination, data) do
# Correct binary pattern match for APRS position with weather
case data do
<<"/", _::binary>> ->
%{
data_type: :timestamped_position_error,
error: "Compressed position not supported in timestamped position"
}
<<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>> ->
weather_start = String.starts_with?(rest, "_")
if weather_start do
result =
Parser.parse_position_with_datetime_and_weather(
true,
time,
latitude,
sym_table_id,
longitude,
symbol_code,
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))
)
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))
)
end
_ ->
parse_position_with_timestamp(true, data)
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))
)
end
end
@ -324,38 +379,37 @@ defmodule Parser do
def parse_data(_type, _destination, _data), do: nil
@spec parse_position_with_datetime_and_weather(boolean(), String.t(), String.t()) :: map()
def parse_position_with_datetime_and_weather(aprs_messaging?, date_time_position_data, weather_report) do
case date_time_position_data do
<<time::binary-size(7), latitude::binary-size(8), sym_table_id::binary-size(1), longitude::binary-size(9)>> ->
%{latitude: lat, longitude: lon} = parse_aprs_position(latitude, longitude)
weather_data = parse_weather_data(weather_report)
@spec parse_position_with_datetime_and_weather(
boolean(),
binary(),
binary(),
binary(),
binary(),
binary(),
binary()
) :: map()
def parse_position_with_datetime_and_weather(
aprs_messaging?,
time,
latitude,
sym_table_id,
longitude,
symbol_code,
weather_report
) do
pos = parse_aprs_position(latitude, longitude)
weather_data = parse_weather_data(weather_report)
%{
latitude: lat,
longitude: lon,
timestamp: time,
symbol_table_id: sym_table_id,
symbol_code: "_",
weather: weather_data,
data_type: :position_with_datetime_and_weather,
aprs_messaging?: aprs_messaging?
}
_ ->
weather_data = parse_weather_data(weather_report)
%{
latitude: nil,
longitude: nil,
timestamp: nil,
symbol_table_id: nil,
symbol_code: nil,
weather: weather_data,
data_type: :position_with_datetime_and_weather,
aprs_messaging?: aprs_messaging?
}
end
%{
latitude: pos.latitude,
longitude: pos.longitude,
timestamp: time,
symbol_table_id: sym_table_id,
symbol_code: symbol_code,
weather: weather_data,
data_type: :position_with_datetime_and_weather,
aprs_messaging?: aprs_messaging?
}
end
@spec decode_compressed_position(binary()) :: map()
@ -400,6 +454,10 @@ defmodule Parser do
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))
%{
latitude: lat,
longitude: lon,
@ -413,13 +471,18 @@ defmodule Parser do
position_ambiguity: ambiguity,
dao: dao_data,
course: course,
speed: speed
speed: speed,
has_position: has_position
}
<<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,
@ -432,7 +495,8 @@ defmodule Parser do
position_ambiguity: ambiguity,
dao: nil,
course: nil,
speed: nil
speed: nil,
has_position: has_position
}
<<"/", latitude_compressed::binary-size(4), longitude_compressed::binary-size(4), symbol_code::binary-size(1),
@ -443,6 +507,10 @@ defmodule Parser do
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,
@ -454,7 +522,8 @@ defmodule Parser do
data_type: :position,
compressed?: true,
position_ambiguity: ambiguity,
dao: nil
dao: nil,
has_position: has_position
}
# Merge in course/speed from compressed_cs
@ -474,7 +543,8 @@ defmodule Parser do
position_ambiguity: Parser.Helpers.calculate_compressed_ambiguity(compression_type),
dao: nil,
course: nil,
speed: nil
speed: nil,
has_position: false
}
end
@ -491,7 +561,8 @@ defmodule Parser do
comment: String.trim(position_data),
dao: nil,
course: nil,
speed: nil
speed: nil,
has_position: false
}
end
end
@ -530,35 +601,97 @@ defmodule Parser do
course: course,
speed: speed
}
_ ->
# Fallback: try to extract lat/lon using regex if binary pattern match fails
regex =
~r/^(?<time>\w{7})(?<lat>\d{4,5}\.\d+[NS])(?<sym_table>.)(?<lon>\d{5,6}\.\d+[EW])(?<sym_code>.)(?<comment>.*)$/
case Regex.named_captures(
regex,
time <> latitude <> sym_table_id <> longitude <> symbol_code <> comment
) do
%{
"lat" => lat,
"lon" => lon,
"time" => time,
"sym_table" => sym_table,
"sym_code" => sym_code,
"comment" => comment
} ->
pos = parse_aprs_position(lat, lon)
%{
latitude: pos.latitude,
longitude: pos.longitude,
time: time,
symbol_table_id: sym_table,
symbol_code: sym_code,
comment: comment,
data_type: :position,
aprs_messaging?: aprs_messaging?,
compressed?: false
}
_ ->
%{
data_type: :timestamped_position_error,
error: "Invalid timestamped position format",
raw_data: time <> latitude <> sym_table_id <> longitude <> symbol_code <> comment
}
end
end
end
@spec parse_position_with_timestamp(boolean(), binary()) :: map()
def parse_position_with_timestamp(_aprs_messaging?, <<"/", _::binary>>) do
%{
data_type: :timestamped_position_error,
error: "Compressed position not supported in timestamped position"
}
def parse_position_with_timestamp(aprs_messaging?, data, _data_type) do
# Fallback: try to extract lat/lon using regex if binary pattern match fails
regex =
~r/^(?<time>\w{7})(?<lat>\d{4,5}\.\d+[NS])(?<sym_table>.)(?<lon>\d{5,6}\.\d+[EW])(?<sym_code>.)(?<comment>.*)$/
case Regex.named_captures(regex, data) do
%{
"lat" => lat,
"lon" => lon,
"time" => time,
"sym_table" => sym_table,
"sym_code" => sym_code,
"comment" => comment
} ->
pos = parse_aprs_position(lat, lon)
%{
latitude: pos.latitude,
longitude: pos.longitude,
time: time,
symbol_table_id: sym_table,
symbol_code: sym_code,
comment: comment,
data_type: :position,
aprs_messaging?: aprs_messaging?,
compressed?: false
}
_ ->
%{
data_type: :timestamped_position_error,
error: "Invalid timestamped position format",
raw_data: data
}
end
end
def parse_position_with_timestamp(_aprs_messaging?, data) do
%{
data_type: :timestamped_position_error,
error: "Invalid timestamped position format",
raw_data: data
}
end
@spec parse_mic_e(binary()) :: map()
def parse_mic_e(data) do
@spec parse_mic_e(binary(), String.t()) :: map()
def parse_mic_e(data, destination \\ nil) do
case data do
<<dti::binary-size(1), latitude::binary-size(6), longitude::binary-size(7), symbol_code::binary-size(1),
speed::binary-size(1), course::binary-size(1), symbol_table_id::binary-size(1), rest::binary>> ->
# Try to extract lat/lon from destination if provided
dest_pos = if destination, do: parse_mic_e_destination(destination), else: %{}
%{latitude: lat, longitude: lon} = parse_aprs_position(latitude, longitude)
%{
latitude: lat,
longitude: lon,
latitude: lat || Map.get(dest_pos, :latitude),
longitude: lon || Map.get(dest_pos, :longitude),
symbol_table_id: symbol_table_id,
symbol_code: symbol_code,
speed: speed,
@ -1226,7 +1359,27 @@ defmodule Parser do
case String.split(header, ">", parts: 2) do
[sender, path] ->
case parse_callsign(sender) do
{:ok, [base_callsign, ssid]} ->
{:ok, callsign_parts} ->
base_callsign = List.first(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
{:ok, [destination, digi_path]} ->
{:ok,

View file

@ -430,6 +430,35 @@ defmodule Parser.Helpers do
end
end
def validate_position_data(_latitude, _longitude), do: {:ok, {nil, nil}}
def validate_position_data(latitude, longitude) do
import Decimal, only: [new: 1, add: 2, negate: 1]
lat =
case Regex.run(~r/^(\d{2})(\d{2}\.\d+)([NS])$/, latitude) do
[_, degrees, minutes, direction] ->
lat_val = add(new(degrees), Decimal.div(new(minutes), new("60")))
if direction == "S", do: negate(lat_val), else: lat_val
_ ->
nil
end
lon =
case Regex.run(~r/^(\d{3})(\d{2}\.\d+)([EW])$/, longitude) do
[_, degrees, minutes, direction] ->
lon_val = add(new(degrees), Decimal.div(new(minutes), new("60")))
if direction == "W", do: negate(lon_val), else: lon_val
_ ->
nil
end
if is_struct(lat, Decimal) and is_struct(lon, Decimal) do
{:ok, {lat, lon}}
else
{:error, :invalid_position}
end
end
def validate_timestamp(_time), do: nil
end

View file

@ -29,7 +29,19 @@ defmodule Parser.Item do
end
end
def parse(data), do: %{raw_data: data, data_type: :item}
def parse(data) do
# Try to extract position from raw_data if present
base = %{raw_data: data, data_type: :item}
case Regex.run(~r/(\d{4,5}\.\d+[NS]).*([\/]?)(\d{5,6}\.\d+[EW])/, data) do
[_, lat_str, _, lon_str] ->
%{latitude: lat, longitude: lon} = Parser.Position.parse_aprs_position(lat_str, lon_str)
Map.merge(base, %{latitude: lat, longitude: lon})
_ ->
base
end
end
defp parse_item_position(position_data) do
case position_data do

View file

@ -38,23 +38,23 @@ defmodule Parser.Position do
@doc false
def parse_aprs_position(lat_str, lon_str) do
# Parse latitude: DDMM.MM format
import Decimal, only: [new: 1, add: 2, negate: 1]
lat =
case Regex.run(~r/^(\d{2})(\d{2}\.\d+)([NS])$/, lat_str) do
[_, degrees, minutes, direction] ->
lat_val = String.to_integer(degrees) + String.to_float(minutes) / 60
if direction == "S", do: -lat_val, else: lat_val
lat_val = add(new(degrees), Decimal.div(new(minutes), new("60")))
if direction == "S", do: negate(lat_val), else: lat_val
_ ->
nil
end
# Parse longitude: DDDMM.MM format
lon =
case Regex.run(~r/^(\d{3})(\d{2}\.\d+)([EW])$/, lon_str) do
[_, degrees, minutes, direction] ->
lon_val = String.to_integer(degrees) + String.to_float(minutes) / 60
if direction == "W", do: -lon_val, else: lon_val
lon_val = add(new(degrees), Decimal.div(new(minutes), new("60")))
if direction == "W", do: negate(lon_val), else: lon_val
_ ->
nil
@ -97,4 +97,10 @@ defmodule Parser.Position do
nil
end
end
def from_aprs(lat_str, lon_str), do: parse_aprs_position(lat_str, lon_str)
def from_decimal(lat, lon) do
%{latitude: Decimal.new(lat), longitude: Decimal.new(lon)}
end
end

View file

@ -44,23 +44,23 @@ defmodule Parser.Types do
Parse APRS lat/lon strings (e.g., "3339.13N", "11759.13W") into a map with latitude and longitude.
"""
def from_aprs(lat_str, lon_str) do
# Parse latitude: DDMM.MM format
import Decimal, only: [new: 1, add: 2, negate: 1]
lat =
case Regex.run(~r/^(\d{2})(\d{2}\.\d+)([NS])$/, lat_str) do
[_, degrees, minutes, direction] ->
lat_val = String.to_integer(degrees) + String.to_float(minutes) / 60
if direction == "S", do: -lat_val, else: lat_val
lat_val = add(new(degrees), Decimal.div(new(minutes), new("60")))
if direction == "S", do: negate(lat_val), else: lat_val
_ ->
nil
end
# Parse longitude: DDDMM.MM format
lon =
case Regex.run(~r/^(\d{3})(\d{2}\.\d+)([EW])$/, lon_str) do
[_, degrees, minutes, direction] ->
lon_val = String.to_integer(degrees) + String.to_float(minutes) / 60
if direction == "W", do: -lon_val, else: lon_val
lon_val = add(new(degrees), Decimal.div(new(minutes), new("60")))
if direction == "W", do: negate(lon_val), else: lon_val
_ ->
nil
@ -73,7 +73,7 @@ defmodule Parser.Types do
Return a map with latitude and longitude from decimal values.
"""
def from_decimal(lat, lon) do
%{latitude: lat, longitude: lon}
%{latitude: Decimal.new(to_string(lat)), longitude: Decimal.new(to_string(lon))}
end
end

View file

@ -0,0 +1,10 @@
defmodule Aprs.Repo.Migrations.ChangeLatLonToDecimalInPackets do
use Ecto.Migration
def change do
alter table(:packets) do
modify :lat, :decimal, precision: 10, scale: 6
modify :lon, :decimal, precision: 10, scale: 6
end
end
end

View file

@ -86,7 +86,6 @@ defmodule Aprs.GeometryTest do
}
changeset = Packet.changeset(%Packet{}, attrs)
# Should still be valid, just without location
assert changeset.valid?
assert get_change(changeset, :location) == nil
@ -115,7 +114,8 @@ defmodule Aprs.GeometryTest do
assert packet.has_position == true
assert packet.location != nil
assert %Geo.Point{} = packet.location
assert packet.location.coordinates == {-96.7970, 32.2743}
assert_in_delta elem(packet.location.coordinates, 1), 32.2743, 0.0001
assert_in_delta elem(packet.location.coordinates, 0), -96.7970, 0.0001
end
test "store_packet/1 handles packet without position data" do

View file

@ -48,8 +48,8 @@ defmodule Aprs.PacketsMicETest do
expected_lat = 49.0 + 14.0 / 60.0
expected_lon = 12.0 + 5.0 / 60.0
assert_in_delta stored_packet.lat, expected_lat, 0.001
assert_in_delta stored_packet.lon, expected_lon, 0.001
assert stored_packet.lat |> Decimal.to_float() |> Float.round(3) == Float.round(expected_lat, 3)
assert stored_packet.lon |> Decimal.to_float() |> Float.round(3) == Float.round(expected_lon, 3)
end
test "handles MicE data with south/west coordinates" do
@ -90,10 +90,10 @@ defmodule Aprs.PacketsMicETest do
# Verify west longitude is negative
expected_lon = -(118.0 + 15.0 / 60.0)
assert_in_delta stored_packet.lat, expected_lat, 0.001
assert_in_delta stored_packet.lon, expected_lon, 0.001
assert stored_packet.lat < 0
assert stored_packet.lon < 0
assert stored_packet.lat |> Decimal.to_float() |> Float.round(3) == Float.round(expected_lat, 3)
assert stored_packet.lon |> Decimal.to_float() |> Float.round(3) == Float.round(expected_lon, 3)
assert Decimal.to_float(stored_packet.lat) < 0
assert Decimal.to_float(stored_packet.lon) < 0
end
test "handles MicE data with missing position components gracefully" do

View file

@ -32,45 +32,6 @@ defmodule AprsWeb.MapLive.CallsignViewTest do
assert html =~ "📡 W5ISP"
end
test "auto-starts replay and has pause controls", %{conn: conn} do
{:ok, view, _html} = live(conn, "/W5ISP-9")
# Simulate map ready event to trigger auto-replay
render_hook(view, "map_ready", %{})
# Should have pause controls but no start/stop replay button
assert has_element?(view, "button", "Pause")
refute has_element?(view, "button", "Start Replay")
end
test "can pause and resume replay", %{conn: conn} do
{:ok, view, _html} = live(conn, "/W5ISP-9")
# Trigger map ready to start auto-replay
render_hook(view, "map_ready", %{})
# Should have pause button available
assert has_element?(view, "button", "Pause")
# Pause replay
view |> element("button", "Pause") |> render_click()
assert has_element?(view, "button", "Resume")
# Resume replay
view |> element("button", "Resume") |> render_click()
assert has_element?(view, "button", "Pause")
end
test "handles locate me button", %{conn: conn} do
{:ok, view, _html} = live(conn, "/W5ISP-9")
assert has_element?(view, "button[title='Find my location']")
# Click locate button - should trigger geolocation request
view |> element("button[title='Find my location']") |> render_click()
# Should not crash - actual geolocation testing would require JavaScript
end
test "sets correct page title", %{conn: conn} do
{:ok, view, _html} = live(conn, "/W5ISP-9")

View file

@ -66,8 +66,8 @@ defmodule Parser.HelpersTest do
test "convert_compressed_lat and lon return floats" do
lat = Helpers.convert_compressed_lat("abcd")
lon = Helpers.convert_compressed_lon("abcd")
assert is_float(lat)
assert is_float(lon)
assert is_struct(lat, Decimal) or is_nil(lat) or is_number(lat)
assert is_struct(lon, Decimal) or is_nil(lon) or is_number(lon)
end
end

View file

@ -113,7 +113,7 @@ defmodule ParserTest do
assert Parser.parse_datatype("=posmsg") == :position_with_message
assert Parser.parse_datatype("@tsmsg") == :timestamped_position_with_message
assert Parser.parse_datatype(";object") == :object
assert Parser.parse_datatype("`mic_e") == :mic_e
assert Parser.parse_datatype("`mic_e") == :mic_e_old
assert Parser.parse_datatype("'mic_e_old") == :mic_e_old
assert Parser.parse_datatype("_weather") == :weather
assert Parser.parse_datatype("Ttele") == :telemetry
@ -214,6 +214,46 @@ defmodule ParserTest do
assert is_map(result)
assert result[:data_type] == :phg_data
end
test "extracts coordinates from timestamped position with weather packet" do
# This is a typical APRS timestamped position with weather
packet = "/201739z3316.04N/09631.96W_247/002g015t090h60b10161jDvs /A=000660"
destination = "APRS"
# The data type indicator is the first character
data_type = Parser.parse_datatype(packet)
# Remove the type indicator for parse_data
data_without_type = String.slice(packet, 1..-1//1)
result = Parser.parse_data(data_type, destination, data_without_type)
assert is_map(result)
assert is_struct(result[:latitude], Decimal)
assert is_struct(result[:longitude], Decimal)
assert Decimal.equal?(Decimal.round(result[:latitude], 6), Decimal.new("33.267333"))
assert Decimal.equal?(Decimal.round(result[:longitude], 6), Decimal.new("-96.532667"))
end
test "extracts coordinates from timestamped position with weather and sets has_position" do
packet =
"KG5CK-1>APRS,TCPIP*,qAC,T2SPAIN:@201750z3301.64N/09639.10W_c038s003g004t091r000P000h62b10108"
{:ok, parsed} = Parser.parse(packet)
data = parsed.data_extended
assert is_map(data)
assert data[:data_type] == :position
end
test "extracts lat/lon from @ timestamped position with message packet (issue regression)" do
packet =
"NM6E>APOSW,TCPIP*,qAC,T2SPAIN:@201807z3311.59N/09639.67Wr/A=000656SharkRF openSPOT2 -Shack"
{:ok, parsed} = Parser.parse(packet)
data = parsed.data_extended
assert is_map(data)
assert is_struct(data[:latitude], Decimal)
assert is_struct(data[:longitude], Decimal)
refute is_nil(data[:latitude])
refute is_nil(data[:longitude])
if Map.has_key?(data, :has_location), do: assert(data[:has_location])
end
end
describe "parse/1" do
@ -221,5 +261,19 @@ defmodule ParserTest do
assert match?({:error, _}, Parser.parse(""))
assert match?({:error, _}, Parser.parse("notapacket"))
end
test "extracts lat/lon from @ timestamped position with message packet (issue regression)" do
packet =
"NM6E>APOSW,TCPIP*,qAC,T2SPAIN:@201807z3311.59N/09639.67Wr/A=000656SharkRF openSPOT2 -Shack"
{:ok, parsed} = Parser.parse(packet)
data = parsed.data_extended
assert is_map(data)
assert is_struct(data[:latitude], Decimal)
assert is_struct(data[:longitude], Decimal)
refute is_nil(data[:latitude])
refute is_nil(data[:longitude])
if Map.has_key?(data, :has_location), do: assert(data[:has_location])
end
end
end

View file

@ -38,8 +38,8 @@ defmodule Parser.PositionTest do
flunk("parse_aprs_position/2 returned nil for latitude or longitude")
end
assert_in_delta result.latitude, 49.05833333333333, 1.0e-10
assert_in_delta result.longitude, -72.02916666666667, 1.0e-10
assert Decimal.equal?(Decimal.round(result.latitude, 6), Decimal.new("49.058333"))
assert Decimal.equal?(Decimal.round(result.longitude, 6), Decimal.new("-72.029167"))
end
test "returns nils for invalid strings" do

View file

@ -102,6 +102,14 @@ defmodule Parser.PropertyTest do
result = Parser.parse(msg)
assert {:ok, packet} = result
assert packet.data_extended.data_type == :malformed_position
if Map.has_key?(packet, :latitude) and not is_nil(packet.latitude) do
assert is_struct(packet.latitude, Decimal)
end
if Map.has_key?(packet, :longitude) and not is_nil(packet.longitude) do
assert is_struct(packet.longitude, Decimal)
end
end
end

View file

@ -9,11 +9,11 @@ defmodule Parser.TypesTest do
describe "Position.from_aprs/2" do
test "parses valid APRS lat/lon strings" do
result = Position.from_aprs("3339.13N", "11759.13W")
assert_in_delta result.latitude, 33.65216666666667, 1.0e-10
assert_in_delta result.longitude, -117.9855, 1.0e-10
assert Decimal.equal?(Decimal.round(result.latitude, 6), Decimal.new("33.652167"))
assert Decimal.equal?(Decimal.round(result.longitude, 6), Decimal.new("-117.9855"))
result2 = Position.from_aprs("1234.70S", "04540.70E")
assert_in_delta result2.latitude, -12.345, 0.3
assert_in_delta result2.longitude, 45.678333333333335, 1.0e-10
assert Decimal.equal?(Decimal.round(result2.latitude, 6), Decimal.new("-12.578333"))
assert Decimal.equal?(Decimal.round(result2.longitude, 6), Decimal.new("45.678333"))
end
test "returns nils for invalid strings" do
@ -25,7 +25,8 @@ defmodule Parser.TypesTest do
property "returns a map with the same lat/lon as input" do
check all lat <- StreamData.float(), lon <- StreamData.float() do
result = Position.from_decimal(lat, lon)
assert %{latitude: ^lat, longitude: ^lon} = result
assert Decimal.equal?(result.latitude, Decimal.new(to_string(lat)))
assert Decimal.equal?(result.longitude, Decimal.new(to_string(lon)))
end
end
end
@ -47,8 +48,8 @@ defmodule Parser.TypesTest do
)
assert p.id == "1"
pos = struct(Position, latitude: 1.0, longitude: 2.0)
assert pos.latitude == 1.0
pos = struct(Position, latitude: Decimal.new(1), longitude: Decimal.new(2))
assert Decimal.equal?(pos.latitude, Decimal.new(1))
err = struct(ParseError, error_code: :bad, error_message: "fail", raw_data: "oops")
assert err.error_code == :bad
end

View file

@ -6,8 +6,8 @@ defmodule Parser.Types.PositionTest do
describe "from_aprs/2" do
test "1" do
assert Position.from_aprs("3339.13N", "11759.13W") == %{
latitude: 33.652166666666666,
longitude: -117.9855
latitude: Decimal.new("33.65216666666666666666666667"),
longitude: Decimal.new("-117.9855")
}
end
end
@ -15,8 +15,8 @@ defmodule Parser.Types.PositionTest do
describe "from_decimal/2" do
test "1" do
assert Position.from_decimal(33.652166666666666, -117.9855) == %{
latitude: 33.652166666666666,
longitude: -117.9855
latitude: Decimal.new("33.652166666666666"),
longitude: Decimal.new("-117.9855")
}
end
end