defmodule Parser do @moduledoc """ Main parsing library """ alias Aprs.Convert alias Parser.Item alias Parser.MicE alias Parser.Object alias Parser.PHG alias Parser.Status alias Parser.Telemetry alias Parser.Weather # Simple APRS position parsing to replace parse_aprs_position defp parse_aprs_position(lat, lon) do # Regex for latitude: 2 deg, 2+ min, 1 dir (N/S) # Regex for longitude: 3 deg, 2+ min, 1 dir (E/W) lat_re = ~r/^(\d{2})(\d{2}\.\d+)([NS])$/ lon_re = ~r/^(\d{3})(\d{2}\.\d+)([EW])$/ with [_, lat_deg, lat_min, lat_dir] <- Regex.run(lat_re, lat), [_, lon_deg, lon_min, lon_dir] <- Regex.run(lon_re, lon) do lat_val = Decimal.add(Decimal.new(lat_deg), Decimal.div(Decimal.new(lat_min), Decimal.new("60"))) lon_val = Decimal.add(Decimal.new(lon_deg), Decimal.div(Decimal.new(lon_min), Decimal.new("60"))) lat = if lat_dir == "S", do: Decimal.negate(lat_val), else: lat_val lon = if lon_dir == "W", do: Decimal.negate(lon_val), else: lon_val %{latitude: lat, longitude: lon} else _ -> %{latitude: nil, longitude: nil} end end @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, :invalid_packet} end def parse(_), do: {:error, :invalid_packet} @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, path]} <- split_path(path), :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) base_callsign = List.first(callsign_parts) ssid = case List.last(callsign_parts) do nil -> nil s when is_binary(s) -> s i when is_integer(i) -> to_string(i) _ -> nil end {: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) }} end rescue _ -> {:error, :invalid_packet} end # 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("!" <> _), do: :position def parse_datatype("/" <> _), do: :timestamped_position def parse_datatype("=" <> _), do: :position_with_message def parse_datatype("@" <> _), do: :timestamped_position_with_message def parse_datatype(";" <> _), do: :object def parse_datatype("`" <> _), do: :mic_e_old def parse_datatype("'" <> _), do: :mic_e_old def parse_datatype("_" <> _), do: :weather def parse_datatype("T" <> _), do: :telemetry def parse_datatype("$" <> _), do: :raw_gps_ultimeter def parse_datatype("<" <> _), do: :station_capabilities def parse_datatype("?" <> _), do: :query def parse_datatype("{" <> _), do: :user_defined def parse_datatype("}" <> _), do: :third_party_traffic def parse_datatype("%" <> _), do: :item def parse_datatype(")" <> _), do: :item def parse_datatype("*" <> _), do: :peet_logging def parse_datatype("," <> _), do: :invalid_test_data def parse_datatype("#DFS" <> _), do: :df_report def parse_datatype("#PHG" <> _), do: :phg_data def parse_datatype("#" <> _), 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: MicE.parse(data, destination) def parse_data(:mic_e_old, destination, data), do: MicE.parse(data, destination) 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: Parser.SpecialDataHelpers.parse_peet_logging(data) def parse_data(:invalid_test_data, _destination, data), do: Parser.SpecialDataHelpers.parse_invalid_test_data(data) def parse_data(:raw_gps_ultimeter, _destination, data) do case Parser.NMEAHelpers.parse_nmea_sentence(data) do {: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: Parser.PHGHelpers.parse_df_strength(s), height: Parser.PHGHelpers.parse_phg_height(h), gain: Parser.PHGHelpers.parse_phg_gain(g), directivity: Parser.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] -> %{ 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(: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, 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 = Parser.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 defp add_has_location(result) do Map.put( result, :has_location, (is_number(result[:latitude]) or is_struct(result[:latitude], Decimal)) and (is_number(result[:longitude]) or is_struct(result[:longitude], Decimal)) ) end @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 = Parser.Weather.parse_weather_data(weather_report) %{ latitude: pos.latitude, longitude: pos.longitude, timestamp: time, symbol_table_id: sym_table_id, symbol_code: symbol_code, weather: weather_data, data_type: :position_with_datetime_and_weather, aprs_messaging?: aprs_messaging? } 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.CompressedPositionHelpers.convert_to_base91(latitude) lon = Parser.CompressedPositionHelpers.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 <> -> parse_position_uncompressed(latitude, sym_table_id, longitude, symbol_code, comment) <> -> parse_position_short_uncompressed(latitude, sym_table_id, longitude) <<"/", 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( latitude_compressed, longitude_compressed, symbol_code, cs, compression_type, comment ) _ -> parse_position_malformed(position_data) end end defp parse_position_uncompressed(latitude, sym_table_id, longitude, symbol_code, comment) do %{latitude: lat, longitude: lon} = parse_aprs_position(latitude, longitude) ambiguity = Parser.UtilityHelpers.calculate_position_ambiguity(latitude, longitude) dao_data = parse_dao_extension(comment) {course, speed} = extract_course_and_speed(comment) has_position = (is_number(lat) or is_struct(lat, Decimal)) and (is_number(lon) or is_struct(lon, Decimal)) base_map = %{ 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, has_position: has_position } if sym_table_id == "/" and symbol_code == "_" do weather_map = Parser.Weather.parse_weather_data(comment) Map.merge(base_map, weather_map) else base_map end end defp parse_position_short_uncompressed(latitude, sym_table_id, longitude) do %{latitude: lat, longitude: lon} = parse_aprs_position(latitude, longitude) ambiguity = Parser.UtilityHelpers.calculate_position_ambiguity(latitude, longitude) has_position = (is_number(lat) or is_struct(lat, Decimal)) and (is_number(lon) or is_struct(lon, Decimal)) %{ 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 } end defp parse_position_compressed(latitude_compressed, longitude_compressed, symbol_code, cs, compression_type, comment) do converted_lat = Parser.CompressedPositionHelpers.convert_compressed_lat(latitude_compressed) converted_lon = Parser.CompressedPositionHelpers.convert_compressed_lon(longitude_compressed) compressed_cs = Parser.CompressedPositionHelpers.convert_compressed_cs(cs) ambiguity = Parser.CompressedPositionHelpers.calculate_compressed_ambiguity(compression_type) has_position = (is_number(converted_lat) or is_struct(converted_lat, Decimal)) and (is_number(converted_lon) or is_struct(converted_lon, Decimal)) 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, has_position: has_position } 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.CompressedPositionHelpers.calculate_compressed_ambiguity(compression_type), dao: nil, course: nil, speed: nil, has_position: false } end 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 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.UtilityHelpers.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.UtilityHelpers.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 } _ -> # Fallback: try to extract lat/lon using regex if binary pattern match fails regex = ~r/^(?