defmodule Aprs do @moduledoc """ Main APRS packet parsing library """ import Aprs.Guards alias Aprs.Item alias Aprs.MicE alias Aprs.Object alias Aprs.PHG alias Aprs.Status alias Aprs.Telemetry alias Aprs.Weather @version "0.1.6" @doc """ Returns the current version of the APRS library as a static string. """ @spec version() :: String.t() def version, do: @version # APRS position parsing with position ambiguity support @spec parse_aprs_position(String.t(), String.t()) :: %{ latitude: float() | nil, longitude: float() | nil, ambiguity: 0..4 } defp parse_aprs_position(lat, lon) do Aprs.Position.parse_aprs_position(lat, lon) end @type packet :: %{ required(:id) => String.t(), required(:sender) => String.t(), required(:path) => String.t(), required(:destination) => String.t(), required(:information_field) => String.t(), required(:data_type) => atom(), required(:base_callsign) => String.t(), required(:ssid) => String.t() | nil, required(:data_extended) => map() | nil, required(:received_at) => DateTime.t(), optional(atom()) => term() } @type parse_result :: {:ok, packet()} | {:error, atom() | String.t()} @type position_ambiguity :: 0..4 @type coordinate :: float() | nil @type position_data :: %{ required(:latitude) => coordinate(), required(:longitude) => coordinate(), optional(:timestamp) => String.t() | nil, optional(:symbol_table_id) => String.t() | nil, optional(:symbol_code) => String.t() | nil, optional(:comment) => String.t(), optional(:altitude) => float() | nil, optional(:phg) => map() | nil, optional(:aprs_messaging?) => boolean(), optional(:compressed?) => boolean(), optional(:position_ambiguity) => position_ambiguity(), optional(:dao) => map() | nil, optional(:course) => integer() | nil, optional(:speed) => float() | nil, optional(:has_position) => boolean(), optional(:data_type) => atom() } @spec parse(String.t()) :: parse_result() def parse(message) when is_binary(message) do # Strip trailing null bytes before parsing message = String.trim_trailing(message, <<0>>) # Ensure the message is valid UTF-8 before parsing parse_with_encoding(message, String.valid?(message)) rescue _ -> {:error, :invalid_packet} end @spec parse(any()) :: parse_result() def parse(_), do: {:error, :invalid_packet} @spec parse_with_encoding(String.t(), boolean()) :: parse_result() defp parse_with_encoding(message, true), do: do_parse(message) # Try to fix invalid UTF-8 by replacing invalid bytes defp parse_with_encoding(message, false) do fixed_message = String.replace(message, ~r/[^\x00-\x7F]/, "?") do_parse(fixed_message) rescue _ -> {:error, :invalid_utf8} end @spec do_parse(String.t()) :: parse_result() defp do_parse(message) do with {:ok, [sender, path, data]} <- split_packet(message), {:ok, callsign_parts} <- parse_callsign(sender), {:ok, data_type} <- parse_datatype_safe(data), {:ok, [destination, path2]} <- split_path(path), :ok <- validate_packet_parts(destination, sender, data_type), {:ok, packet_data} <- build_packet_data(sender, path2, destination, data, data_type, callsign_parts) do # Add resultcode and resultmsg for successful parse enriched_packet = Map.merge(packet_data, %{ resultcode: "success", resultmsg: "OK" }) {:ok, enriched_packet} else {:error, reason} -> {:error, format_error_message(reason)} end rescue _ -> {:error, "Parse exception"} end @spec format_error_message(:invalid_packet | String.t()) :: :invalid_packet | String.t() defp format_error_message(:invalid_packet), do: :invalid_packet defp format_error_message(reason) when is_binary(reason), do: reason @spec validate_packet_parts(String.t(), String.t(), atom()) :: :ok | {:error, :invalid_packet} defp validate_packet_parts("", "", _), do: {:error, :invalid_packet} defp validate_packet_parts("", _, :empty), do: {:error, :invalid_packet} defp validate_packet_parts(_, _, _), do: :ok @spec build_packet_data(String.t(), String.t(), String.t(), String.t(), atom(), [String.t()]) :: {:ok, packet()} defp build_packet_data(sender, path, destination, data, data_type, callsign_parts) do data_trimmed = trim_binary(data) # For messages and items, we need to keep the type indicator data_for_parsing = prepare_data_for_parsing(data_type, data_trimmed) data_extended = parse_data(data_type, destination, data_for_parsing) # Parse digipeaters from path digipeaters = parse_digipeaters(path) # Use data_type from data_extended if available (e.g., weather packets) final_data_type = determine_final_data_type(data_extended, data_type) # Add standard APRS fields to the main packet structure base_packet = %{ id: generate_packet_id(), sender: sender, path: path, destination: destination, information_field: data_trimmed, data_type: final_data_type, base_callsign: List.first(callsign_parts), ssid: extract_ssid(callsign_parts), data_extended: data_extended, received_at: DateTime.truncate(DateTime.utc_now(), :microsecond), # Standard APRS parser fields srccallsign: sender, dstcallsign: destination, body: data_trimmed, origpacket: sender <> ">" <> destination <> if(path == "", do: "", else: "," <> path) <> ":" <> data_trimmed, header: sender <> ">" <> destination <> if(path == "", do: "", else: "," <> path), alive: 1, # Add reference parser field mappings type: atom_to_standard_type(final_data_type), digipeaters: digipeaters, # Add commonly missing fields with default values posambiguity: 0, format: "uncompressed", messaging: 0, daodatumbyte: nil, gpsfixstatus: nil, mbits: nil, message: nil, phg: nil, wx: nil, radiorange: nil, itemname: nil } # Merge data_extended fields into main packet final_packet = merge_data_extended(base_packet, data_extended) # Map internal field names to reference parser format final_packet = map_fields_to_reference_format(final_packet) {:ok, final_packet} end @spec generate_packet_id() :: String.t() defp generate_packet_id do 16 |> :crypto.strong_rand_bytes() |> Base.encode16(case: :lower) end @spec extract_data_without_type(String.t()) :: String.t() defp extract_data_without_type(<<_first_char::binary-size(1), rest::binary>>), do: rest defp extract_data_without_type(_), do: "" @spec extract_ssid([String.t()]) :: String.t() | nil defp extract_ssid(callsign_parts) do case List.last(callsign_parts) do nil -> nil s when is_binary(s) -> s i when is_integer(i) -> to_string(i) _ -> nil end end @spec prepare_data_for_parsing(atom(), String.t()) :: String.t() defp prepare_data_for_parsing(:message, data), do: data defp prepare_data_for_parsing(:item, data), do: data defp prepare_data_for_parsing(_, data), do: extract_data_without_type(data) @spec determine_final_data_type(map() | nil, atom()) :: atom() defp determine_final_data_type(%{data_type: type}, _) when is_atom(type), do: type defp determine_final_data_type(_, data_type), do: data_type # Map of internal data_type atoms to standard type strings @standard_type_map %{ position: "location", position_with_message: "location", timestamped_position: "location", timestamped_position_with_message: "location", position_with_datetime_and_weather: "wx", weather: "wx", object: "object", item: "item", message: "message", telemetry_message: "telemetry-message", telemetry: "telemetry", status: "status", station_capabilities: "capabilities", mic_e: "location", mic_e_old: "location", mic_e_error: "location", malformed_position: "location", nmea: "location" } # Convert internal data_type atoms to standard type strings @spec atom_to_standard_type(atom()) :: String.t() defp atom_to_standard_type(type) do Map.get(@standard_type_map, type, Atom.to_string(type)) end # Parse digipeaters from path string @spec parse_digipeaters(String.t()) :: [map()] defp parse_digipeaters(""), do: [] defp parse_digipeaters(path) do path |> String.split(",") |> Enum.map(&parse_single_digipeater/1) |> Enum.reject(&is_nil/1) end @spec parse_single_digipeater(String.t()) :: map() | nil defp parse_single_digipeater(<<"q", _::binary-size(2)>> = digi) do %{call: digi, wasdigied: 0} end defp parse_single_digipeater(digi) do parse_digipeater_usage(digi, String.ends_with?(digi, "*")) end @spec parse_digipeater_usage(String.t(), boolean()) :: map() defp parse_digipeater_usage(digi, true) do %{call: String.trim_trailing(digi, "*"), wasdigied: 1} end defp parse_digipeater_usage(digi, false) do %{call: digi, wasdigied: 0} end # Map internal field names to reference parser format @spec map_fields_to_reference_format(map()) :: map() defp map_fields_to_reference_format(packet) do packet |> map_position_ambiguity() |> map_dao_data() |> map_weather_data() |> map_telemetry_data() |> map_format_field() |> map_symbol_fields() |> map_messaging() end @spec map_messaging(map()) :: map() defp map_messaging(%{aprs_messaging?: true} = packet), do: Map.put(packet, :messaging, 1) defp map_messaging(packet), do: packet @spec merge_data_extended(map(), map() | nil) :: map() defp merge_data_extended(base_packet, data_extended) when is_map(data_extended) do Map.merge(base_packet, data_extended) end defp merge_data_extended(base_packet, _), do: base_packet @spec map_position_ambiguity(map()) :: map() defp map_position_ambiguity(%{position_ambiguity: ambiguity} = packet) do Map.put(packet, :posambiguity, ambiguity) end defp map_position_ambiguity(packet), do: packet @spec map_dao_data(map()) :: map() defp map_dao_data(%{dao: %{datum: datum}} = packet) do Map.put(packet, :daodatumbyte, datum) end defp map_dao_data(packet), do: packet @spec map_weather_data(map()) :: map() defp map_weather_data(%{weather: weather_data} = packet) when is_map(weather_data) do Map.put(packet, :wx, weather_data) end defp map_weather_data(packet), do: packet @spec map_telemetry_data(map()) :: map() defp map_telemetry_data(%{telemetry: %{bits: bits}} = packet) do Map.put(packet, :mbits, bits) end defp map_telemetry_data(%{telemetry: telemetry_data} = packet) when is_map(telemetry_data) do Map.put(packet, :mbits, telemetry_data[:bits]) end defp map_telemetry_data(packet), do: packet @spec map_format_field(map()) :: map() defp map_format_field(%{data_extended: %{format: format}} = packet) do Map.put(packet, :format, format) end defp map_format_field(%{compressed?: true} = packet) do Map.put(packet, :format, "compressed") end defp map_format_field(%{format: _format} = packet), do: packet @spec map_symbol_fields(map()) :: map() defp map_symbol_fields(packet) do packet |> Map.put(:symbolcode, Map.get(packet, :symbol_code)) |> Map.put(:symboltable, Map.get(packet, :symbol_table_id)) end # Safely split packet into components using binary pattern matching @spec split_packet(String.t()) :: {:ok, [String.t()]} | {:error, String.t()} def split_packet(message) do case find_delimiter(message, ?>) do {:ok, sender, rest} -> case find_delimiter(rest, ?:) do {:ok, path, data} -> {:ok, [sender, path, data]} :error -> {:error, :invalid_packet} end :error -> {:error, :invalid_packet} end end # Helper function to find delimiter using binary pattern matching @spec find_delimiter(binary(), byte()) :: {:ok, binary(), binary()} | :error defp find_delimiter(binary, delimiter) do find_delimiter(binary, delimiter, 0, binary) end @spec find_delimiter(binary(), byte(), non_neg_integer(), binary()) :: {:ok, binary(), binary()} | :error defp find_delimiter(<>, delimiter, pos, original) do {:ok, binary_part(original, 0, pos), rest} end defp find_delimiter(<<_byte, rest::binary>>, delimiter, pos, original) do find_delimiter(rest, delimiter, pos + 1, original) end defp find_delimiter(<<>>, _delimiter, _pos, _original) do :error end # Binary-safe trim function that handles Unicode characters # Using String.trim/1 is safe here as APRS packets should be ASCII @spec trim_binary(binary()) :: binary() defp trim_binary(binary) do String.trim(binary) end # Safely split path into destination and digipeater path @spec split_path(String.t()) :: {:ok, [String.t()]} | {:error, String.t()} def split_path(path) when is_binary(path) do split = String.split(path, ",", parts: 2) split_path_parts(split) end @spec split_path_parts(list(String.t())) :: {:ok, [String.t()]} | {:error, String.t()} 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} @spec parse_datatype_safe(String.t()) :: {:ok, atom()} def parse_datatype_safe(""), do: {:ok, :empty} def parse_datatype_safe(data), do: {:ok, parse_datatype(data)} @spec parse_callsign(String.t()) :: {:ok, [String.t()]} | {:error, String.t()} def parse_callsign(callsign) do case Aprs.AX25.parse_callsign(callsign) do {:ok, {base, ssid}} -> {:ok, [base, ssid]} {:error, reason} -> {:error, reason} end end # Map of data type indicators to their corresponding atom types @datatype_map %{ ":" => :message, ">" => :status, "!" => :position, "/" => :timestamped_position, "=" => :position_with_message, "@" => :timestamped_position_with_message, ";" => :object, "`" => :mic_e_old, "'" => :mic_e_old, "_" => :weather, "T" => :telemetry, "$" => :raw_gps_ultimeter, "<" => :station_capabilities, "?" => :query, "{" => :user_defined, "}" => :third_party_traffic, "%" => :item, ")" => :item, "*" => :peet_logging, "," => :invalid_test_data } # One of the nutty exceptions in the APRS protocol has to do with this # data type indicator. It's usually the first character of the message. # However, in some rare cases, the ! indicator can be anywhere in the # first 40 characters of the message. I'm not going to deal with that # weird case right now. It seems like its for a specific type of old # TNC hardware that probably doesn't even exist anymore. @spec parse_datatype(String.t()) :: atom() def parse_datatype("#DFS" <> _rest), do: :df_report def parse_datatype("#PHG" <> _rest), do: :phg_data def parse_datatype("#" <> _rest), do: :phg_data def parse_datatype(<> = data) when is_binary(data) do Map.get(@datatype_map, first_char, :unknown_datatype) end def parse_datatype(_), do: :unknown_datatype @spec parse_data(atom(), String.t(), String.t()) :: map() | nil def parse_data(:empty, _destination, _data), do: %{data_type: :empty} def parse_data(:mic_e, destination, data), do: MicE.parse(data, strip_ssid(destination), :mic_e) def parse_data(:mic_e_old, destination, data), do: MicE.parse(data, strip_ssid(destination), :mic_e_old) def parse_data(:object, _destination, data), do: Object.parse(data) def parse_data(:item, _destination, data), do: Item.parse(data) def parse_data(:weather, _destination, data), do: Weather.parse(data) def parse_data(:telemetry, _destination, data), do: Telemetry.parse(data) def parse_data(:status, _destination, data), do: Status.parse(data) def parse_data(:phg_data, _destination, data), do: PHG.parse(data) def parse_data(:peet_logging, _destination, data), do: Aprs.SpecialDataHelpers.parse_peet_logging(data) def parse_data(:invalid_test_data, _destination, data), do: Aprs.SpecialDataHelpers.parse_invalid_test_data(data) def parse_data(:raw_gps_ultimeter, _destination, data) do case Aprs.NMEAHelpers.parse_nmea_sentence(data) do {:ok, nmea_result} -> %{ data_type: :nmea, format: "nmea", latitude: nmea_result.latitude, longitude: nmea_result.longitude, speed: nmea_result[:speed], course: nmea_result[:course], symbol_table_id: "/", symbol_code: "/", position_ambiguity: 0, has_position: true } {:error, error} -> %{ data_type: :raw_gps_ultimeter, error: error, nmea_type: nil, raw_data: data, latitude: nil, longitude: nil } end end def parse_data(:df_report, _destination, data) do if String.starts_with?(data, "DFS") and byte_size(data) >= 7 do <<"DFS", s, h, g, d, rest::binary>> = data %{ df_strength: Aprs.PHGHelpers.parse_df_strength(s), height: Aprs.PHGHelpers.parse_phg_height(h), gain: Aprs.PHGHelpers.parse_phg_gain(g), directivity: Aprs.PHGHelpers.parse_phg_directivity(d), comment: rest, data_type: :df_report } else %{ df_data: data, data_type: :df_report } end end def parse_data(:user_defined, _destination, data), do: parse_user_defined(data) def parse_data(:third_party_traffic, _destination, data), do: parse_third_party_traffic(data) def parse_data(:message, _destination, data) do case Regex.run(~r/^:([^:]+):(.*?)(\{(\d+)\})?$/s, data) do [_, addressee, message_text, _full_ack, message_number] -> trimmed_text = String.trim(message_text) %{ data_type: classify_message_type(trimmed_text), addressee: String.trim(addressee), message_text: trimmed_text, message: trimmed_text, message_number: message_number } [_, addressee, message_text] -> trimmed_text = String.trim(message_text) %{ data_type: classify_message_type(trimmed_text), addressee: String.trim(addressee), message_text: trimmed_text, message: trimmed_text } _ -> %{ data_type: :message, addressee: nil, message: nil, error: "Failed to parse message format" } end end def parse_data(:position, destination, <<"!", rest::binary>>) do parse_data(:position, destination, rest) end def parse_data(:position, _destination, <<"/", _::binary>> = data) do data |> parse_position_without_timestamp() |> handle_position_with_timestamp_result() end def parse_data(:position, _destination, data) do data |> parse_position_without_timestamp() |> handle_position_result(:position) end def parse_data(:position_with_message, _destination, data) do data |> parse_position_with_message_without_timestamp() |> Map.put(:data_type, :position_with_message) 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 case data do <> -> weather_start = String.starts_with?(rest, "_") if weather_start do result = Aprs.parse_position_with_datetime_and_weather( true, time, latitude, sym_table_id, longitude, symbol_code, rest ) add_has_location(result) else result = parse_position_with_timestamp(true, data, :timestamped_position_with_message) add_has_location(result) end _ -> result = parse_position_with_timestamp(true, data, :timestamped_position_with_message) add_has_location(result) end end def parse_data(:station_capabilities, _destination, data), do: parse_station_capabilities(data) def parse_data(:query, _destination, data), do: parse_query(data) # Catch-all for unknown or unsupported types def parse_data(_type, _destination, _data), do: nil # Telemetry definition messages (PARM, UNIT, EQNS, BITS) are typed as telemetry-message by FAP @spec classify_message_type(String.t()) :: :telemetry_message | :message defp classify_message_type(<<"PARM.", _::binary>>), do: :telemetry_message defp classify_message_type(<<"UNIT.", _::binary>>), do: :telemetry_message defp classify_message_type(<<"EQNS.", _::binary>>), do: :telemetry_message defp classify_message_type(<<"BITS.", _::binary>>), do: :telemetry_message defp classify_message_type(_), do: :message @spec handle_position_result(map(), atom()) :: map() defp handle_position_result(%{data_type: :malformed_position} = result, _data_type), do: result defp handle_position_result(result, data_type), do: Map.put(result, :data_type, data_type) @spec handle_position_with_timestamp_result(map()) :: map() defp handle_position_with_timestamp_result(%{data_type: :malformed_position} = result), do: result defp handle_position_with_timestamp_result(result), do: Map.put(result, :data_type, :position) @spec add_has_location(map()) :: map() defp add_has_location(result) do Map.put(result, :has_location, has_valid_coordinates?(result)) end @spec has_valid_coordinates?(map()) :: boolean() defp has_valid_coordinates?(%{latitude: lat, longitude: lon}) do valid_coordinate?(lat) and valid_coordinate?(lon) end defp has_valid_coordinates?(_), do: false @spec parse_position_with_datetime_and_weather( boolean(), binary(), binary(), binary(), binary(), binary(), binary() ) :: map() def parse_position_with_datetime_and_weather( aprs_messaging?, time, latitude, sym_table_id, longitude, symbol_code, weather_report ) do pos = parse_aprs_position(latitude, longitude) weather_data = Weather.parse_weather_data(weather_report) # Remove timestamp from weather data to preserve the position timestamp weather_data_without_timestamp = Map.delete(weather_data, :timestamp) Map.merge( %{ latitude: pos.latitude, longitude: pos.longitude, timestamp: time, symbol_table_id: sym_table_id, symbol_code: symbol_code, weather: weather_data_without_timestamp, data_type: :weather, aprs_messaging?: aprs_messaging? }, weather_data_without_timestamp ) end @spec decode_compressed_position(binary()) :: map() def decode_compressed_position( <<"/", latitude::binary-size(4), longitude::binary-size(4), symbol_code::binary-size(1), _cs::binary-size(2), _compression_type::binary-size(2), _rest::binary>> ) do lat = convert_to_base91(latitude) lon = convert_to_base91(longitude) %{ latitude: lat, longitude: lon, symbol_code: symbol_code } end @spec convert_to_base91(binary()) :: integer() def convert_to_base91(<>) do [v1, v2, v3, v4] = to_charlist(value) (v1 - 33) * 91 * 91 * 91 + (v2 - 33) * 91 * 91 + (v3 - 33) * 91 + v4 end # Helper to extract altitude from comment field (e.g., "/A=000680" or "/A=-00088") @spec extract_altitude_and_clean_comment(String.t()) :: {float() | nil, String.t()} defp extract_altitude_and_clean_comment(comment) do case Regex.run(~r"/([Aa])=(-?\d{5,6})", comment) do [full_match, case_letter, altitude_str] -> altitude = String.to_integer(altitude_str) * 1.0 cleaned_comment = if case_letter == "a" do # lowercase /a= - keep a=NNNNNN in comment (FAP-compatible) comment |> String.replace(full_match, "a=" <> altitude_str) |> String.trim() else # uppercase /A= - strip entirely from comment comment |> String.replace(full_match, "") |> strip_leading_slash() |> String.trim() end {altitude, cleaned_comment} _ -> {nil, comment} end end @spec strip_leading_slash(String.t()) :: String.t() defp strip_leading_slash(<<"/", rest::binary>>), do: rest defp strip_leading_slash(comment), do: comment # Strip SSID from callsign (FAP.pm: $dstcallsign =~ s/-\d+$//;) @spec strip_ssid(String.t() | nil) :: String.t() | nil defp strip_ssid(nil), do: nil defp strip_ssid(callsign) do case Regex.run(~r/^(.+)-\d+$/, callsign) do [_, base] -> base _ -> callsign end end # Strip leading / or space from comment (FAP.pm line 1211: $rest =~ s/^[\/\s]//;) @spec strip_leading_delimiter(String.t()) :: String.t() defp strip_leading_delimiter(<<"/", rest::binary>>), do: rest defp strip_leading_delimiter(<<" ", rest::binary>>), do: rest defp strip_leading_delimiter(comment), do: comment # Extract APRS data extension from the start of comment. # FAP.pm processes Course/Speed OR PHG OR RNG as mutually exclusive, # all anchored to the start of the comment text. @spec extract_data_extension(String.t()) :: {integer() | nil, float() | nil, String.t() | nil, String.t() | nil, String.t()} defp extract_data_extension(comment) do cond do # Course/Speed: NNN/NNN at start (7 chars) match = Regex.run(~r/^([0-9. ]{3})\/([0-9. ]{3})/, comment) -> [full_match, course_str, speed_str] = match rest = binary_part(comment, byte_size(full_match), byte_size(comment) - byte_size(full_match)) course = parse_course_value(course_str) speed = if Regex.match?(~r/^\d{3}$/, speed_str), do: String.to_integer(speed_str) * 1.0 {course, speed, nil, nil, rest} # PHGR: PHG + 4 chars + rate char + / (8 chars stripped) Regex.match?(~r/^PHG\d[\x30-\x7e]\d\d[0-9A-Z]\//, comment) -> phg_string = binary_part(comment, 3, 4) rest = binary_part(comment, 8, byte_size(comment) - 8) {nil, nil, phg_string, nil, rest} # PHG: PHG + 4 chars (7 chars stripped) Regex.match?(~r/^PHG\d[\x30-\x7e]\d\d/, comment) -> phg_string = binary_part(comment, 3, 4) rest = binary_part(comment, 7, byte_size(comment) - 7) {nil, nil, phg_string, nil, rest} # RNG: RNG + 4 digits (7 chars stripped) match = Regex.run(~r/^RNG(\d{4})/, comment) -> [_full, range_digits] = match range_miles = String.to_integer(range_digits) rest = binary_part(comment, 7, byte_size(comment) - 7) {nil, nil, nil, Integer.to_string(range_miles), rest} true -> {nil, nil, nil, nil, comment} end end @spec parse_course_value(String.t()) :: non_neg_integer() defp parse_course_value(course_str) do if Regex.match?(~r/^\d{3}$/, course_str) do c = String.to_integer(course_str) if c >= 1 and c <= 360, do: c, else: 0 else 0 end end # Helper to extract PHG data from comment # PHG format: PHG followed by 4+ digits, optionally followed by / @spec extract_phg_data(String.t()) :: {map() | nil, String.t()} defp extract_phg_data(comment) do case Regex.run(~r"PHG(\d)(\d)(\d)(\d)(?:\d+/)?\s?", comment) do [full_match, p, h, g, d] -> <> = p <> = h <> = g <> = d {power_val, _} = Aprs.PHGHelpers.parse_phg_power(p_char) {height_val, _} = Aprs.PHGHelpers.parse_phg_height(h_char) {gain_val, _} = Aprs.PHGHelpers.parse_phg_gain(g_char) {dir_val, _} = Aprs.PHGHelpers.parse_phg_directivity(d_char) phg_map = %{ power: power_val, height: height_val, gain: gain_val, directivity: dir_val } cleaned_comment = comment |> String.replace(full_match, "") |> String.trim() {phg_map, cleaned_comment} _ -> {nil, comment} end end # Helper to extract PHG string from comment (for compatibility) # Returns exactly 4 PHG digits matching FAP behavior @spec extract_phg_string(String.t()) :: String.t() | nil defp extract_phg_string(comment) do case Regex.run(~r"PHG(\d{4})", comment) do [_, phg_digits] -> phg_digits _ -> nil end end # Strip weather parameters from a comment string, returning only the non-weather part. # Matches FAP.pm _wx_parse behavior: # - Initial wind/gust/temp pattern is front-anchored # - Secondary fields use non-anchored first-match-only replacement (Perl s/...//) @spec strip_weather_from_comment(String.t()) :: String.t() defp strip_weather_from_comment(comment) do comment |> strip_weather_initial_match() |> strip_weather_secondary_fields() |> strip_weather_nodata_patterns() |> String.trim() end # Front-anchored initial match: wind direction/speed + gust + temp (FAP lines 2115-2131) @spec strip_weather_initial_match(String.t()) :: String.t() defp strip_weather_initial_match(s) do cond do # _NNN/NNNgNNNtNNN or cNNNsNNNgNNNtNNN m = Regex.run(~r/^_{0,1}([\d .\-]{3})\/([\d .]{3})g([\d .]+)t(-?\d{1,3}|\.{2,3})/, s) -> binary_part(s, byte_size(hd(m)), byte_size(s) - byte_size(hd(m))) m = Regex.run(~r/^_{0,1}c([\d .\-]{3})s([\d .]{3})g([\d .]+)t(-?\d{1,3}|\.{2,3})/, s) -> binary_part(s, byte_size(hd(m)), byte_size(s) - byte_size(hd(m))) # wind + temp (no gust) m = Regex.run(~r/^_{0,1}([\d .\-]{3})\/([\d .]{3})t(-?\d{1,3}|\.{2,3})/, s) -> binary_part(s, byte_size(hd(m)), byte_size(s) - byte_size(hd(m))) # wind + gust (no temp) m = Regex.run(~r/^_{0,1}([\d .\-]{3})\/([\d .]{3})g([\d .]+)/, s) -> binary_part(s, byte_size(hd(m)), byte_size(s) - byte_size(hd(m))) # gust + temp only (no wind direction/speed prefix) m = Regex.run(~r/^g(\d+)t(-?\d{1,3}|\.{2,3})/, s) -> binary_part(s, byte_size(hd(m)), byte_size(s) - byte_size(hd(m))) true -> s end end # Non-anchored first-match-only replacement for secondary weather fields # (FAP lines 2133-2178). Each field replaced at most once (no global flag). @weather_secondary_patterns [ ~r/t(-?\d{1,3})/, ~r/r(\d{1,3})/, ~r/p(\d{1,3})/, ~r/P(\d{1,3})/, ~r/h(\d{1,3})/, ~r/b(\d{4,5})/, ~r/[lL](\d{1,3})/, ~r/v([\-+]?\d+)/, ~r/s(\d{1,3})/, ~r/#(\d+)/ ] @spec strip_weather_secondary_fields(String.t()) :: String.t() defp strip_weather_secondary_fields(s) do Enum.reduce(@weather_secondary_patterns, s, fn pattern, acc -> String.replace(acc, pattern, "", global: false) end) end # Strip remaining no-data patterns like "r..." "P..." (FAP line 2180) @spec strip_weather_nodata_patterns(String.t()) :: String.t() defp strip_weather_nodata_patterns(s) do String.replace(s, ~r/^([rPphblLs#][\. ]{1,5})+/, "") end # Patch parse_position_without_timestamp to include course/speed @spec parse_position_without_timestamp(String.t()) :: map() | nil def parse_position_without_timestamp( <> = position_data ) do if valid_aprs_coordinate?(latitude, longitude) do parse_position_uncompressed(latitude, sym_table_id, longitude, symbol_code, comment) else try_parse_compressed_without_prefix(position_data) end end def parse_position_without_timestamp( <> = position_data ) do if valid_aprs_coordinate?(latitude, longitude) do parse_position_short_uncompressed(latitude, sym_table_id, longitude) else try_parse_compressed_without_prefix(position_data) end end # Try compressed position for data >= 13 bytes that didn't match uncompressed patterns def parse_position_without_timestamp(position_data) when byte_size(position_data) >= 13 do try_parse_compressed_without_prefix(position_data) end def parse_position_without_timestamp(_invalid_data) do %{data_type: :malformed_position, error: "Invalid position format"} end # Helper function to validate APRS coordinates using binary pattern matching @spec valid_aprs_coordinate?(String.t(), String.t()) :: boolean() defp valid_aprs_coordinate?(lat, lon) do lat_valid = valid_latitude_format?(lat) lon_valid = valid_longitude_format?(lon) lat_valid and lon_valid end # Accept digits and spaces in minute/fraction positions (APRS position ambiguity) # FAP regex: (\d{2})([0-7 ][0-9 ]\.[0-9 ]{2})([NnSs]) @spec valid_latitude_format?(binary()) :: boolean() defp valid_latitude_format?(<>) when is_digit(d1) and is_digit(d2) and is_minute_tens(m1) and is_digit_or_space(m2) and is_digit_or_space(f1) and is_digit_or_space(f2) and dir in [?N, ?S] do true end defp valid_latitude_format?(_), do: false # FAP regex: (\d{3})([0-7 ][0-9 ]\.[0-9 ]{2})([EeWw]) @spec valid_longitude_format?(binary()) :: boolean() defp valid_longitude_format?(<>) when is_digit(d1) and is_digit(d2) and is_digit(d3) and is_minute_tens(m1) and is_digit_or_space(m2) and is_digit_or_space(f1) and is_digit_or_space(f2) and dir in [?E, ?W] do true end defp valid_longitude_format?(_), do: false # Helper function to try parsing as compressed position without "/" prefix @spec try_parse_compressed_without_prefix(binary()) :: map() defp try_parse_compressed_without_prefix(position_data) do case position_data do # Handle "/" symbol table compressed positions with DAO check <<"/", 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>> when cs == "&!" -> parse_position_compressed_with_full_data( "/", latitude_compressed, longitude_compressed, symbol_code, # No real course/speed "", # No compression type " ", compression_type <> comment ) # Handle "/" symbol table compressed positions <<"/", 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>> -> parse_position_compressed_with_full_data( "/", latitude_compressed, longitude_compressed, symbol_code, cs, compression_type, comment ) # Alternate symbol table compressed format with cs + compression type <> when byte_size(position_data) >= 13 and sym_table_id != <<"/">> and sym_table_id >= <<"!">> and sym_table_id <= <<"~">> -> parse_position_compressed_with_full_data( sym_table_id, latitude_compressed, longitude_compressed, symbol_code, cs, compression_type, comment ) <> when byte_size(position_data) >= 13 -> parse_position_compressed_missing_prefix( latitude_compressed, longitude_compressed, symbol_code, cs, compression_type, comment ) _ -> parse_position_malformed(position_data) end end @spec parse_position_uncompressed(String.t(), String.t(), String.t(), String.t(), String.t()) :: map() defp parse_position_uncompressed(latitude, sym_table_id, longitude, symbol_code, comment) do %{latitude: lat, longitude: lon, ambiguity: ambiguity} = parse_aprs_position(latitude, longitude) # FAP.pm order: Course/Speed OR PHG OR RNG (mutually exclusive, from start) {course, speed, phg_string, radiorange, comment_after_ext} = extract_data_extension(comment) # Then altitude (anywhere in comment) {altitude, comment_after_alt} = extract_altitude_and_clean_comment(comment_after_ext) # Then comment telemetry |...| {_telemetry, comment_after_telemetry} = Aprs.TelemetryFromComment.extract_telemetry_from_comment(comment_after_alt) # Then DAO !XYZ! {dao_data, comment_after_dao} = parse_dao_extension(comment_after_telemetry) # Strip leading / or space (FAP line 1211) comment_cleaned = comment_after_dao |> strip_leading_delimiter() |> String.trim() has_position = valid_coordinate?(lat) and valid_coordinate?(lon) posresolution = Aprs.UtilityHelpers.calculate_position_resolution(ambiguity) base_map = %{ latitude: lat, longitude: lon, timestamp: nil, symbol_table_id: sym_table_id, symbol_code: symbol_code, comment: comment_cleaned, altitude: altitude, phg: phg_string, aprs_messaging?: false, compressed?: false, position_ambiguity: ambiguity, dao: dao_data, course: course, speed: speed, has_position: has_position, posresolution: posresolution, format: "uncompressed", posambiguity: ambiguity, messaging: 0, radiorange: radiorange, wx: nil } # Check if this is a weather packet and merge accordingly merge_weather_if_present(base_map, sym_table_id, symbol_code, comment) end @spec parse_position_short_uncompressed(String.t(), String.t(), String.t()) :: map() defp parse_position_short_uncompressed(latitude, sym_table_id, longitude) do %{latitude: lat, longitude: lon, ambiguity: ambiguity} = parse_aprs_position(latitude, longitude) has_position = valid_coordinate?(lat) and valid_coordinate?(lon) %{ latitude: lat, longitude: lon, timestamp: nil, symbol_table_id: sym_table_id, symbol_code: "_", data_type: :position, aprs_messaging?: false, compressed?: false, position_ambiguity: ambiguity, dao: nil, course: nil, speed: nil, has_position: has_position, format: "uncompressed", # Standard parser fields posambiguity: ambiguity, messaging: 0 } end @spec parse_position_compressed_with_full_data( String.t(), binary(), binary(), String.t(), binary(), binary(), String.t() ) :: map() defp parse_position_compressed_with_full_data( sym_table_id, latitude_compressed, longitude_compressed, symbol_code, cs, compression_type, comment ) do case {Aprs.CompressedPositionHelpers.convert_compressed_lat(latitude_compressed), Aprs.CompressedPositionHelpers.convert_compressed_lon(longitude_compressed)} do {{:ok, converted_lat}, {:ok, converted_lon}} -> compressed_cs = Aprs.CompressedPositionHelpers.convert_compressed_cs(cs) compression_info = Aprs.CompressedPositionHelpers.parse_compression_type(compression_type) ambiguity = compression_info.position_resolution has_position = valid_coordinate?(converted_lat) and valid_coordinate?(converted_lon) # FAP only strips telemetry for non-weather compressed positions # (via _comments_to_decimal). Weather packets go through _wx_parse which # does NOT strip telemetry. {telemetry, comment_for_processing} = if symbol_code == "_" do # Weather packet: extract telemetry values but keep text in comment {telemetry_data, _} = Aprs.TelemetryFromComment.extract_telemetry_from_comment(comment) {telemetry_data, comment} else Aprs.TelemetryFromComment.extract_telemetry_from_comment(comment) end # Parse DAO extension from comment {dao_data, cleaned_comment_after_dao} = parse_dao_extension(comment_for_processing) # Extract altitude and PHG from compressed position comment {altitude, cleaned_comment_after_alt} = extract_altitude_and_clean_comment(cleaned_comment_after_dao) {_phg_data, cleaned_comment_after_phg} = extract_phg_data(cleaned_comment_after_alt) phg_string = extract_phg_string(cleaned_comment_after_alt) cleaned_comment_after_dao = cleaned_comment_after_phg posresolution = Aprs.UtilityHelpers.calculate_compressed_position_resolution() base_data = %{ latitude: converted_lat, longitude: converted_lon, symbol_table_id: sym_table_id, symbol_code: symbol_code, comment: String.trim(cleaned_comment_after_dao), position_format: :compressed, compression_type: compression_type, compression_info: compression_info, data_type: :position, compressed?: true, position_ambiguity: ambiguity, dao: dao_data, has_position: has_position, posresolution: posresolution, format: "compressed", posambiguity: ambiguity, messaging: compression_info.aprs_messaging, altitude: altitude, phg: phg_string } base_data = if telemetry == nil do base_data else Map.put(base_data, :telemetry, telemetry) end # Add course and speed if available base_data = Map.merge(base_data, compressed_cs) # Merge weather data for weather station symbol merge_weather_if_present(base_data, sym_table_id, symbol_code, cleaned_comment_after_dao) {{:error, lat_error}, _} -> %{ data_type: :position_error, error_message: "Invalid compressed location: #{lat_error}", has_position: false } {_, {:error, lon_error}} -> %{ data_type: :position_error, error_message: "Invalid compressed location: #{lon_error}", has_position: false } _ -> %{ data_type: :position_error, error_message: "Invalid compressed location", has_position: false } end end @spec parse_position_compressed_missing_prefix(binary(), binary(), String.t(), binary(), binary(), String.t()) :: map() defp parse_position_compressed_missing_prefix( latitude_compressed, longitude_compressed, symbol_code, cs, compression_type, comment ) do # IO.puts("parse_position_compressed_missing_prefix called with comment: #{comment}") case {Aprs.CompressedPositionHelpers.convert_compressed_lat(latitude_compressed), Aprs.CompressedPositionHelpers.convert_compressed_lon(longitude_compressed)} do {{:ok, converted_lat}, {:ok, converted_lon}} -> compressed_cs = Aprs.CompressedPositionHelpers.convert_compressed_cs(cs) # Parse full compression type information compression_info = Aprs.CompressedPositionHelpers.parse_compression_type(compression_type) ambiguity = compression_info.position_resolution has_position = valid_coordinate?(converted_lat) and valid_coordinate?(converted_lon) # Parse DAO extension from comment {dao_data, cleaned_comment_after_dao} = parse_dao_extension(comment) base_data = %{ latitude: converted_lat, longitude: converted_lon, symbol_table_id: "/", symbol_code: symbol_code, comment: cleaned_comment_after_dao, position_format: :compressed, compression_type: compression_type, compression_info: compression_info, data_type: :position, compressed?: true, position_ambiguity: ambiguity, dao: dao_data, has_position: has_position } Map.merge(base_data, compressed_cs) {{:error, lat_error}, _} -> %{ data_type: :position_error, error_message: "Invalid compressed location: #{lat_error}", has_position: false } {_, {:error, lon_error}} -> %{ data_type: :position_error, error_message: "Invalid compressed location: #{lon_error}", has_position: false } _ -> %{ data_type: :position_error, error_message: "Invalid compressed location", has_position: false } end end @spec parse_position_malformed(String.t()) :: map() defp parse_position_malformed(position_data) do %{ latitude: nil, longitude: nil, timestamp: nil, symbol_table_id: nil, symbol_code: nil, data_type: :malformed_position, aprs_messaging?: false, compressed?: false, comment: String.trim(position_data), dao: nil, course: nil, speed: nil, has_position: false } end # Patch parse_position_with_message_without_timestamp to propagate course/speed @spec parse_position_with_message_without_timestamp(String.t()) :: map() def parse_position_with_message_without_timestamp(position_data) do position_data |> parse_position_without_timestamp() |> Map.put(:aprs_messaging?, true) end # Patch parse_position_with_timestamp to extract course/speed from comment @spec parse_position_with_timestamp(boolean(), binary(), atom()) :: map() def parse_position_with_timestamp( aprs_messaging?, <>, data_type ) do if valid_aprs_coordinate?(latitude, longitude) do build_position_result(aprs_messaging?, latitude, longitude, time, sym_table_id, symbol_code, comment, data_type) else handle_invalid_position_data( aprs_messaging?, time, latitude, sym_table_id, longitude, symbol_code, comment, data_type ) end end def parse_position_with_timestamp(_aprs_messaging?, _data, _data_type) do %{ data_type: :timestamped_position_error, error: "Invalid timestamped position format" } end @spec handle_invalid_position_data(boolean(), binary(), binary(), binary(), binary(), binary(), binary(), atom()) :: map() defp handle_invalid_position_data( aprs_messaging?, time, latitude, sym_table_id, longitude, symbol_code, comment, data_type ) do position_data = latitude <> sym_table_id <> longitude <> symbol_code <> comment # Try compressed position first (13+ bytes starting with symbol table char) case try_compressed_timestamped(position_data, aprs_messaging?, time, data_type) do {:ok, result} -> result :error -> # Fallback: try to extract lat/lon using regex try_regex_position_fallback(aprs_messaging?, time, position_data) end end @spec try_compressed_timestamped(binary(), boolean(), binary(), atom()) :: {:ok, map()} | :error defp try_compressed_timestamped(position_data, aprs_messaging?, time, data_type) when byte_size(position_data) >= 13 do compressed_result = try_parse_compressed_without_prefix(position_data) case compressed_result do %{data_type: type} when type in [:malformed_position, :position_error] -> :error %{has_position: false} -> :error %{latitude: lat, longitude: lon} = result when is_number(lat) and is_number(lon) -> unix_timestamp = Aprs.UtilityHelpers.validate_timestamp(time) {:ok, result |> Map.put(:timestamp, unix_timestamp) |> Map.put(:time, unix_timestamp) |> Map.put(:aprs_messaging?, aprs_messaging?) |> Map.put(:data_type, data_type) |> Map.put(:messaging, if(aprs_messaging?, do: 1, else: 0))} _ -> :error end end defp try_compressed_timestamped(_position_data, _aprs_messaging?, _time, _data_type), do: :error @spec try_regex_position_fallback(boolean(), String.t(), String.t()) :: map() defp try_regex_position_fallback(aprs_messaging?, time, raw_data) do regex = ~r/^(?\d{4,5}\.\d+[NS])(?.)(?\d{5,6}\.\d+[EW])(?.)(?.*)$/ case Regex.named_captures(regex, raw_data) do %{ "lat" => lat, "lon" => lon, "sym_table" => sym_table, "sym_code" => sym_code, "comment" => comment } -> build_fallback_position_result(aprs_messaging?, lat, lon, time, sym_table, sym_code, comment) _ -> %{ data_type: :timestamped_position_error, error: "Invalid timestamped position format", raw_data: time <> raw_data } end end @spec build_fallback_position_result(boolean(), String.t(), String.t(), String.t(), String.t(), String.t(), String.t()) :: map() defp build_fallback_position_result(aprs_messaging?, lat, lon, time, sym_table, sym_code, comment) do pos = parse_aprs_position(lat, lon) ambiguity = Map.get(pos, :ambiguity, 0) # FAP.pm order: data extension → altitude → telemetry → DAO → leading delimiter {course, speed, phg_string, radiorange, comment_after_ext} = extract_data_extension(comment) {altitude, comment_after_alt} = extract_altitude_and_clean_comment(comment_after_ext) {_telemetry, comment_after_telemetry} = Aprs.TelemetryFromComment.extract_telemetry_from_comment(comment_after_alt) {_dao_data, comment_after_dao} = parse_dao_extension(comment_after_telemetry) comment_cleaned = comment_after_dao |> strip_leading_delimiter() |> String.trim() base_map = %{ latitude: pos.latitude, longitude: pos.longitude, time: time, timestamp: time, symbol_table_id: sym_table, symbol_code: sym_code, comment: comment_cleaned, aprs_messaging?: aprs_messaging?, compressed?: false, altitude: altitude, phg: phg_string, radiorange: radiorange, course: course, speed: speed, position_ambiguity: ambiguity, posambiguity: ambiguity } merge_weather_if_present(base_map, sym_table, sym_code, comment) end @spec build_position_result(boolean(), String.t(), String.t(), String.t(), String.t(), String.t(), String.t(), atom()) :: map() defp build_position_result(aprs_messaging?, lat, lon, time, sym_table_id, symbol_code, comment, data_type) do position = parse_aprs_position(lat, lon) ambiguity = Map.get(position, :ambiguity, 0) unix_timestamp = Aprs.UtilityHelpers.validate_timestamp(time) # FAP.pm order: data extension → altitude → telemetry → DAO → leading delimiter {course, speed, phg_string, radiorange, comment_after_ext} = extract_data_extension(comment) {altitude, comment_after_alt} = extract_altitude_and_clean_comment(comment_after_ext) {_telemetry, comment_after_telemetry} = Aprs.TelemetryFromComment.extract_telemetry_from_comment(comment_after_alt) {dao_data, comment_after_dao} = parse_dao_extension(comment_after_telemetry) comment_cleaned = comment_after_dao |> strip_leading_delimiter() |> String.trim() posresolution = Aprs.UtilityHelpers.calculate_position_resolution(ambiguity) base_map = %{ latitude: position.latitude, longitude: position.longitude, position: position, time: unix_timestamp, timestamp: unix_timestamp, symbol_table_id: sym_table_id, symbol_code: symbol_code, comment: comment_cleaned, aprs_messaging?: aprs_messaging?, compressed?: false, course: course, speed: speed, altitude: altitude, phg: phg_string, radiorange: radiorange, dao: dao_data, data_type: data_type, format: "uncompressed", messaging: if(aprs_messaging?, do: 1, else: 0), position_ambiguity: ambiguity, posambiguity: ambiguity, posresolution: posresolution } merge_weather_if_present(base_map, sym_table_id, symbol_code, comment) end # Status Report parsing def parse_status(<<">", status_text::binary>>) do %{ status_text: status_text, data_type: :status } end @spec parse_status(String.t()) :: map() def parse_status(data) do %{ status_text: data, data_type: :status } end # Station Capabilities parsing def parse_station_capabilities(<<"<", capabilities::binary>>) do %{ capabilities: capabilities, data_type: :station_capabilities } end @spec parse_station_capabilities(String.t()) :: map() def parse_station_capabilities(data) do %{ capabilities: data, data_type: :station_capabilities } end # Query parsing def parse_query(<<"?", query_type::binary-size(1), query_data::binary>>) do %{ query_type: query_type, query_data: query_data, data_type: :query } end @spec parse_query(String.t()) :: map() def parse_query(data) do %{ query_data: data, data_type: :query } end # User Defined parsing def parse_user_defined(<<"{", user_id::binary-size(1), user_data::binary>>) do parsed_data = parse_user_defined_format(user_id, user_data) Map.merge( %{ user_id: user_id, data_type: :user_defined, raw_data: user_data }, parsed_data ) end @spec parse_user_defined(String.t()) :: map() def parse_user_defined(data) do %{ user_data: data, data_type: :user_defined } end # Map of user-defined format identifiers @user_defined_formats %{ "A" => :experimental_a, "B" => :experimental_b, "C" => :custom_c } # Parse specific user-defined formats @spec parse_user_defined_format(String.t(), String.t()) :: map() defp parse_user_defined_format(user_id, user_data) do format = Map.get(@user_defined_formats, user_id, :unknown) %{format: format, content: user_data} end # Third Party Traffic parsing def parse_third_party_traffic(packet) do parse_third_party_with_depth_check(packet, Aprs.UtilityHelpers.count_leading_braces(packet)) end @spec parse_third_party_with_depth_check(String.t(), integer()) :: map() defp parse_third_party_with_depth_check(_packet, depth) when depth + 1 > 3 do %{error: "Maximum tunnel depth exceeded"} end defp parse_third_party_with_depth_check(packet, _depth) do case parse_tunneled_packet(packet) do {:ok, parsed_packet} -> build_third_party_traffic_result(packet, parsed_packet) {:error, reason} -> %{error: reason} end end @spec build_third_party_traffic_result(String.t(), map()) :: map() defp build_third_party_traffic_result(packet, parsed_packet) do case parse_nested_tunnel(packet) do {:ok, nested_packet} -> %{ third_party_packet: nested_packet, data_type: :third_party_traffic, raw_data: packet } {:error, _} -> %{ third_party_packet: parsed_packet, data_type: :third_party_traffic, raw_data: packet } end end @spec parse_tunneled_packet(String.t()) :: {:ok, map()} | {:error, String.t()} defp parse_tunneled_packet(packet) do case String.split(packet, ":", parts: 2) do [header, information] -> parse_tunneled_packet_with_header(header, information) _ -> {:error, "Invalid tunneled packet format"} end end @spec parse_tunneled_packet_with_header(String.t(), String.t()) :: {:ok, map()} | {:error, String.t()} defp parse_tunneled_packet_with_header(header, information) do case parse_tunneled_header(header) do {:ok, header_data} -> parse_tunneled_packet_with_information(header_data, information) {:error, reason} -> {:error, "Invalid header: #{reason}"} end end @spec parse_tunneled_packet_with_information(map(), String.t()) :: {:ok, map()} defp parse_tunneled_packet_with_information(header_data, information) do {:ok, data_type} = parse_datatype_safe(information) data_without_type = String.slice(information, 1..-1//1) data_extended = parse_data(data_type, header_data.destination, data_without_type) {:ok, Map.merge(header_data, %{ information_field: information, data_type: data_type, data_extended: data_extended })} end @spec parse_tunneled_header(String.t()) :: {:ok, map()} | {:error, String.t()} defp parse_tunneled_header(header) do case String.split(header, ">", parts: 2) do [sender, path] -> parse_sender_and_path(sender, path) _ -> {:error, "Invalid header format"} end end @spec parse_sender_and_path(String.t(), String.t()) :: {:ok, map()} | {:error, String.t()} defp parse_sender_and_path(sender, path) do case parse_callsign(sender) do {:ok, callsign_parts} -> base_callsign = List.first(callsign_parts) ssid = List.last(callsign_parts) case split_path_for_tunnel(path) do {:ok, [destination, digi_path]} -> {:ok, %{ sender: sender, base_callsign: base_callsign, ssid: ssid, destination: destination, digi_path: digi_path }} {:error, reason} -> {:error, "Invalid path: #{reason}"} end {:error, reason} -> {:error, "Invalid callsign: #{reason}"} end end @spec split_path_for_tunnel(String.t()) :: {:ok, [String.t()]} | {:error, String.t()} defp split_path_for_tunnel(path) do split_path(path) end # Add network tunneling support @spec parse_network_tunnel(String.t()) :: {:ok, map()} | {:error, String.t()} defp parse_network_tunnel(packet) do # Network tunneling packets start with "}" and contain a complete APRS packet tunneled_packet = String.slice(packet, 1..-1//1) case parse_tunneled_packet(tunneled_packet) do {:ok, parsed_packet} -> {:ok, Map.merge(parsed_packet, %{ tunnel_type: :network, raw_data: packet })} {:error, reason} -> {:error, "Invalid tunneled packet: #{reason}"} end end # Add support for multiple levels of tunneling @spec parse_nested_tunnel(String.t(), non_neg_integer()) :: {:ok, map()} | {:error, String.t()} defp parse_nested_tunnel(packet, depth \\ 0) do cond do depth > 3 -> {:error, "Maximum tunnel depth exceeded"} String.starts_with?(packet, "}") -> case parse_network_tunnel(packet) do {:ok, parsed_packet} -> handle_parsed_network_tunnel(parsed_packet, depth) {:error, reason} -> {:error, reason} end true -> {:error, "Not a tunneled packet"} end end @spec handle_parsed_network_tunnel(map(), non_neg_integer()) :: {:ok, map()} defp handle_parsed_network_tunnel(parsed_packet, depth) do case Map.get(parsed_packet, :data_extended) do %{raw_data: nested_data} when is_binary(nested_data) -> case parse_nested_tunnel(nested_data, depth + 1) do {:ok, nested_packet} -> {:ok, Map.put(parsed_packet, :nested_packet, nested_packet)} {:error, _} -> {:ok, parsed_packet} end _ -> {:ok, parsed_packet} end end # Add DAO (Datum) extension support @spec parse_dao_extension(String.t()) :: {map() | nil, String.t()} defp parse_dao_extension(comment) do # IO.puts("parse_dao_extension called with comment: #{comment}") case Regex.run(~r/!([A-Za-z])([\x21-\x7b])([\x21-\x7b])!/, comment) do [full_match, lat_dao, lon_dao, _] -> cleaned_comment = comment |> String.replace(full_match, "") |> String.trim() dao_data = %{ lat_dao: lat_dao, lon_dao: lon_dao, datum: "WGS84" } # IO.puts("DAO regex 1 matched: #{inspect(dao_data)}") {dao_data, cleaned_comment} _ -> # Try alternative DAO format with & prefix case Regex.run(~r/&!([A-Za-z])/, comment) do [full_match, datum_byte] -> cleaned_comment = comment |> String.replace(full_match, "") |> String.trim() dao_data = %{ lat_dao: nil, lon_dao: nil, datum: datum_byte } # IO.puts("DAO regex 2 matched: #{inspect(dao_data)}") {dao_data, cleaned_comment} _ -> # IO.puts("DAO regex did not match") {nil, comment} end end end # Helper to check if coordinate is valid (reduces redundant checks) @spec valid_coordinate?(float() | nil) :: boolean() defp valid_coordinate?(coord) do is_number(coord) end # Weather merging for position packets. # Position packets with the `_` weather symbol (any symbol table, including overlays) # get weather data extracted. The data_type is NOT changed here. @spec merge_weather_if_present(map(), String.t(), String.t(), String.t()) :: map() defp merge_weather_if_present(base_map, _sym_table_id, "_", comment) do weather_data = Weather.parse_weather_data(comment) weather_data_without_timestamp = Map.delete(weather_data, :timestamp) cleaned_comment = strip_weather_from_comment(comment) base_map |> Map.put(:weather, weather_data_without_timestamp) |> Map.put(:wx, Map.get(weather_data_without_timestamp, :wx, weather_data_without_timestamp)) |> Map.put(:comment, cleaned_comment) end defp merge_weather_if_present(base_map, _sym_table_id, _symbol_code, _comment), do: base_map end