improvements
This commit is contained in:
parent
0d070cf470
commit
e4d72ec6cc
17 changed files with 438 additions and 243 deletions
6
.dialyzer_ignore.exs
Normal file
6
.dialyzer_ignore.exs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[
|
||||
{{"lib/parser.ex", 1}, :pattern_match, "The pattern can never match the type."},
|
||||
{{"lib/parser.ex", 893}, :pattern_match, "The pattern can never match the type."},
|
||||
{{"lib/parser.ex", 324}, :pattern_match_cov,
|
||||
"The pattern :variable_ can never match, because previous clauses completely cover the type {:error, <<_::224>>}."}
|
||||
]
|
||||
|
|
@ -89,6 +89,16 @@ 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} ->
|
||||
|
|
|
|||
|
|
@ -749,9 +749,16 @@ defmodule AprsWeb.MapLive.CallsignView do
|
|||
|
||||
defp has_position_data?(packet) do
|
||||
case packet.data_extended do
|
||||
%MicE{} -> true
|
||||
%{latitude: lat, longitude: lon} when not is_nil(lat) and not is_nil(lon) -> true
|
||||
_ -> false
|
||||
%MicE{} ->
|
||||
true
|
||||
|
||||
%{latitude: lat, longitude: lon} when not is_nil(lat) and not is_nil(lon) ->
|
||||
true
|
||||
|
||||
_ ->
|
||||
lat = Map.get(packet, :lat) || Map.get(packet, "lat")
|
||||
lon = Map.get(packet, :lon) || Map.get(packet, "lon")
|
||||
not is_nil(lat) and not is_nil(lon)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -793,19 +800,16 @@ defmodule AprsWeb.MapLive.CallsignView do
|
|||
|
||||
defp get_symbol_table_id(data) do
|
||||
cond do
|
||||
is_map(data) && Map.has_key?(data, :symbol_table_id) && data.symbol_table_id ->
|
||||
data.symbol_table_id
|
||||
is_map(data) && (Map.get(data, :symbol_table_id) || Map.get(data, "symbol_table_id")) ->
|
||||
Map.get(data, :symbol_table_id) || Map.get(data, "symbol_table_id")
|
||||
|
||||
is_map(data) && Map.has_key?(data, "symbol_table_id") && data["symbol_table_id"] ->
|
||||
data["symbol_table_id"]
|
||||
|
||||
is_map(data) && Map.has_key?(data, :packet) && Map.has_key?(data.packet, :symbol_table_id) &&
|
||||
data.packet.symbol_table_id ->
|
||||
data.packet.symbol_table_id
|
||||
is_map(data) && Map.has_key?(data, :packet) &&
|
||||
(Map.get(data.packet, :symbol_table_id) || Map.get(data.packet, "symbol_table_id")) ->
|
||||
Map.get(data.packet, :symbol_table_id) || Map.get(data.packet, "symbol_table_id")
|
||||
|
||||
is_map(data) && Map.has_key?(data, "packet") &&
|
||||
Map.has_key?(data["packet"], "symbol_table_id") && data["packet"]["symbol_table_id"] ->
|
||||
data["packet"]["symbol_table_id"]
|
||||
(Map.get(data["packet"], :symbol_table_id) || Map.get(data["packet"], "symbol_table_id")) ->
|
||||
Map.get(data["packet"], :symbol_table_id) || Map.get(data["packet"], "symbol_table_id")
|
||||
|
||||
true ->
|
||||
"/"
|
||||
|
|
@ -814,19 +818,16 @@ defmodule AprsWeb.MapLive.CallsignView do
|
|||
|
||||
defp get_symbol_code(data) do
|
||||
cond do
|
||||
is_map(data) && Map.has_key?(data, :symbol_code) && data.symbol_code ->
|
||||
data.symbol_code
|
||||
is_map(data) && (Map.get(data, :symbol_code) || Map.get(data, "symbol_code")) ->
|
||||
Map.get(data, :symbol_code) || Map.get(data, "symbol_code")
|
||||
|
||||
is_map(data) && Map.has_key?(data, "symbol_code") && data["symbol_code"] ->
|
||||
data["symbol_code"]
|
||||
is_map(data) && Map.has_key?(data, :packet) &&
|
||||
(Map.get(data.packet, :symbol_code) || Map.get(data.packet, "symbol_code")) ->
|
||||
Map.get(data.packet, :symbol_code) || Map.get(data.packet, "symbol_code")
|
||||
|
||||
is_map(data) && Map.has_key?(data, :packet) && Map.has_key?(data.packet, :symbol_code) &&
|
||||
data.packet.symbol_code ->
|
||||
data.packet.symbol_code
|
||||
|
||||
is_map(data) && Map.has_key?(data, "packet") && Map.has_key?(data["packet"], "symbol_code") &&
|
||||
data["packet"]["symbol_code"] ->
|
||||
data["packet"]["symbol_code"]
|
||||
is_map(data) && Map.has_key?(data, "packet") &&
|
||||
(Map.get(data["packet"], :symbol_code) || Map.get(data["packet"], "symbol_code")) ->
|
||||
Map.get(data["packet"], :symbol_code) || Map.get(data["packet"], "symbol_code")
|
||||
|
||||
true ->
|
||||
">"
|
||||
|
|
|
|||
|
|
@ -442,13 +442,19 @@ defmodule AprsWeb.MapLive.Enhanced do
|
|||
data_extended = packet.data_extended || %{}
|
||||
callsign = packet.base_callsign <> if packet.ssid, do: "-#{packet.ssid}", else: ""
|
||||
|
||||
symbol_table_id =
|
||||
Map.get(data_extended, :symbol_table_id) || Map.get(data_extended, "symbol_table_id") || "/"
|
||||
|
||||
symbol_code =
|
||||
Map.get(data_extended, :symbol_code) || Map.get(data_extended, "symbol_code") || ">"
|
||||
|
||||
%{
|
||||
id: callsign,
|
||||
callsign: callsign,
|
||||
lat: packet.lat,
|
||||
lng: packet.lon,
|
||||
symbol_table: data_extended["symbol_table_id"] || "/",
|
||||
symbol_code: data_extended["symbol_code"] || ">",
|
||||
symbol_table: symbol_table_id,
|
||||
symbol_code: symbol_code,
|
||||
historical: false,
|
||||
popup: build_popup_content(packet, callsign, false),
|
||||
timestamp: DateTime.to_unix(packet.created_at, :millisecond)
|
||||
|
|
@ -521,18 +527,18 @@ defmodule AprsWeb.MapLive.Enhanced do
|
|||
end)
|
||||
|> Enum.map(fn {callsign, _} -> callsign end)
|
||||
|
||||
# Remove old markers from client
|
||||
# Only update the client if the marker is present
|
||||
socket =
|
||||
Enum.reduce(old_markers, socket, fn callsign, acc_socket ->
|
||||
push_event(acc_socket, "remove_marker", %{id: callsign})
|
||||
end)
|
||||
|
||||
# Update server state
|
||||
active_markers =
|
||||
socket.assigns.active_markers
|
||||
|> Enum.reject(fn {callsign, _} -> callsign in old_markers end)
|
||||
|> Map.new()
|
||||
if old_markers == [] do
|
||||
socket
|
||||
else
|
||||
Enum.reduce(old_markers, socket, fn callsign, acc_socket ->
|
||||
push_event(acc_socket, "remove_marker", %{id: callsign})
|
||||
end)
|
||||
end
|
||||
|
||||
# Use Map.drop/2 for better performance
|
||||
active_markers = Map.drop(socket.assigns.active_markers, old_markers)
|
||||
assign(socket, :active_markers, active_markers)
|
||||
end
|
||||
|
||||
|
|
@ -615,9 +621,9 @@ defmodule AprsWeb.MapLive.Enhanced do
|
|||
end
|
||||
|
||||
defp has_position_data?(packet) do
|
||||
packet.has_position == true and
|
||||
packet.lat != nil and
|
||||
packet.lon != nil
|
||||
lat = Map.get(packet, :lat) || Map.get(packet, "lat")
|
||||
lon = Map.get(packet, :lon) || Map.get(packet, "lon")
|
||||
not is_nil(lat) and not is_nil(lon)
|
||||
end
|
||||
|
||||
defp within_bounds?(packet, bounds) do
|
||||
|
|
|
|||
|
|
@ -310,7 +310,7 @@ defmodule AprsWeb.MapLive.Index do
|
|||
push_event(acc, "remove_marker", %{id: k})
|
||||
end)
|
||||
|
||||
# Fetch the latest 100 packets within the new bounds and push to client
|
||||
# Fetch the latest packets within the new bounds and push to client (historical only)
|
||||
bounds_list = [map_bounds.west, map_bounds.south, map_bounds.east, map_bounds.north]
|
||||
now = DateTime.utc_now()
|
||||
one_hour_ago = DateTime.add(now, -3600, :second)
|
||||
|
|
@ -335,6 +335,7 @@ defmodule AprsWeb.MapLive.Index do
|
|||
socket
|
||||
end
|
||||
|
||||
# Only update visible_packets with the historical packets for the new bounds
|
||||
assign(socket, map_bounds: map_bounds, visible_packets: new_visible_packets)
|
||||
end
|
||||
|
||||
|
|
@ -351,8 +352,6 @@ defmodule AprsWeb.MapLive.Index do
|
|||
|
||||
def handle_info({:postgres_packet, packet}, socket), do: handle_info_postgres_packet(packet, socket)
|
||||
|
||||
def handle_info(:flush_packet_buffer, socket), do: handle_info_flush_packet_buffer(socket)
|
||||
|
||||
def handle_info(%Phoenix.Socket.Broadcast{topic: "aprs_messages", event: "packet", payload: packet}, socket),
|
||||
do: handle_info({:postgres_packet, packet}, socket)
|
||||
|
||||
|
|
@ -416,7 +415,8 @@ defmodule AprsWeb.MapLive.Index do
|
|||
all_packets = Map.put(socket.assigns.all_packets, callsign_key, packet)
|
||||
socket = assign(socket, all_packets: all_packets)
|
||||
|
||||
if is_nil(lat) or is_nil(lon),
|
||||
# Only add marker if it is within bounds and not already present
|
||||
if is_nil(lat) or is_nil(lon) or Map.has_key?(socket.assigns.visible_packets, callsign_key),
|
||||
do: {:noreply, socket},
|
||||
else: handle_valid_postgres_packet(packet, lat, lon, socket)
|
||||
end
|
||||
|
|
@ -691,62 +691,34 @@ defmodule AprsWeb.MapLive.Index do
|
|||
"""
|
||||
end
|
||||
|
||||
# Handle cleanup of old packets
|
||||
# Clean up expired markers from visible_packets and client, but do not re-query the DB
|
||||
defp handle_cleanup_old_packets(socket) do
|
||||
# Schedule next cleanup
|
||||
Process.send_after(self(), :cleanup_old_packets, 60_000)
|
||||
|
||||
one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second)
|
||||
packets_to_remove = get_packets_to_remove(socket, one_hour_ago)
|
||||
visible_packets = get_visible_packets(socket, one_hour_ago)
|
||||
|
||||
socket =
|
||||
if map_size(visible_packets) == map_size(socket.assigns.visible_packets) do
|
||||
assign(socket, packet_age_threshold: one_hour_ago)
|
||||
else
|
||||
update_socket_for_removed_packets(
|
||||
socket,
|
||||
visible_packets,
|
||||
one_hour_ago,
|
||||
packets_to_remove
|
||||
)
|
||||
end
|
||||
|
||||
if connected?(socket), do: Process.send_after(self(), :cleanup_old_packets, 60_000)
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
defp get_packets_to_remove(socket, one_hour_ago) do
|
||||
socket.assigns.visible_packets
|
||||
|> Enum.reject(fn {_key, packet} ->
|
||||
packet_within_time_threshold?(packet, one_hour_ago) &&
|
||||
within_bounds?(packet, socket.assigns.map_bounds)
|
||||
end)
|
||||
|> Enum.map(fn {callsign, _packet} -> callsign end)
|
||||
end
|
||||
|
||||
defp get_visible_packets(socket, one_hour_ago) do
|
||||
socket.assigns.visible_packets
|
||||
|> Enum.filter(fn {_key, packet} ->
|
||||
packet_within_time_threshold?(packet, one_hour_ago) &&
|
||||
within_bounds?(packet, socket.assigns.map_bounds)
|
||||
end)
|
||||
|> Map.new()
|
||||
end
|
||||
|
||||
defp update_socket_for_removed_packets(socket, visible_packets, one_hour_ago, packets_to_remove) do
|
||||
socket =
|
||||
socket
|
||||
|> push_event("refresh_markers", %{})
|
||||
|> assign(
|
||||
visible_packets: visible_packets,
|
||||
packet_age_threshold: one_hour_ago
|
||||
)
|
||||
|
||||
if Enum.any?(packets_to_remove) do
|
||||
Enum.reduce(packets_to_remove, socket, fn callsign, acc_socket ->
|
||||
push_event(acc_socket, "remove_marker", %{id: callsign})
|
||||
# Remove expired packets from visible_packets
|
||||
updated_visible_packets =
|
||||
socket.assigns.visible_packets
|
||||
|> Enum.filter(fn {_key, packet} ->
|
||||
packet_within_time_threshold?(packet, one_hour_ago)
|
||||
end)
|
||||
else
|
||||
socket
|
||||
end
|
||||
|> Map.new()
|
||||
|
||||
# Remove expired markers from the client
|
||||
expired_keys =
|
||||
socket.assigns.visible_packets
|
||||
|> Enum.reject(fn {_key, packet} ->
|
||||
packet_within_time_threshold?(packet, one_hour_ago)
|
||||
end)
|
||||
|> Enum.map(fn {key, _} -> key end)
|
||||
|
||||
socket =
|
||||
Enum.reduce(expired_keys, socket, fn key, acc ->
|
||||
push_event(acc, "remove_marker", %{id: key})
|
||||
end)
|
||||
|
||||
assign(socket, visible_packets: updated_visible_packets)
|
||||
end
|
||||
|
||||
# Check if a packet is within the time threshold (not too old)
|
||||
|
|
@ -879,12 +851,18 @@ defmodule AprsWeb.MapLive.Index do
|
|||
|
||||
@spec build_packet_map(map() | struct(), number(), number(), map() | nil) :: map()
|
||||
defp build_packet_map(packet, lat, lon, data_extended) do
|
||||
data_extended = data_extended || %{}
|
||||
callsign = generate_callsign(packet)
|
||||
symbol_table_id = get_in(data_extended, ["symbol_table_id"]) || "/"
|
||||
symbol_code = get_in(data_extended, ["symbol_code"]) || ">"
|
||||
|
||||
symbol_table_id =
|
||||
Map.get(data_extended, :symbol_table_id) || Map.get(data_extended, "symbol_table_id") || "/"
|
||||
|
||||
symbol_code =
|
||||
Map.get(data_extended, :symbol_code) || Map.get(data_extended, "symbol_code") || ">"
|
||||
|
||||
symbol_description =
|
||||
get_in(data_extended, ["symbol_description"]) || "Symbol: #{symbol_table_id}#{symbol_code}"
|
||||
Map.get(data_extended, :symbol_description) || Map.get(data_extended, "symbol_description") ||
|
||||
"Symbol: #{symbol_table_id}#{symbol_code}"
|
||||
|
||||
timestamp =
|
||||
cond do
|
||||
|
|
@ -898,7 +876,7 @@ defmodule AprsWeb.MapLive.Index do
|
|||
""
|
||||
end
|
||||
|
||||
comment = get_in(data_extended, ["comment"]) || ""
|
||||
comment = Map.get(data_extended, :comment) || Map.get(data_extended, "comment") || ""
|
||||
|
||||
popup = """
|
||||
<div class=\"aprs-popup\">
|
||||
|
|
|
|||
171
lib/parser.ex
171
lib/parser.ex
|
|
@ -1,3 +1,5 @@
|
|||
# @dialyzer {:nowarn_function, parse_data: 3}
|
||||
# @dialyzer {:nowarn_function, parse_object: 1}
|
||||
defmodule Parser do
|
||||
@moduledoc """
|
||||
Main parsing library
|
||||
|
|
@ -9,32 +11,20 @@ defmodule Parser do
|
|||
require Logger
|
||||
|
||||
# Simple APRS position parsing to replace parse_aprs_position
|
||||
defp parse_aprs_position(lat_str, lon_str) do
|
||||
# Parse latitude: DDMM.MM format
|
||||
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
|
||||
|
||||
_ ->
|
||||
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
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
|
||||
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(_, _), do: %{latitude: nil, longitude: nil}
|
||||
|
||||
@type packet :: %{
|
||||
id: String.t(),
|
||||
sender: String.t(),
|
||||
|
|
@ -94,9 +84,6 @@ defmodule Parser do
|
|||
"Invalid packet format" -> {:error, "Invalid packet format"}
|
||||
_ -> {:error, reason}
|
||||
end
|
||||
|
||||
_ ->
|
||||
{:error, "PARSE ERROR"}
|
||||
end
|
||||
rescue
|
||||
error ->
|
||||
|
|
@ -105,32 +92,37 @@ defmodule Parser do
|
|||
end
|
||||
|
||||
# Validate callsign for AX.25 compliance
|
||||
def validate_callsign(callsign, :src) do
|
||||
if is_binary(callsign) and String.match?(callsign, ~r/^[A-Z0-9\-]+$/) and
|
||||
not String.contains?(callsign, "*") do
|
||||
def validate_callsign(callsign, :src) when is_binary(callsign) do
|
||||
if regex_callsign_valid?(callsign) and not String.contains?(callsign, "*") do
|
||||
:ok
|
||||
else
|
||||
{:error, "Invalid source callsign"}
|
||||
end
|
||||
end
|
||||
|
||||
def validate_callsign(callsign, :dst) do
|
||||
cond do
|
||||
callsign == "" -> {:error, "Missing destination callsign"}
|
||||
not String.match?(callsign, ~r/^[A-Z0-9\-]+$/) -> {:error, "Invalid destination callsign"}
|
||||
true -> :ok
|
||||
end
|
||||
def validate_callsign(_callsign, :src), do: {:error, "Invalid source callsign"}
|
||||
|
||||
def validate_callsign("", :dst), do: {:error, "Missing destination callsign"}
|
||||
|
||||
def validate_callsign(callsign, :dst) when is_binary(callsign) do
|
||||
if regex_callsign_valid?(callsign), do: :ok, else: {:error, "Invalid destination callsign"}
|
||||
end
|
||||
|
||||
def validate_callsign(_callsign, :dst), do: {:error, "Invalid destination callsign"}
|
||||
|
||||
defp regex_callsign_valid?(callsign), do: String.match?(callsign, ~r/^[A-Z0-9\-]+$/)
|
||||
|
||||
# Validate path for too many components
|
||||
def validate_path(path) do
|
||||
if path != "" and length(String.split(path, ",")) > 8 do
|
||||
def validate_path(path) when is_binary(path) and path != "" do
|
||||
if length(String.split(path, ",")) > 8 do
|
||||
{:error, "Too many path components"}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
def validate_path(_), do: :ok
|
||||
|
||||
# Safely split packet into components
|
||||
@spec split_packet(String.t()) :: {:ok, [String.t()]} | {:error, String.t()}
|
||||
def split_packet(message) do
|
||||
|
|
@ -147,19 +139,15 @@ defmodule Parser do
|
|||
|
||||
# Safely split path into destination and digipeater path
|
||||
@spec split_path(String.t()) :: {:ok, [String.t()]} | {:error, String.t()}
|
||||
def split_path(path) do
|
||||
case String.split(path, ",", parts: 2) do
|
||||
[destination, digi_path] ->
|
||||
{:ok, [destination, digi_path]}
|
||||
|
||||
[destination] ->
|
||||
{:ok, [destination, ""]}
|
||||
|
||||
_ ->
|
||||
{:error, "Invalid path format"}
|
||||
end
|
||||
def split_path(path) when is_binary(path) do
|
||||
split = String.split(path, ",", parts: 2)
|
||||
split_path_parts(split)
|
||||
end
|
||||
|
||||
defp split_path_parts([destination, digi_path]), do: {:ok, [destination, digi_path]}
|
||||
defp split_path_parts([destination]), do: {:ok, [destination, ""]}
|
||||
defp split_path_parts(_), do: {:error, "Invalid path format"}
|
||||
|
||||
# Safe version of parse_datatype that returns {:ok, type} or {:error, reason}
|
||||
@spec parse_datatype_safe(String.t()) :: {:ok, atom()} | {:error, String.t()}
|
||||
def parse_datatype_safe(""), do: {:error, "Empty data"}
|
||||
|
|
@ -209,33 +197,18 @@ defmodule Parser do
|
|||
def parse_data(:mic_e, destination, data), do: parse_mic_e(destination, data)
|
||||
def parse_data(:mic_e_old, destination, data), do: parse_mic_e(destination, data)
|
||||
|
||||
def parse_data(:position, _destination, <<"!", rest::binary>>) do
|
||||
parse_data(:position, _destination, rest)
|
||||
end
|
||||
|
||||
def parse_data(:position, _destination, <<"/", _::binary>> = data) do
|
||||
result = parse_position_without_timestamp(data)
|
||||
if result.data_type == :malformed_position, do: result, else: %{result | data_type: :position}
|
||||
end
|
||||
|
||||
def parse_data(:position, _destination, data) do
|
||||
# Strip leading ! if present
|
||||
data =
|
||||
case data do
|
||||
<<"!", rest::binary>> -> rest
|
||||
_ -> data
|
||||
end
|
||||
|
||||
case data do
|
||||
<<"/", _::binary>> ->
|
||||
result = parse_position_without_timestamp(data)
|
||||
|
||||
if result.data_type == :malformed_position do
|
||||
result
|
||||
else
|
||||
%{result | data_type: :position}
|
||||
end
|
||||
|
||||
_ ->
|
||||
result = parse_position_without_timestamp(data)
|
||||
|
||||
if result.data_type == :malformed_position do
|
||||
result
|
||||
else
|
||||
%{result | data_type: :position}
|
||||
end
|
||||
end
|
||||
result = parse_position_without_timestamp(data)
|
||||
if result.data_type == :malformed_position, do: result, else: %{result | data_type: :position}
|
||||
end
|
||||
|
||||
def parse_data(:position_with_message, _destination, data) do
|
||||
|
|
@ -243,17 +216,15 @@ defmodule Parser do
|
|||
%{result | data_type: :position}
|
||||
end
|
||||
|
||||
def parse_data(:timestamped_position, _destination, data) do
|
||||
case data do
|
||||
<<"/", _::binary>> ->
|
||||
%{
|
||||
data_type: :timestamped_position_error,
|
||||
error: "Compressed position not supported in timestamped position"
|
||||
}
|
||||
def parse_data(:timestamped_position, _destination, <<"/", _::binary>>) do
|
||||
%{
|
||||
data_type: :timestamped_position_error,
|
||||
error: "Compressed position not supported in timestamped position"
|
||||
}
|
||||
end
|
||||
|
||||
_ ->
|
||||
parse_position_with_timestamp(false, data, :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
|
||||
|
|
@ -291,11 +262,11 @@ defmodule Parser do
|
|||
end
|
||||
end
|
||||
|
||||
def parse_data(:status, _destination, data), do: parse_status(data)
|
||||
def parse_data(:object, _destination, data), do: parse_object(data)
|
||||
def parse_data(:item, _destination, data), do: parse_item(data)
|
||||
def parse_data(:weather, _destination, data), do: parse_weather(data)
|
||||
def parse_data(:telemetry, _destination, data), do: parse_telemetry(data)
|
||||
def parse_data(:status, _destination, data), do: Parser.Status.parse(data)
|
||||
def parse_data(:object, _destination, data), do: Parser.Object.parse(data)
|
||||
def parse_data(:item, _destination, data), do: Parser.Item.parse(data)
|
||||
def parse_data(:weather, _destination, data), do: Parser.Weather.parse(data)
|
||||
def parse_data(:telemetry, _destination, data), do: Parser.Telemetry.parse(data)
|
||||
def parse_data(:station_capabilities, _destination, data), do: parse_station_capabilities(data)
|
||||
def parse_data(:query, _destination, data), do: parse_query(data)
|
||||
def parse_data(:user_defined, _destination, data), do: parse_user_defined(data)
|
||||
|
|
@ -359,7 +330,6 @@ defmodule Parser do
|
|||
end
|
||||
|
||||
def parse_data(:df_report, _destination, data) do
|
||||
# Delegate to the DFS logic in the PHG handler
|
||||
if String.starts_with?(data, "DFS") and byte_size(data) >= 7 do
|
||||
<<"DFS", s, h, g, d, rest::binary>> = data
|
||||
|
||||
|
|
@ -1218,25 +1188,12 @@ defmodule Parser do
|
|||
end
|
||||
|
||||
# Parse specific user-defined formats
|
||||
defp parse_user_defined_format(user_id, user_data) do
|
||||
case user_id do
|
||||
"A" ->
|
||||
# Experimental format A
|
||||
%{format: :experimental_a, content: user_data}
|
||||
defp parse_user_defined_format("A", user_data), do: %{format: :experimental_a, content: user_data}
|
||||
|
||||
"B" ->
|
||||
# Experimental format B
|
||||
%{format: :experimental_b, content: user_data}
|
||||
defp parse_user_defined_format("B", user_data), do: %{format: :experimental_b, content: user_data}
|
||||
|
||||
"C" ->
|
||||
# Custom format C
|
||||
%{format: :custom_c, content: user_data}
|
||||
|
||||
_ ->
|
||||
# Unknown user-defined format
|
||||
%{format: :unknown, content: user_data}
|
||||
end
|
||||
end
|
||||
defp parse_user_defined_format("C", user_data), do: %{format: :custom_c, content: user_data}
|
||||
defp parse_user_defined_format(_, user_data), do: %{format: :unknown, content: user_data}
|
||||
|
||||
# Third Party Traffic parsing
|
||||
def parse_third_party_traffic(packet) do
|
||||
|
|
|
|||
|
|
@ -7,8 +7,69 @@ defmodule Parser.Item do
|
|||
Parse an APRS item string. Returns a struct or error.
|
||||
"""
|
||||
@spec parse(String.t()) :: map() | nil
|
||||
def parse(_item_str) do
|
||||
# Stub: actual logic to be implemented
|
||||
nil
|
||||
def parse(<<item_indicator, item_name_and_data::binary>>) when item_indicator in [?%, ?)] do
|
||||
case Regex.run(~r/^(.{1,9})([!_])(.*)$/, item_name_and_data) do
|
||||
[_, item_name, status_char, position_data] ->
|
||||
parsed_position = parse_item_position(position_data)
|
||||
|
||||
base_data = %{
|
||||
item_name: String.trim(item_name),
|
||||
live_killed: status_char,
|
||||
data_type: :item
|
||||
}
|
||||
|
||||
Map.merge(base_data, parsed_position)
|
||||
|
||||
_ ->
|
||||
%{
|
||||
item_name: item_name_and_data,
|
||||
raw_data: <<item_indicator>> <> item_name_and_data,
|
||||
data_type: :item
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def parse(data), do: %{raw_data: data, data_type: :item}
|
||||
|
||||
defp parse_item_position(position_data) do
|
||||
case position_data do
|
||||
<<latitude::binary-size(8), sym_table_id::binary-size(1), longitude::binary-size(9), symbol_code::binary-size(1),
|
||||
comment::binary>> ->
|
||||
%{latitude: lat, longitude: lon} =
|
||||
Parser.Position.parse_aprs_position(latitude, longitude)
|
||||
|
||||
%{
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
symbol_table_id: sym_table_id,
|
||||
symbol_code: symbol_code,
|
||||
comment: comment,
|
||||
position_format: :uncompressed
|
||||
}
|
||||
|
||||
<<"/", latitude_compressed::binary-size(4), longitude_compressed::binary-size(4), symbol_code::binary-size(1),
|
||||
cs::binary-size(2), compression_type::binary-size(1), comment::binary>> ->
|
||||
converted_lat = Parser.Helpers.convert_compressed_lat(latitude_compressed)
|
||||
converted_lon = Parser.Helpers.convert_compressed_lon(longitude_compressed)
|
||||
compressed_cs = Parser.Helpers.convert_compressed_cs(cs)
|
||||
|
||||
base_data = %{
|
||||
latitude: converted_lat,
|
||||
longitude: converted_lon,
|
||||
symbol_table_id: "/",
|
||||
symbol_code: symbol_code,
|
||||
comment: comment,
|
||||
position_format: :compressed,
|
||||
compression_type: compression_type
|
||||
}
|
||||
|
||||
Map.merge(base_data, compressed_cs)
|
||||
|
||||
_ ->
|
||||
%{
|
||||
comment: position_data,
|
||||
position_format: :unknown
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
# @dialyzer {:nowarn_function, parse: 1}
|
||||
defmodule Parser.Object do
|
||||
@moduledoc """
|
||||
APRS object parsing.
|
||||
|
|
@ -7,8 +8,59 @@ defmodule Parser.Object do
|
|||
Parse an APRS object string. Returns a struct or error.
|
||||
"""
|
||||
@spec parse(String.t()) :: map() | nil
|
||||
def parse(_object_str) do
|
||||
# Stub: actual logic to be implemented
|
||||
nil
|
||||
def parse(<<";", object_name::binary-size(9), live_killed::binary-size(1), timestamp::binary-size(7), rest::binary>>) do
|
||||
position_data =
|
||||
case rest do
|
||||
<<latitude::binary-size(8), sym_table_id::binary-size(1), longitude::binary-size(9), symbol_code::binary-size(1),
|
||||
comment::binary>> ->
|
||||
%{latitude: lat, longitude: lon} =
|
||||
Parser.Position.parse_aprs_position(latitude, longitude)
|
||||
|
||||
%{
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
symbol_table_id: sym_table_id,
|
||||
symbol_code: symbol_code,
|
||||
comment: comment,
|
||||
position_format: :uncompressed
|
||||
}
|
||||
|
||||
<<"/", latitude_compressed::binary-size(4), longitude_compressed::binary-size(4), symbol_code::binary-size(1),
|
||||
cs::binary-size(2), compression_type::binary-size(1), comment::binary>> ->
|
||||
try do
|
||||
converted_lat = Parser.Helpers.convert_compressed_lat(latitude_compressed)
|
||||
converted_lon = Parser.Helpers.convert_compressed_lon(longitude_compressed)
|
||||
compressed_cs = Parser.Helpers.convert_compressed_cs(cs)
|
||||
|
||||
base_data = %{
|
||||
latitude: converted_lat,
|
||||
longitude: converted_lon,
|
||||
symbol_table_id: "/",
|
||||
symbol_code: symbol_code,
|
||||
comment: comment,
|
||||
position_format: :compressed,
|
||||
compression_type: compression_type
|
||||
}
|
||||
|
||||
Map.merge(base_data, compressed_cs)
|
||||
rescue
|
||||
_ -> %{latitude: nil, longitude: nil, comment: comment, position_format: :compressed}
|
||||
end
|
||||
|
||||
_ ->
|
||||
%{comment: rest, position_format: :unknown}
|
||||
end
|
||||
|
||||
Map.merge(
|
||||
%{
|
||||
object_name: String.trim(object_name),
|
||||
live_killed: live_killed,
|
||||
timestamp: timestamp,
|
||||
data_type: :object
|
||||
},
|
||||
position_data
|
||||
)
|
||||
end
|
||||
|
||||
def parse(data), do: %{raw_data: data, data_type: :object}
|
||||
end
|
||||
|
|
|
|||
|
|
@ -7,8 +7,6 @@ defmodule Parser.Status do
|
|||
Parse an APRS status string. Returns a struct or error.
|
||||
"""
|
||||
@spec parse(String.t()) :: map() | nil
|
||||
def parse(_status_str) do
|
||||
# Stub: actual logic to be implemented
|
||||
nil
|
||||
end
|
||||
def parse(<<">", status_text::binary>>), do: %{status_text: status_text, data_type: :status}
|
||||
def parse(data), do: %{status_text: data, data_type: :status}
|
||||
end
|
||||
|
|
|
|||
|
|
@ -7,8 +7,83 @@ defmodule Parser.Telemetry do
|
|||
Parse an APRS telemetry string. Returns a struct or error.
|
||||
"""
|
||||
@spec parse(String.t()) :: map() | nil
|
||||
def parse(_telemetry_str) do
|
||||
# Stub: actual logic to be implemented
|
||||
nil
|
||||
def parse("T#" <> rest) do
|
||||
case String.split(rest, ",") do
|
||||
[seq | [_ | _] = values] ->
|
||||
analog_values = Enum.take(values, 5)
|
||||
digital_values = values |> Enum.drop(5) |> Enum.take(8)
|
||||
|
||||
%{
|
||||
sequence_number: Parser.Helpers.parse_telemetry_sequence(seq),
|
||||
analog_values: Parser.Helpers.parse_analog_values(analog_values),
|
||||
digital_values: Parser.Helpers.parse_digital_values(digital_values),
|
||||
data_type: :telemetry,
|
||||
raw_data: rest
|
||||
}
|
||||
|
||||
_ ->
|
||||
%{
|
||||
raw_data: rest,
|
||||
data_type: :telemetry
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def parse(<<":PARM.", rest::binary>>) do
|
||||
%{
|
||||
data_type: :telemetry_parameters,
|
||||
parameter_names: String.split(rest, ",", trim: true),
|
||||
raw_data: rest
|
||||
}
|
||||
end
|
||||
|
||||
def parse(<<":EQNS.", rest::binary>>) do
|
||||
equations =
|
||||
rest
|
||||
|> String.split(",", trim: true)
|
||||
|> Enum.chunk_every(3)
|
||||
|> Enum.map(fn [a, b, c] ->
|
||||
%{
|
||||
a: Parser.Helpers.parse_coefficient(a),
|
||||
b: Parser.Helpers.parse_coefficient(b),
|
||||
c: Parser.Helpers.parse_coefficient(c)
|
||||
}
|
||||
end)
|
||||
|
||||
%{
|
||||
data_type: :telemetry_equations,
|
||||
equations: equations,
|
||||
raw_data: rest
|
||||
}
|
||||
end
|
||||
|
||||
def parse(<<":UNIT.", rest::binary>>) do
|
||||
%{
|
||||
data_type: :telemetry_units,
|
||||
units: String.split(rest, ",", trim: true),
|
||||
raw_data: rest
|
||||
}
|
||||
end
|
||||
|
||||
def parse(<<":BITS.", rest::binary>>) do
|
||||
case String.split(rest, ",", trim: true) do
|
||||
[bits_sense | project_names] ->
|
||||
%{
|
||||
data_type: :telemetry_bits,
|
||||
bits_sense: String.to_charlist(bits_sense),
|
||||
project_names: project_names,
|
||||
raw_data: rest
|
||||
}
|
||||
|
||||
[] ->
|
||||
%{
|
||||
data_type: :telemetry_bits,
|
||||
bits_sense: [],
|
||||
project_names: [],
|
||||
raw_data: rest
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def parse(data), do: %{raw_data: data, data_type: :telemetry}
|
||||
end
|
||||
|
|
|
|||
|
|
@ -7,8 +7,38 @@ defmodule Parser.Weather do
|
|||
Parse an APRS weather report string. Returns a struct or error.
|
||||
"""
|
||||
@spec parse(String.t()) :: map() | nil
|
||||
def parse(_weather_str) do
|
||||
# Stub: actual logic to be implemented
|
||||
nil
|
||||
def parse("_" <> <<timestamp::binary-size(8), rest::binary>>) do
|
||||
weather_data = parse_weather_data(rest)
|
||||
Map.merge(%{timestamp: timestamp, data_type: :weather}, weather_data)
|
||||
end
|
||||
|
||||
def parse(data) do
|
||||
weather_data = parse_weather_data(data)
|
||||
Map.merge(%{data_type: :weather}, weather_data)
|
||||
end
|
||||
|
||||
defp parse_weather_data(weather_data) do
|
||||
timestamp = Parser.Helpers.extract_timestamp(weather_data)
|
||||
weather_data = Parser.Helpers.remove_timestamp(weather_data)
|
||||
|
||||
weather_values = %{
|
||||
wind_direction: Parser.Helpers.parse_wind_direction(weather_data),
|
||||
wind_speed: Parser.Helpers.parse_wind_speed(weather_data),
|
||||
wind_gust: Parser.Helpers.parse_wind_gust(weather_data),
|
||||
temperature: Parser.Helpers.parse_temperature(weather_data),
|
||||
rain_1h: Parser.Helpers.parse_rainfall_1h(weather_data),
|
||||
rain_24h: Parser.Helpers.parse_rainfall_24h(weather_data),
|
||||
rain_since_midnight: Parser.Helpers.parse_rainfall_since_midnight(weather_data),
|
||||
humidity: Parser.Helpers.parse_humidity(weather_data),
|
||||
pressure: Parser.Helpers.parse_pressure(weather_data),
|
||||
luminosity: Parser.Helpers.parse_luminosity(weather_data),
|
||||
snow: Parser.Helpers.parse_snow(weather_data)
|
||||
}
|
||||
|
||||
result = %{timestamp: timestamp, data_type: :weather, raw_weather_data: weather_data}
|
||||
|
||||
Enum.reduce(weather_values, result, fn {key, value}, acc ->
|
||||
if is_nil(value), do: acc, else: Map.put(acc, key, value)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
BIN
priv/static/.DS_Store
vendored
BIN
priv/static/.DS_Store
vendored
Binary file not shown.
|
|
@ -5,13 +5,17 @@ defmodule Parser.ItemTest do
|
|||
alias Parser.Item
|
||||
|
||||
describe "parse/1" do
|
||||
test "returns nil for now" do
|
||||
assert Item.parse(")ITEM!4903.50N/07201.75W>Test item") == nil
|
||||
test "returns a map with :data_type => :item for valid input" do
|
||||
result = Item.parse(")ITEM!4903.50N/07201.75W>Test item")
|
||||
assert is_map(result)
|
||||
assert result[:data_type] == :item
|
||||
end
|
||||
|
||||
property "always returns nil for any string" do
|
||||
property "always returns a map with :data_type == :item for any string" do
|
||||
check all s <- StreamData.string(:ascii, min_length: 1, max_length: 30) do
|
||||
assert Item.parse(s) == nil
|
||||
result = Item.parse(s)
|
||||
assert is_map(result)
|
||||
assert result[:data_type] == :item
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -5,13 +5,17 @@ defmodule Parser.ObjectTest do
|
|||
alias Parser.Object
|
||||
|
||||
describe "parse/1" do
|
||||
test "returns nil for now" do
|
||||
assert Object.parse(";OBJECT*111111z4903.50N/07201.75W>Test object") == nil
|
||||
test "returns a map with :data_type => :object for valid input" do
|
||||
result = Object.parse(";OBJECT*111111z4903.50N/07201.75W>Test object")
|
||||
assert is_map(result)
|
||||
assert result[:data_type] == :object
|
||||
end
|
||||
|
||||
property "always returns nil for any string" do
|
||||
property "always returns a map with :data_type == :object for any string" do
|
||||
check all s <- StreamData.string(:ascii, min_length: 1, max_length: 30) do
|
||||
assert Object.parse(s) == nil
|
||||
result = Object.parse(s)
|
||||
assert is_map(result)
|
||||
assert result[:data_type] == :object
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -5,13 +5,18 @@ defmodule Parser.StatusTest do
|
|||
alias Parser.Status
|
||||
|
||||
describe "parse/1" do
|
||||
test "returns nil for now" do
|
||||
assert Status.parse(">Test status message") == nil
|
||||
test "returns a status map for valid input" do
|
||||
assert Status.parse(">Test status message") == %{
|
||||
data_type: :status,
|
||||
status_text: "Test status message"
|
||||
}
|
||||
end
|
||||
|
||||
property "always returns nil for any string" do
|
||||
property "always returns a map with :data_type == :status for any string" do
|
||||
check all s <- StreamData.string(:ascii, min_length: 1, max_length: 30) do
|
||||
assert Status.parse(s) == nil
|
||||
result = Status.parse(s)
|
||||
assert is_map(result)
|
||||
assert result[:data_type] == :status
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -5,13 +5,17 @@ defmodule Parser.TelemetryTest do
|
|||
alias Parser.Telemetry
|
||||
|
||||
describe "parse/1" do
|
||||
test "returns nil for now" do
|
||||
assert Telemetry.parse("T#123,456,789,012,345,678,901,234") == nil
|
||||
test "returns a map with :data_type for valid input" do
|
||||
result = Telemetry.parse("T#123,456,789,012,345,678,901,234")
|
||||
assert is_map(result)
|
||||
assert Map.has_key?(result, :data_type)
|
||||
end
|
||||
|
||||
property "always returns nil for any string" do
|
||||
property "always returns a map with :data_type for any string" do
|
||||
check all s <- StreamData.string(:ascii, min_length: 1, max_length: 30) do
|
||||
assert Telemetry.parse(s) == nil
|
||||
result = Telemetry.parse(s)
|
||||
assert is_map(result)
|
||||
assert Map.has_key?(result, :data_type)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -5,13 +5,17 @@ defmodule Parser.WeatherTest do
|
|||
alias Parser.Weather
|
||||
|
||||
describe "parse/1" do
|
||||
test "returns nil for now" do
|
||||
assert Weather.parse("_12345678c000s000g000t000r000p000P000h00b00000") == nil
|
||||
test "returns a map with :data_type => :weather for valid input" do
|
||||
result = Weather.parse("_12345678c000s000g000t000r000p000P000h00b00000")
|
||||
assert is_map(result)
|
||||
assert result[:data_type] == :weather
|
||||
end
|
||||
|
||||
property "always returns nil for any string" do
|
||||
property "always returns a map with :data_type == :weather for any string" do
|
||||
check all s <- StreamData.string(:ascii, min_length: 1, max_length: 30) do
|
||||
assert Weather.parse(s) == nil
|
||||
result = Weather.parse(s)
|
||||
assert is_map(result)
|
||||
assert result[:data_type] == :weather
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue