improvements

This commit is contained in:
Graham McIntire 2025-06-20 10:54:45 -05:00
parent 0d070cf470
commit e4d72ec6cc
No known key found for this signature in database
17 changed files with 438 additions and 243 deletions

6
.dialyzer_ignore.exs Normal file
View 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>>}."}
]

View file

@ -89,6 +89,16 @@ defmodule Aprs.Packets do
|> Packet.changeset(packet_attrs) |> Packet.changeset(packet_attrs)
|> Repo.insert() do |> Repo.insert() do
{:ok, packet} -> {: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} {:ok, packet}
{:error, changeset} -> {:error, changeset} ->

View file

@ -749,9 +749,16 @@ defmodule AprsWeb.MapLive.CallsignView do
defp has_position_data?(packet) do defp has_position_data?(packet) do
case packet.data_extended do case packet.data_extended do
%MicE{} -> true %MicE{} ->
%{latitude: lat, longitude: lon} when not is_nil(lat) and not is_nil(lon) -> true true
_ -> false
%{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
end end
@ -793,19 +800,16 @@ defmodule AprsWeb.MapLive.CallsignView do
defp get_symbol_table_id(data) do defp get_symbol_table_id(data) do
cond do cond do
is_map(data) && Map.has_key?(data, :symbol_table_id) && data.symbol_table_id -> is_map(data) && (Map.get(data, :symbol_table_id) || Map.get(data, "symbol_table_id")) ->
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"] -> is_map(data) && Map.has_key?(data, :packet) &&
data["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")
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") && is_map(data) && Map.has_key?(data, "packet") &&
Map.has_key?(data["packet"], "symbol_table_id") && data["packet"]["symbol_table_id"] -> (Map.get(data["packet"], :symbol_table_id) || Map.get(data["packet"], "symbol_table_id")) ->
data["packet"]["symbol_table_id"] Map.get(data["packet"], :symbol_table_id) || Map.get(data["packet"], "symbol_table_id")
true -> true ->
"/" "/"
@ -814,19 +818,16 @@ defmodule AprsWeb.MapLive.CallsignView do
defp get_symbol_code(data) do defp get_symbol_code(data) do
cond do cond do
is_map(data) && Map.has_key?(data, :symbol_code) && data.symbol_code -> is_map(data) && (Map.get(data, :symbol_code) || Map.get(data, "symbol_code")) ->
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"] -> is_map(data) && Map.has_key?(data, :packet) &&
data["symbol_code"] (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) && is_map(data) && Map.has_key?(data, "packet") &&
data.packet.symbol_code -> (Map.get(data["packet"], :symbol_code) || Map.get(data["packet"], "symbol_code")) ->
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"]
true -> true ->
">" ">"

View file

@ -442,13 +442,19 @@ defmodule AprsWeb.MapLive.Enhanced do
data_extended = packet.data_extended || %{} data_extended = packet.data_extended || %{}
callsign = packet.base_callsign <> if packet.ssid, do: "-#{packet.ssid}", else: "" 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, id: callsign,
callsign: callsign, callsign: callsign,
lat: packet.lat, lat: packet.lat,
lng: packet.lon, lng: packet.lon,
symbol_table: data_extended["symbol_table_id"] || "/", symbol_table: symbol_table_id,
symbol_code: data_extended["symbol_code"] || ">", symbol_code: symbol_code,
historical: false, historical: false,
popup: build_popup_content(packet, callsign, false), popup: build_popup_content(packet, callsign, false),
timestamp: DateTime.to_unix(packet.created_at, :millisecond) timestamp: DateTime.to_unix(packet.created_at, :millisecond)
@ -521,18 +527,18 @@ defmodule AprsWeb.MapLive.Enhanced do
end) end)
|> Enum.map(fn {callsign, _} -> callsign end) |> Enum.map(fn {callsign, _} -> callsign end)
# Remove old markers from client # Only update the client if the marker is present
socket = socket =
Enum.reduce(old_markers, socket, fn callsign, acc_socket -> if old_markers == [] do
push_event(acc_socket, "remove_marker", %{id: callsign}) socket
end) else
Enum.reduce(old_markers, socket, fn callsign, acc_socket ->
# Update server state push_event(acc_socket, "remove_marker", %{id: callsign})
active_markers = end)
socket.assigns.active_markers end
|> Enum.reject(fn {callsign, _} -> callsign in old_markers end)
|> Map.new()
# Use Map.drop/2 for better performance
active_markers = Map.drop(socket.assigns.active_markers, old_markers)
assign(socket, :active_markers, active_markers) assign(socket, :active_markers, active_markers)
end end
@ -615,9 +621,9 @@ defmodule AprsWeb.MapLive.Enhanced do
end end
defp has_position_data?(packet) do defp has_position_data?(packet) do
packet.has_position == true and lat = Map.get(packet, :lat) || Map.get(packet, "lat")
packet.lat != nil and lon = Map.get(packet, :lon) || Map.get(packet, "lon")
packet.lon != nil not is_nil(lat) and not is_nil(lon)
end end
defp within_bounds?(packet, bounds) do defp within_bounds?(packet, bounds) do

View file

@ -310,7 +310,7 @@ defmodule AprsWeb.MapLive.Index do
push_event(acc, "remove_marker", %{id: k}) push_event(acc, "remove_marker", %{id: k})
end) 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] bounds_list = [map_bounds.west, map_bounds.south, map_bounds.east, map_bounds.north]
now = DateTime.utc_now() now = DateTime.utc_now()
one_hour_ago = DateTime.add(now, -3600, :second) one_hour_ago = DateTime.add(now, -3600, :second)
@ -335,6 +335,7 @@ defmodule AprsWeb.MapLive.Index do
socket socket
end end
# Only update visible_packets with the historical packets for the new bounds
assign(socket, map_bounds: map_bounds, visible_packets: new_visible_packets) assign(socket, map_bounds: map_bounds, visible_packets: new_visible_packets)
end 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({: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), def handle_info(%Phoenix.Socket.Broadcast{topic: "aprs_messages", event: "packet", payload: packet}, socket),
do: handle_info({:postgres_packet, 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) all_packets = Map.put(socket.assigns.all_packets, callsign_key, packet)
socket = assign(socket, all_packets: all_packets) 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}, do: {:noreply, socket},
else: handle_valid_postgres_packet(packet, lat, lon, socket) else: handle_valid_postgres_packet(packet, lat, lon, socket)
end end
@ -691,62 +691,34 @@ defmodule AprsWeb.MapLive.Index do
""" """
end 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 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) one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second)
packets_to_remove = get_packets_to_remove(socket, one_hour_ago) # Remove expired packets from visible_packets
visible_packets = get_visible_packets(socket, one_hour_ago) updated_visible_packets =
socket.assigns.visible_packets
socket = |> Enum.filter(fn {_key, packet} ->
if map_size(visible_packets) == map_size(socket.assigns.visible_packets) do packet_within_time_threshold?(packet, one_hour_ago)
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})
end) end)
else |> Map.new()
socket
end # 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 end
# Check if a packet is within the time threshold (not too old) # 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() @spec build_packet_map(map() | struct(), number(), number(), map() | nil) :: map()
defp build_packet_map(packet, lat, lon, data_extended) do defp build_packet_map(packet, lat, lon, data_extended) do
data_extended = data_extended || %{}
callsign = generate_callsign(packet) 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 = 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 = timestamp =
cond do cond do
@ -898,7 +876,7 @@ defmodule AprsWeb.MapLive.Index do
"" ""
end end
comment = get_in(data_extended, ["comment"]) || "" comment = Map.get(data_extended, :comment) || Map.get(data_extended, "comment") || ""
popup = """ popup = """
<div class=\"aprs-popup\"> <div class=\"aprs-popup\">

View file

@ -1,3 +1,5 @@
# @dialyzer {:nowarn_function, parse_data: 3}
# @dialyzer {:nowarn_function, parse_object: 1}
defmodule Parser do defmodule Parser do
@moduledoc """ @moduledoc """
Main parsing library Main parsing library
@ -9,32 +11,20 @@ defmodule Parser do
require Logger require Logger
# Simple APRS position parsing to replace parse_aprs_position # Simple APRS position parsing to replace parse_aprs_position
defp parse_aprs_position(lat_str, lon_str) do defp parse_aprs_position(
# Parse latitude: DDMM.MM format <<lat_deg::binary-size(2), lat_min::binary-size(5), lat_dir::binary-size(1)>>,
lat = <<lon_deg::binary-size(3), lon_min::binary-size(5), lon_dir::binary-size(1)>>
case Regex.run(~r/^(\d{2})(\d{2}\.\d+)([NS])$/, lat_str) do )
[_, degrees, minutes, direction] -> when lat_dir in ["N", "S"] and lon_dir in ["E", "W"] do
lat_val = String.to_integer(degrees) + String.to_float(minutes) / 60 lat_val = String.to_integer(lat_deg) + String.to_float(lat_min) / 60
if direction == "S", do: -lat_val, else: lat_val 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
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
%{latitude: lat, longitude: lon} %{latitude: lat, longitude: lon}
end end
defp parse_aprs_position(_, _), do: %{latitude: nil, longitude: nil}
@type packet :: %{ @type packet :: %{
id: String.t(), id: String.t(),
sender: String.t(), sender: String.t(),
@ -94,9 +84,6 @@ defmodule Parser do
"Invalid packet format" -> {:error, "Invalid packet format"} "Invalid packet format" -> {:error, "Invalid packet format"}
_ -> {:error, reason} _ -> {:error, reason}
end end
_ ->
{:error, "PARSE ERROR"}
end end
rescue rescue
error -> error ->
@ -105,32 +92,37 @@ defmodule Parser do
end end
# Validate callsign for AX.25 compliance # Validate callsign for AX.25 compliance
def validate_callsign(callsign, :src) do def validate_callsign(callsign, :src) when is_binary(callsign) do
if is_binary(callsign) and String.match?(callsign, ~r/^[A-Z0-9\-]+$/) and if regex_callsign_valid?(callsign) and not String.contains?(callsign, "*") do
not String.contains?(callsign, "*") do
:ok :ok
else else
{:error, "Invalid source callsign"} {:error, "Invalid source callsign"}
end end
end end
def validate_callsign(callsign, :dst) do def validate_callsign(_callsign, :src), do: {:error, "Invalid source callsign"}
cond do
callsign == "" -> {:error, "Missing destination callsign"} def validate_callsign("", :dst), do: {:error, "Missing destination callsign"}
not String.match?(callsign, ~r/^[A-Z0-9\-]+$/) -> {:error, "Invalid destination callsign"}
true -> :ok def validate_callsign(callsign, :dst) when is_binary(callsign) do
end if regex_callsign_valid?(callsign), do: :ok, else: {:error, "Invalid destination callsign"}
end 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 # Validate path for too many components
def validate_path(path) do def validate_path(path) when is_binary(path) and path != "" do
if path != "" and length(String.split(path, ",")) > 8 do if length(String.split(path, ",")) > 8 do
{:error, "Too many path components"} {:error, "Too many path components"}
else else
:ok :ok
end end
end end
def validate_path(_), do: :ok
# Safely split packet into components # Safely split packet into components
@spec split_packet(String.t()) :: {:ok, [String.t()]} | {:error, String.t()} @spec split_packet(String.t()) :: {:ok, [String.t()]} | {:error, String.t()}
def split_packet(message) do def split_packet(message) do
@ -147,19 +139,15 @@ defmodule Parser do
# Safely split path into destination and digipeater path # Safely split path into destination and digipeater path
@spec split_path(String.t()) :: {:ok, [String.t()]} | {:error, String.t()} @spec split_path(String.t()) :: {:ok, [String.t()]} | {:error, String.t()}
def split_path(path) do def split_path(path) when is_binary(path) do
case String.split(path, ",", parts: 2) do split = String.split(path, ",", parts: 2)
[destination, digi_path] -> split_path_parts(split)
{:ok, [destination, digi_path]}
[destination] ->
{:ok, [destination, ""]}
_ ->
{:error, "Invalid path format"}
end
end 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} # Safe version of parse_datatype that returns {:ok, type} or {:error, reason}
@spec parse_datatype_safe(String.t()) :: {:ok, atom()} | {:error, String.t()} @spec parse_datatype_safe(String.t()) :: {:ok, atom()} | {:error, String.t()}
def parse_datatype_safe(""), do: {:error, "Empty data"} 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, 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(: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 def parse_data(:position, _destination, data) do
# Strip leading ! if present result = parse_position_without_timestamp(data)
data = if result.data_type == :malformed_position, do: result, else: %{result | data_type: :position}
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
end end
def parse_data(:position_with_message, _destination, data) do def parse_data(:position_with_message, _destination, data) do
@ -243,17 +216,15 @@ defmodule Parser do
%{result | data_type: :position} %{result | data_type: :position}
end end
def parse_data(:timestamped_position, _destination, data) do def parse_data(:timestamped_position, _destination, <<"/", _::binary>>) do
case data do %{
<<"/", _::binary>> -> data_type: :timestamped_position_error,
%{ error: "Compressed position not supported in timestamped position"
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) parse_position_with_timestamp(false, data, :timestamped_position)
end
end end
def parse_data(:timestamped_position_with_message, _destination, data) do def parse_data(:timestamped_position_with_message, _destination, data) do
@ -291,11 +262,11 @@ defmodule Parser do
end end
end end
def parse_data(:status, _destination, data), do: parse_status(data) def parse_data(:status, _destination, data), do: Parser.Status.parse(data)
def parse_data(:object, _destination, data), do: parse_object(data) def parse_data(:object, _destination, data), do: Parser.Object.parse(data)
def parse_data(:item, _destination, data), do: parse_item(data) def parse_data(:item, _destination, data), do: Parser.Item.parse(data)
def parse_data(:weather, _destination, data), do: parse_weather(data) def parse_data(:weather, _destination, data), do: Parser.Weather.parse(data)
def parse_data(:telemetry, _destination, data), do: parse_telemetry(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(:station_capabilities, _destination, data), do: parse_station_capabilities(data)
def parse_data(:query, _destination, data), do: parse_query(data) def parse_data(:query, _destination, data), do: parse_query(data)
def parse_data(:user_defined, _destination, data), do: parse_user_defined(data) def parse_data(:user_defined, _destination, data), do: parse_user_defined(data)
@ -359,7 +330,6 @@ defmodule Parser do
end end
def parse_data(:df_report, _destination, data) do 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 if String.starts_with?(data, "DFS") and byte_size(data) >= 7 do
<<"DFS", s, h, g, d, rest::binary>> = data <<"DFS", s, h, g, d, rest::binary>> = data
@ -1218,25 +1188,12 @@ defmodule Parser do
end end
# Parse specific user-defined formats # Parse specific user-defined formats
defp parse_user_defined_format(user_id, user_data) do defp parse_user_defined_format("A", user_data), do: %{format: :experimental_a, content: user_data}
case user_id do
"A" ->
# Experimental format A
%{format: :experimental_a, content: user_data}
"B" -> defp parse_user_defined_format("B", user_data), do: %{format: :experimental_b, content: user_data}
# Experimental format B
%{format: :experimental_b, content: user_data}
"C" -> defp parse_user_defined_format("C", user_data), do: %{format: :custom_c, content: user_data}
# Custom format C defp parse_user_defined_format(_, user_data), do: %{format: :unknown, content: user_data}
%{format: :custom_c, content: user_data}
_ ->
# Unknown user-defined format
%{format: :unknown, content: user_data}
end
end
# Third Party Traffic parsing # Third Party Traffic parsing
def parse_third_party_traffic(packet) do def parse_third_party_traffic(packet) do

View file

@ -7,8 +7,69 @@ defmodule Parser.Item do
Parse an APRS item string. Returns a struct or error. Parse an APRS item string. Returns a struct or error.
""" """
@spec parse(String.t()) :: map() | nil @spec parse(String.t()) :: map() | nil
def parse(_item_str) do def parse(<<item_indicator, item_name_and_data::binary>>) when item_indicator in [?%, ?)] do
# Stub: actual logic to be implemented case Regex.run(~r/^(.{1,9})([!_])(.*)$/, item_name_and_data) do
nil [_, 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
end end

View file

@ -1,3 +1,4 @@
# @dialyzer {:nowarn_function, parse: 1}
defmodule Parser.Object do defmodule Parser.Object do
@moduledoc """ @moduledoc """
APRS object parsing. APRS object parsing.
@ -7,8 +8,59 @@ defmodule Parser.Object do
Parse an APRS object string. Returns a struct or error. Parse an APRS object string. Returns a struct or error.
""" """
@spec parse(String.t()) :: map() | nil @spec parse(String.t()) :: map() | nil
def parse(_object_str) do def parse(<<";", object_name::binary-size(9), live_killed::binary-size(1), timestamp::binary-size(7), rest::binary>>) do
# Stub: actual logic to be implemented position_data =
nil 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 end
def parse(data), do: %{raw_data: data, data_type: :object}
end end

View file

@ -7,8 +7,6 @@ defmodule Parser.Status do
Parse an APRS status string. Returns a struct or error. Parse an APRS status string. Returns a struct or error.
""" """
@spec parse(String.t()) :: map() | nil @spec parse(String.t()) :: map() | nil
def parse(_status_str) do def parse(<<">", status_text::binary>>), do: %{status_text: status_text, data_type: :status}
# Stub: actual logic to be implemented def parse(data), do: %{status_text: data, data_type: :status}
nil
end
end end

View file

@ -7,8 +7,83 @@ defmodule Parser.Telemetry do
Parse an APRS telemetry string. Returns a struct or error. Parse an APRS telemetry string. Returns a struct or error.
""" """
@spec parse(String.t()) :: map() | nil @spec parse(String.t()) :: map() | nil
def parse(_telemetry_str) do def parse("T#" <> rest) do
# Stub: actual logic to be implemented case String.split(rest, ",") do
nil [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 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 end

View file

@ -7,8 +7,38 @@ defmodule Parser.Weather do
Parse an APRS weather report string. Returns a struct or error. Parse an APRS weather report string. Returns a struct or error.
""" """
@spec parse(String.t()) :: map() | nil @spec parse(String.t()) :: map() | nil
def parse(_weather_str) do def parse("_" <> <<timestamp::binary-size(8), rest::binary>>) do
# Stub: actual logic to be implemented weather_data = parse_weather_data(rest)
nil 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
end end

BIN
priv/static/.DS_Store vendored

Binary file not shown.

View file

@ -5,13 +5,17 @@ defmodule Parser.ItemTest do
alias Parser.Item alias Parser.Item
describe "parse/1" do describe "parse/1" do
test "returns nil for now" do test "returns a map with :data_type => :item for valid input" do
assert Item.parse(")ITEM!4903.50N/07201.75W>Test item") == nil result = Item.parse(")ITEM!4903.50N/07201.75W>Test item")
assert is_map(result)
assert result[:data_type] == :item
end 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 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 end
end end

View file

@ -5,13 +5,17 @@ defmodule Parser.ObjectTest do
alias Parser.Object alias Parser.Object
describe "parse/1" do describe "parse/1" do
test "returns nil for now" do test "returns a map with :data_type => :object for valid input" do
assert Object.parse(";OBJECT*111111z4903.50N/07201.75W>Test object") == nil result = Object.parse(";OBJECT*111111z4903.50N/07201.75W>Test object")
assert is_map(result)
assert result[:data_type] == :object
end 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 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 end
end end

View file

@ -5,13 +5,18 @@ defmodule Parser.StatusTest do
alias Parser.Status alias Parser.Status
describe "parse/1" do describe "parse/1" do
test "returns nil for now" do test "returns a status map for valid input" do
assert Status.parse(">Test status message") == nil assert Status.parse(">Test status message") == %{
data_type: :status,
status_text: "Test status message"
}
end 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 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 end
end end

View file

@ -5,13 +5,17 @@ defmodule Parser.TelemetryTest do
alias Parser.Telemetry alias Parser.Telemetry
describe "parse/1" do describe "parse/1" do
test "returns nil for now" do test "returns a map with :data_type for valid input" do
assert Telemetry.parse("T#123,456,789,012,345,678,901,234") == nil result = Telemetry.parse("T#123,456,789,012,345,678,901,234")
assert is_map(result)
assert Map.has_key?(result, :data_type)
end 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 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 end
end end

View file

@ -5,13 +5,17 @@ defmodule Parser.WeatherTest do
alias Parser.Weather alias Parser.Weather
describe "parse/1" do describe "parse/1" do
test "returns nil for now" do test "returns a map with :data_type => :weather for valid input" do
assert Weather.parse("_12345678c000s000g000t000r000p000P000h00b00000") == nil result = Weather.parse("_12345678c000s000g000t000r000p000P000h00b00000")
assert is_map(result)
assert result[:data_type] == :weather
end 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 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 end
end end