diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs new file mode 100644 index 0000000..d86b1ed --- /dev/null +++ b/.dialyzer_ignore.exs @@ -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>>}."} +] diff --git a/lib/aprs/packets.ex b/lib/aprs/packets.ex index 1198a51..e833b2b 100644 --- a/lib/aprs/packets.ex +++ b/lib/aprs/packets.ex @@ -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} -> diff --git a/lib/aprs_web/live/map_live/callsign_view.ex b/lib/aprs_web/live/map_live/callsign_view.ex index ff99f3f..2509dc6 100644 --- a/lib/aprs_web/live/map_live/callsign_view.ex +++ b/lib/aprs_web/live/map_live/callsign_view.ex @@ -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 -> ">" diff --git a/lib/aprs_web/live/map_live/enhanced.ex b/lib/aprs_web/live/map_live/enhanced.ex index e9050a9..ba4fee6 100644 --- a/lib/aprs_web/live/map_live/enhanced.ex +++ b/lib/aprs_web/live/map_live/enhanced.ex @@ -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 diff --git a/lib/aprs_web/live/map_live/index.ex b/lib/aprs_web/live/map_live/index.ex index 55523cd..353b81a 100644 --- a/lib/aprs_web/live/map_live/index.ex +++ b/lib/aprs_web/live/map_live/index.ex @@ -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 = """
diff --git a/lib/parser.ex b/lib/parser.ex index bfbdb6b..ceb8060 100644 --- a/lib/parser.ex +++ b/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( + <>, + <> + ) + 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 diff --git a/lib/parser/item.ex b/lib/parser/item.ex index d08e0c0..aad30a8 100644 --- a/lib/parser/item.ex +++ b/lib/parser/item.ex @@ -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(<>) 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_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: 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 diff --git a/lib/parser/object.ex b/lib/parser/object.ex index 633164c..d13a933 100644 --- a/lib/parser/object.ex +++ b/lib/parser/object.ex @@ -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: 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 diff --git a/lib/parser/status.ex b/lib/parser/status.ex index b69f175..d93fe06 100644 --- a/lib/parser/status.ex +++ b/lib/parser/status.ex @@ -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 diff --git a/lib/parser/telemetry.ex b/lib/parser/telemetry.ex index 86f7180..a86128c 100644 --- a/lib/parser/telemetry.ex +++ b/lib/parser/telemetry.ex @@ -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 diff --git a/lib/parser/weather.ex b/lib/parser/weather.ex index ecaeb35..f197822 100644 --- a/lib/parser/weather.ex +++ b/lib/parser/weather.ex @@ -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("_" <> <>) 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 diff --git a/priv/static/.DS_Store b/priv/static/.DS_Store deleted file mode 100644 index 76b628c..0000000 Binary files a/priv/static/.DS_Store and /dev/null differ diff --git a/test/parser/item_test.exs b/test/parser/item_test.exs index 7fc588a..dde7189 100644 --- a/test/parser/item_test.exs +++ b/test/parser/item_test.exs @@ -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 diff --git a/test/parser/object_test.exs b/test/parser/object_test.exs index 8453210..b19e0d8 100644 --- a/test/parser/object_test.exs +++ b/test/parser/object_test.exs @@ -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 diff --git a/test/parser/status_test.exs b/test/parser/status_test.exs index 6c9914b..0367312 100644 --- a/test/parser/status_test.exs +++ b/test/parser/status_test.exs @@ -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 diff --git a/test/parser/telemetry_test.exs b/test/parser/telemetry_test.exs index f7934f5..25dd810 100644 --- a/test/parser/telemetry_test.exs +++ b/test/parser/telemetry_test.exs @@ -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 diff --git a/test/parser/weather_test.exs b/test/parser/weather_test.exs index b7375aa..a46da95 100644 --- a/test/parser/weather_test.exs +++ b/test/parser/weather_test.exs @@ -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