# @dialyzer {:nowarn_function, parse_data: 3} # @dialyzer {:nowarn_function, parse_object: 1} defmodule Parser do @moduledoc """ Main parsing library """ # import Bitwise alias Aprs.Convert alias Parser.Types.MicE require Logger # Simple APRS position parsing to replace parse_aprs_position 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(), path: String.t(), destination: String.t(), information_field: String.t(), data_type: atom(), base_callsign: String.t(), ssid: String.t(), data_extended: map() | nil, received_at: DateTime.t() } @type parse_result :: {:ok, packet()} | {:error, atom() | String.t()} @type position_ambiguity :: 0..4 @spec parse(String.t()) :: parse_result() def parse(message) when is_binary(message) do do_parse(message) rescue error -> Logger.debug("PARSE ERROR: #{inspect(error)} for message: #{message}") {:error, :invalid_packet} end def parse(_), do: {:error, :invalid_packet} defp do_parse(message) do with {:ok, [sender, path, data]} <- split_packet(message), {:ok, [base_callsign, ssid]} <- parse_callsign(sender), {:ok, data_type} <- parse_datatype_safe(data), {:ok, [destination, path]} <- split_path(path), :ok <- validate_callsign(sender, :src), :ok <- validate_callsign(destination, :dst), :ok <- validate_path(path) do data_trimmed = String.trim(data) data_without_type = String.slice(data_trimmed, 1..-1//1) data_extended = parse_data(data_type, destination, data_without_type) {:ok, %{ id: 16 |> :crypto.strong_rand_bytes() |> Base.encode16(case: :lower), sender: sender, path: path, destination: destination, information_field: data_trimmed, data_type: data_type, base_callsign: base_callsign, ssid: ssid, data_extended: data_extended, received_at: DateTime.truncate(DateTime.utc_now(), :microsecond) }} else {:error, reason} when is_binary(reason) -> case reason do "Invalid packet format" -> {:error, "Invalid packet format"} _ -> {:error, reason} end end rescue error -> Logger.debug("PARSE ERROR: #{inspect(error)} for message: #{message}") {:error, :invalid_packet} end # Validate callsign for AX.25 compliance 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, :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) 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 split_packet_parts(String.split(message, [">", ":"], parts: 3)) end @spec split_packet_parts([String.t()]) :: {:ok, [String.t()]} | {:error, String.t()} defp split_packet_parts([sender, path, data]) when byte_size(sender) > 0 and byte_size(path) > 0 do {:ok, [sender, path, data]} end @spec split_packet_parts(list()) :: {:error, String.t()} defp split_packet_parts(_), do: {:error, "Invalid packet format"} # 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 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"} 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 Parser.AX25.parse_callsign(callsign) do {:ok, {base, ssid}} -> {:ok, [base, ssid]} {:error, reason} -> {:error, reason} end end # 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(<<":", _::binary>>), do: :message def parse_datatype(<<">", _::binary>>), do: :status def parse_datatype(<<"!", _::binary>>), do: :position def parse_datatype(<<"/", _::binary>>), do: :timestamped_position def parse_datatype(<<"=", _::binary>>), do: :position_with_message def parse_datatype(<<"@", _::binary>>), do: :timestamped_position_with_message def parse_datatype(<<";", _::binary>>), do: :object def parse_datatype(<<"`", _::binary>>), do: :mic_e def parse_datatype(<<"'", _::binary>>), do: :mic_e_old def parse_datatype(<<"_", _::binary>>), do: :weather def parse_datatype(<<"T", _::binary>>), do: :telemetry def parse_datatype(<<"$", _::binary>>), do: :raw_gps_ultimeter def parse_datatype(<<"<", _::binary>>), do: :station_capabilities def parse_datatype(<<"?", _::binary>>), do: :query def parse_datatype(<<"{", _::binary>>), do: :user_defined def parse_datatype(<<"}", _::binary>>), do: :third_party_traffic def parse_datatype(<<"%", _::binary>>), do: :item def parse_datatype(<<")", _::binary>>), do: :item def parse_datatype(<<"*", _::binary>>), do: :peet_logging def parse_datatype(<<",", _::binary>>), do: :invalid_test_data def parse_datatype(<<"#DFS", _::binary>>), do: :df_report def parse_datatype(<<"#PHG", _::binary>>), do: :phg_data def parse_datatype(<<"#", _::binary>>), do: :phg_data def parse_datatype(_), do: :unknown_datatype @spec parse_data(atom(), String.t(), String.t()) :: map() | nil def parse_data(:mic_e, _destination, data), do: parse_mic_e(data) def parse_data(:mic_e_old, _destination, data), do: parse_mic_e(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 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 result = parse_position_with_message_without_timestamp(data) %{result | data_type: :position} end def parse_data(:timestamped_position, _destination, <<"/", _::binary>>) do %{ data_type: :timestamped_position_error, error: "Compressed position not supported in timestamped position" } end def parse_data(:timestamped_position, _destination, data) do parse_position_with_timestamp(false, data, :timestamped_position) end def parse_data(:timestamped_position_with_message, _destination, data) do case data do <<"/", _::binary>> -> %{ data_type: :timestamped_position_error, error: "Compressed position not supported in timestamped position" } _ -> parse_position_with_timestamp(true, data) end end def parse_data(:message, _destination, data) do case Regex.run(~r/^:([^:]+):(.+?)(\{(\d+)\})?$/s, data) do [_, addressee, message_text, _full_ack, message_number] -> %{ data_type: :message, addressee: String.trim(addressee), message_text: String.trim(message_text), message_number: message_number } [_, addressee, message_text] -> %{ data_type: :message, addressee: String.trim(addressee), message_text: String.trim(message_text) } _ -> nil end end 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) def parse_data(:third_party_traffic, _destination, data), do: parse_third_party_traffic(data) def parse_data(:peet_logging, _destination, data), do: Parser.Helpers.parse_peet_logging(data) def parse_data(:invalid_test_data, _destination, data), do: Parser.Helpers.parse_invalid_test_data(data) def parse_data(:raw_gps_ultimeter, _destination, data) do case Parser.Helpers.parse_nmea_sentence(data) do {:error, error} when is_binary(error) -> %{ data_type: :raw_gps_ultimeter, error: error, nmea_type: nil, raw_data: data, latitude: nil, longitude: nil } _ -> %{ data_type: :raw_gps_ultimeter, error: "Invalid NMEA sentence", 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: Parser.Helpers.parse_df_strength(s), height: Parser.Helpers.parse_phg_height(h), gain: Parser.Helpers.parse_phg_gain(g), directivity: Parser.Helpers.parse_phg_directivity(d), comment: rest, data_type: :df_report } else %{ df_data: data, data_type: :df_report } end end def parse_data(:phg_data, _destination, data), do: parse_phg_data(data) def parse_data(_type, _destination, _data), do: nil @spec parse_position_with_datetime_and_weather(boolean(), String.t(), String.t()) :: map() def parse_position_with_datetime_and_weather(aprs_messaging?, date_time_position_data, weather_report) do case date_time_position_data do <> -> %{latitude: lat, longitude: lon} = parse_aprs_position(latitude, longitude) weather_data = parse_weather_data(weather_report) %{ latitude: lat, longitude: lon, timestamp: time, symbol_table_id: sym_table_id, symbol_code: "_", weather: weather_data, data_type: :position_with_datetime_and_weather, aprs_messaging?: aprs_messaging? } _ -> weather_data = parse_weather_data(weather_report) %{ latitude: nil, longitude: nil, timestamp: nil, symbol_table_id: nil, symbol_code: nil, weather: weather_data, data_type: :position_with_datetime_and_weather, aprs_messaging?: aprs_messaging? } end 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 = Parser.Helpers.convert_to_base91(latitude) lon = Parser.Helpers.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 course and speed from comment field (e.g., "/123/045" or "123/045") @spec extract_course_and_speed(String.t()) :: {integer() | nil, float() | nil} defp extract_course_and_speed(comment) do # Match "/123/045" or "123/045" at the start of the comment case Regex.run(~r"/?(\d{3})/(\d{3})", comment) do [_, course, speed] -> {String.to_integer(course), String.to_integer(speed) * 1.0} _ -> {nil, nil} end end # Patch parse_position_without_timestamp to include course/speed @spec parse_position_without_timestamp(String.t()) :: map() def parse_position_without_timestamp(position_data) do case position_data do <> -> %{latitude: lat, longitude: lon} = parse_aprs_position(latitude, longitude) ambiguity = Parser.Helpers.calculate_position_ambiguity(latitude, longitude) dao_data = parse_dao_extension(comment) {course, speed} = extract_course_and_speed(comment) %{ latitude: lat, longitude: lon, timestamp: nil, symbol_table_id: sym_table_id, symbol_code: symbol_code, comment: comment, data_type: :position, aprs_messaging?: false, compressed?: false, position_ambiguity: ambiguity, dao: dao_data, course: course, speed: speed } <> -> %{latitude: lat, longitude: lon} = parse_aprs_position(latitude, longitude) ambiguity = Parser.Helpers.calculate_position_ambiguity(latitude, longitude) %{ 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 } <<"/", 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) ambiguity = Parser.Helpers.calculate_compressed_ambiguity(compression_type) base_data = %{ latitude: converted_lat, longitude: converted_lon, symbol_table_id: "/", symbol_code: symbol_code, comment: comment, position_format: :compressed, compression_type: compression_type, data_type: :position, compressed?: true, position_ambiguity: ambiguity, dao: nil } # Merge in course/speed from compressed_cs Map.merge(base_data, compressed_cs) rescue _e -> %{ latitude: nil, longitude: nil, symbol_table_id: "/", symbol_code: symbol_code, comment: comment, position_format: :compressed, compression_type: compression_type, data_type: :position, compressed?: true, position_ambiguity: Parser.Helpers.calculate_compressed_ambiguity(compression_type), dao: nil, course: nil, speed: nil } end _ -> %{ 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 } end 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 result = parse_position_without_timestamp(position_data) Map.put(result, :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 case Parser.Helpers.validate_position_data(latitude, longitude) do {:ok, {lat, lon}} -> position = parse_aprs_position(latitude, longitude) {course, speed} = extract_course_and_speed(comment) %{ latitude: lat, longitude: lon, position: position, time: Parser.Helpers.validate_timestamp(time), symbol_table_id: sym_table_id, symbol_code: symbol_code, comment: comment, data_type: :position, aprs_messaging?: aprs_messaging?, compressed?: false, course: course, speed: speed } end end @spec parse_position_with_timestamp(boolean(), binary()) :: map() def parse_position_with_timestamp(_aprs_messaging?, <<"/", _::binary>>) do %{ data_type: :timestamped_position_error, error: "Compressed position not supported in timestamped position" } end def parse_position_with_timestamp(_aprs_messaging?, data) do %{ data_type: :timestamped_position_error, error: "Invalid timestamped position format", raw_data: data } end @spec parse_mic_e(binary()) :: map() def parse_mic_e(data) do case data do <> -> %{latitude: lat, longitude: lon} = parse_aprs_position(latitude, longitude) %{ latitude: lat, longitude: lon, symbol_table_id: symbol_table_id, symbol_code: symbol_code, speed: speed, course: course, dti: dti, comment: rest, data_type: :mic_e } _ -> nil end end @spec parse_mic_e_digit(binary()) :: [integer() | atom() | nil] def parse_mic_e_digit(<>) when c in ?0..?9, do: [c - ?0, 0, nil] def parse_mic_e_digit(<>) when c in ?A..?J, do: [c - ?A, 1, :custom] def parse_mic_e_digit(<>) when c in ?P..?Y, do: [c - ?P, 1, :standard] def parse_mic_e_digit(<<"K">>), do: [0, 1, :custom] def parse_mic_e_digit(<<"L">>), do: [0, 0, nil] def parse_mic_e_digit(<<"Z">>), do: [0, 1, :standard] def parse_mic_e_digit(_), do: [:unknown, :unknown, :unknown] @spec parse_mic_e_destination(String.t()) :: map() def parse_mic_e_destination(destination_field) do digits = destination_field |> String.codepoints() |> Enum.map(&parse_mic_e_digit/1) |> Enum.map(&hd/1) deg = digits |> Enum.slice(0..1) |> Enum.join() |> String.to_integer() min = digits |> Enum.slice(2..3) |> Enum.join() |> String.to_integer() fractional = digits |> Enum.slice(4..5) |> Enum.join() |> String.to_integer() [ns, lo, ew] = destination_field |> to_charlist() |> Enum.slice(3..5) north_south_indicator = case ns do x when x in ?0..?9 -> :south x when x == ?L -> :south x when x in ?P..?Z -> :north _ -> :unknown end east_west_indicator = case ew do x when x in ?0..?9 -> :east x when x == ?L -> :east x when x in ?P..?Z -> :west _ -> :unknown end longitude_offset = case lo do x when x in ?0..?9 -> 0 x when x == ?L -> 0 x when x in ?P..?Z -> 100 _ -> :unknown end statuses = [ "Emergency", "Priority", "Special", "Committed", "Returning", "In Service", "En Route", "Off Duty" ] message_digits = destination_field |> String.codepoints() |> Enum.take(3) [_, message_bit_1, message_type] = parse_mic_e_digit(Enum.at(message_digits, 0)) [_, message_bit_2, _] = parse_mic_e_digit(Enum.at(message_digits, 1)) [_, message_bit_3, _] = parse_mic_e_digit(Enum.at(message_digits, 2)) # Convert the bits to binary to get the array index index = message_bit_1 * 4 + message_bit_2 * 2 + message_bit_3 # need to invert this from the actual array index display_index = (7 - index) |> to_string() |> String.pad_leading(2, "0") [message_code, message_description] = case message_type do :standard -> ["M" <> display_index, Enum.at(statuses, index)] :custom -> ["C" <> display_index, "Custom-#{display_index}"] nil -> ["", Enum.at(statuses, index)] end %{ lat_degrees: deg, lat_minutes: min, lat_fractional: fractional, lat_direction: north_south_indicator, lon_direction: east_west_indicator, longitude_offset: longitude_offset, message_code: message_code, message_description: message_description } end def parse_mic_e_information( <> = _information_field, longitude_offset ) do m = case m28 - 28 do x when x >= 60 -> x - 60 x -> x end sp = case sp28 - 28 do x when x >= 80 -> x - 80 x -> x end dc = dc28 - 28 quotient = div(dc, 10) remainder = rem(dc, 10) dc = sp * 10 + quotient heading = (remainder - 4) * 100 + (se28 - 28) # Messages should at least have a starting and ending symbol, and an optional message in between # But, there might not be any symbols either, so it could look like any of the following: # >^ <- TH-D74 # nil <- who knows # ]\"55}146.820 MHz T103 -0600= <- Kenwood DM-710 regex = ~r/^(?.?)(?.*)(?.)(?.)$/i result = Parser.Helpers.find_matches(regex, message) symbol1 = if result["first"] == "" do result["secondtolast"] else result["first"] end manufacturer = Parser.Helpers.parse_manufacturer(symbol1 <> result["secondtolast"] <> result["last"]) %{ dti: dti, lon_degrees: d28 - 28 + longitude_offset, lon_minutes: m, lon_fractional: f28 - 28, speed: dc, heading: heading, symbol: symbol, table: table, manufacturer: manufacturer, message: message } end @spec parse_manufacturer(binary()) :: String.t() def parse_manufacturer(symbols) do Aprs.DeviceIdentification.identify_device(symbols) end @spec convert_compressed_lat(binary()) :: float() def convert_compressed_lat(lat) do [l1, l2, l3, l4] = to_charlist(lat) 90 - ((l1 - 33) * 91 ** 3 + (l2 - 33) * 91 ** 2 + (l3 - 33) * 91 + l4 - 33) / 380_926 end @spec convert_compressed_lon(binary()) :: float() def convert_compressed_lon(lon) do [l1, l2, l3, l4] = to_charlist(lon) -180 + ((l1 - 33) * 91 ** 3 + (l2 - 33) * 91 ** 2 + (l3 - 33) * 91 + l4 - 33) / 190_463 end @spec convert_compressed_cs(binary()) :: map() def convert_compressed_cs(cs) do [c, s] = to_charlist(cs) c = c - 33 s = s - 33 case c do x when x in ?!..?z -> # compressed speed/course value # speed is returned in knots %{ course: s * 4, speed: Convert.speed(1.08 ** s - 1, :knots, :mph) } ?Z -> # pre-calculated radio range %{ range: 2 * 1.08 ** s } _ -> %{} end 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 # Object Report parsing def parse_object( <<";", object_name::binary-size(9), live_killed::binary-size(1), timestamp::binary-size(7), rest::binary>> ) do position_data = case rest do # Uncompressed position format <> -> %{latitude: lat, longitude: lon} = parse_aprs_position(latitude, longitude) %{ latitude: lat, longitude: lon, symbol_table_id: sym_table_id, symbol_code: symbol_code, comment: comment, position_format: :uncompressed } # Compressed position format <<"/", 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) if is_number(converted_lat) and is_number(converted_lon) do 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) else raise "Invalid compressed position" end 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 @spec parse_object(String.t()) :: map() def parse_object(data) do %{ raw_data: data, data_type: :object } end # Item Report parsing @spec parse_item(binary()) :: map() def parse_item(<>) when item_indicator in [?%, ?)] do # Items can have up to 9 character names, followed by ! for position or _ for killed 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_item(data) do %{ raw_data: data, data_type: :item } end # Parse item position data (similar to object position parsing) @spec parse_item_position(String.t()) :: map() defp parse_item_position(position_data) do case position_data do # Uncompressed position format <> -> %{latitude: lat, longitude: lon} = parse_aprs_position(latitude, longitude) %{ latitude: lat, longitude: lon, symbol_table_id: sym_table_id, symbol_code: symbol_code, comment: comment, position_format: :uncompressed } # Compressed position format <<"/", 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 # Weather Report parsing def parse_weather(<<"_", timestamp::binary-size(8), rest::binary>>) do weather_data = parse_weather_data(rest) Map.merge( %{ timestamp: timestamp, data_type: :weather }, weather_data ) end @spec parse_weather(String.t()) :: map() def parse_weather(data) do weather_data = parse_weather_data(data) Map.merge( %{ data_type: :weather }, weather_data ) end # Comprehensive weather data parsing defp parse_weather_data(weather_data) do # Extract timestamp if present timestamp = Parser.Helpers.extract_timestamp(weather_data) weather_data = Parser.Helpers.remove_timestamp(weather_data) # Parse each weather component 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) } # Build base result map result = %{ timestamp: timestamp, data_type: :weather, raw_weather_data: weather_data } # Add non-nil weather values to result Enum.reduce(weather_values, result, fn {key, value}, acc -> if is_nil(value), do: acc, else: Map.put(acc, key, value) end) end # Telemetry parsing def parse_telemetry(<<"T#", rest::binary>>) 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 # Handle telemetry parameter definitions (PARM) def parse_telemetry(<<":PARM.", rest::binary>>) do %{ data_type: :telemetry_parameters, parameter_names: String.split(rest, ",", trim: true), raw_data: rest } end # Handle telemetry equation definitions (EQNS) def parse_telemetry(<<":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 # Handle telemetry unit definitions (UNIT) def parse_telemetry(<<":UNIT.", rest::binary>>) do %{ data_type: :telemetry_units, units: String.split(rest, ",", trim: true), raw_data: rest } end # Handle telemetry bit sense definitions (BITS) def parse_telemetry(<<":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 @spec parse_telemetry(String.t()) :: map() def parse_telemetry(data) do %{ raw_data: data, data_type: :telemetry } 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 # Parse specific user-defined formats defp parse_user_defined_format("A", user_data), do: %{format: :experimental_a, content: user_data} defp parse_user_defined_format("B", user_data), do: %{format: :experimental_b, content: user_data} 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 if Parser.Helpers.count_leading_braces(packet) + 1 > 3 do %{ error: "Maximum tunnel depth exceeded" } else case parse_tunneled_packet(packet) do {:ok, parsed_packet} -> build_third_party_traffic_result(packet, parsed_packet) {:error, reason} -> %{ error: reason } end end end 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 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 defp parse_tunneled_packet_with_information(header_data, information) do case parse_datatype_safe(information) do {:ok, data_type} -> 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 })} {:error, reason} -> {:error, "Invalid data type: #{reason}"} end 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] -> case parse_callsign(sender) do {:ok, [base_callsign, ssid]} -> case split_path(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 _ -> {:error, "Invalid header format"} end 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 case String.slice(packet, 1..-1//1) do tunneled_packet -> 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 end # Add support for multiple levels of tunneling 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 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 # PHG Data parsing (Power, Height, Gain, Directivity) def parse_phg_data(<<"#", rest::binary>>) do parse_phg_data(rest) end def parse_phg_data(<<"PHG", p, h, g, d, rest::binary>>) do %{ power: Parser.Helpers.parse_phg_power(p), height: Parser.Helpers.parse_phg_height(h), gain: Parser.Helpers.parse_phg_gain(g), directivity: Parser.Helpers.parse_phg_directivity(d), comment: rest, data_type: :phg_data } end def parse_phg_data(data) do %{raw_data: data, data_type: :phg_data} end # Add DAO (Datum) extension support @spec parse_dao_extension(String.t()) :: map() | nil defp parse_dao_extension(comment) do case Regex.run(~r/!([A-Za-z])([A-Za-z])([A-Za-z])!/, comment) do [_, lat_dao, lon_dao, _] -> %{ lat_dao: lat_dao, lon_dao: lon_dao, datum: "WGS84" } _ -> nil end end end