major refactor and fixes with streaming
This commit is contained in:
parent
d8454d6267
commit
ecee5fd677
24 changed files with 713 additions and 347 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
363
lib/parser.ex
363
lib/parser.ex
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue