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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

Binary file not shown.

View file

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

View file

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

View file

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

View file

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

View file

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