defmodule Parser do @moduledoc """ Main parsing library """ # import Bitwise alias Aprs.Convert alias Parser.Types.MicE alias Parser.Types.Position require Logger @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()} @spec parse(String.t()) :: parse_result() def 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) do data_trimmed = String.trim(data) # Strip the first character (datatype indicator) from the 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, # Set received_at when creating packet received_at: DateTime.truncate(DateTime.utc_now(), :microsecond) }} else {:error, reason} -> {:error, reason} _ -> {:error, "PARSE ERROR"} end rescue error -> Logger.debug("PARSE ERROR: #{inspect(error)} for message: #{message}") # {:ok, file} = File.open("./badpackets.txt", [:append]) # IO.binwrite(file, message <> "\n\n") # File.close(file) {:error, :invalid_packet} end # 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 defp split_packet_parts([sender, path, data]) when byte_size(sender) > 0 and byte_size(path) > 0 do {:ok, [sender, path, data]} end 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) do case String.split(path, ",", parts: 2) do [destination, digi_path] -> {:ok, [destination, digi_path]} [destination] -> {:ok, [destination, ""]} _ -> {:error, "Invalid path format"} end end # 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 cond do not is_binary(callsign) -> {:error, "Invalid callsign format"} byte_size(callsign) == 0 -> {:error, "Empty callsign"} String.contains?(callsign, "-") -> case String.split(callsign, "-") do [base, ssid] -> {:ok, [base, ssid]} _ -> {:ok, [callsign, "0"]} end true -> {:ok, [callsign, "0"]} 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(<<"#", _::binary>>), do: :phg_data def parse_datatype(<<"(", _::binary>>), do: :unused def parse_datatype(<<"&", _::binary>>), do: :reserved 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(destination, data) def parse_data(:mic_e_old, destination, data), do: parse_mic_e(destination, data) def parse_data(:position, _destination, data) do case data do <<"/", _::binary>> -> result = parse_position_without_timestamp(false, data) if result.data_type == :malformed_position do result else %{result | data_type: :position} end _ -> result = parse_position_without_timestamp(false, data) if result.data_type == :malformed_position do result else %{result | data_type: :position} end end end def parse_data(:position_with_message, _destination, data) do result = parse_position_without_timestamp(true, data) %{result | data_type: :position} end def parse_data(:timestamped_position, _destination, data) do case data do <<"/", _::binary>> -> %{ data_type: :timestamped_position_error, error: "Compressed position not supported in timestamped position" } _ -> parse_position_with_timestamp(false, data, :timestamped_position) end 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: parse_status(data) def parse_data(:object, _destination, data), do: parse_object(data) def parse_data(:item, _destination, data), do: parse_item(data) def parse_data(:weather, _destination, data), do: parse_weather(data) def parse_data(:telemetry, _destination, data), do: parse_telemetry(data) def parse_data(: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(:phg_data, _destination, data), do: parse_phg_data(data) def parse_data(:peet_logging, _destination, data), do: parse_peet_logging(data) def parse_data(:invalid_test_data, _destination, data), do: parse_invalid_test_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} = Position.from_aprs(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 = 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 @spec parse_position_without_timestamp(boolean(), String.t()) :: map() def parse_position_without_timestamp(aprs_messaging?, position_data) do case position_data do <> -> %{latitude: lat, longitude: lon} = Position.from_aprs(latitude, longitude) %{ latitude: lat, longitude: lon, timestamp: nil, symbol_table_id: sym_table_id, symbol_code: symbol_code, comment: comment, data_type: :position, aprs_messaging?: aprs_messaging?, compressed?: false } <> -> %{latitude: lat, longitude: lon} = Position.from_aprs(latitude, longitude) %{ latitude: lat, longitude: lon, timestamp: nil, symbol_table_id: sym_table_id, symbol_code: "_", data_type: :position, aprs_messaging?: aprs_messaging?, compressed?: false } <<"/", 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 = convert_compressed_lat(latitude_compressed) converted_lon = convert_compressed_lon(longitude_compressed) compressed_cs = 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, data_type: :position, compressed?: true } Map.merge(base_data, compressed_cs) _ -> %{ latitude: nil, longitude: nil, timestamp: nil, symbol_table_id: nil, symbol_code: nil, data_type: :malformed_position, aprs_messaging?: aprs_messaging?, compressed?: false, comment: String.trim(position_data) } end end @spec parse_position_with_timestamp(boolean(), binary(), atom()) :: map() def parse_position_with_timestamp( aprs_messaging?, <>, _data_type ) do case validate_position_data(latitude, longitude) do {:ok, {lat, lon}} -> position = Position.from_aprs(latitude, longitude) %{ latitude: lat, longitude: lon, position: position, time: validate_timestamp(time), symbol_table_id: sym_table_id, symbol_code: symbol_code, comment: comment, data_type: :position, aprs_messaging?: aprs_messaging?, compressed?: false } {:error, reason} -> Logger.warning("Invalid timestamped position data: #{reason}") %{ latitude: nil, longitude: nil, time: time, symbol_table_id: sym_table_id, symbol_code: symbol_code, comment: comment, data_type: :timestamped_position_error, aprs_messaging?: aprs_messaging?, error: reason } end rescue e -> Logger.error(Exception.format(:error, e, __STACKTRACE__)) %{ latitude: nil, longitude: nil, data_type: :timestamped_position_error, error: "Timestamped position parsing failed" } 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(String.t(), String.t()) :: MicE.t() | map() def parse_mic_e(destination_field, information_field) do # Logger.debug("MIC-E: " <> destination_field <> " :: " <> information_field) # Mic-E is kind of a nutty compression scheme, APRS packs additional # information into the destination field when Mic-E encoding is used. # No other aprs packets use the destination field this way as far as i know. # The destination field contains the following information: # Latitude, message code, N/S & E/W indicators, longitude offset, digipath code destination_data = parse_mic_e_destination(destination_field) information_data = parse_mic_e_information(information_field, destination_data.longitude_offset) %MicE{ lat_degrees: destination_data.lat_degrees, lat_minutes: destination_data.lat_minutes, lat_fractional: destination_data.lat_fractional, lat_direction: destination_data.lat_direction, lon_direction: destination_data.lon_direction, longitude_offset: destination_data.longitude_offset, message_code: destination_data.message_code, message_description: destination_data.message_description, dti: information_data.dti, heading: information_data.heading, lon_degrees: information_data.lon_degrees, lon_minutes: information_data.lon_minutes, lon_fractional: information_data.lon_fractional, speed: information_data.speed, manufacturer: information_data.manufacturer, message: information_data.message } end @spec parse_mic_e(binary()) :: map() def parse_mic_e(data) do case data do <> -> %{latitude: lat, longitude: lon} = Position.from_aprs(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 = find_matches(regex, message) symbol1 = if result["first"] == "" do result["secondtolast"] else result["first"] end manufacturer = 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(<<" ", _::binary-size(2)>>), do: "Original MIC-E" def parse_manufacturer(<<">", _, "^">>), do: "Kenwood TH-D74" def parse_manufacturer(<<">", _::binary-size(2)>>), do: "Kenwood TH-D74A" def parse_manufacturer(<<"]", _, "=">>), do: "Kenwood DM-710" def parse_manufacturer(<<"]", _::binary-size(2)>>), do: "Kenwood DM-700" def parse_manufacturer(<<"`", "_", " ">>), do: "Yaesu VX-8" def parse_manufacturer(<<"`", "_", "\"">>), do: "Yaesu FTM-350" def parse_manufacturer(<<"`", "_", "#">>), do: "Yaesu VX-8G" def parse_manufacturer(<<"`", "_", "$">>), do: "Yaesu FT1D" def parse_manufacturer(<<"`", "_", "%">>), do: "Yaesu FTM-400DR" def parse_manufacturer(<<"`", "_", ")">>), do: "Yaesu FTM-100D" def parse_manufacturer(<<"`", "_", "(">>), do: "Yaesu FT2D" def parse_manufacturer(<<"`", " ", "X">>), do: "AP510" def parse_manufacturer(<<"`", _::binary-size(2)>>), do: "Mic-Emsg" def parse_manufacturer(<<"'", "|", "3">>), do: "Byonics TinyTrack3" def parse_manufacturer(<<"'", "|", "4">>), do: "Byonics TinyTrack4" def parse_manufacturer(<<"'", ":", "4">>), do: "SCS GmbH & Co. P4dragon DR-7400 modems" def parse_manufacturer(<<"'", ":", "8">>), do: "SCS GmbH & Co. P4dragon DR-7800 modems" def parse_manufacturer(<<"'", _::binary-size(2)>>), do: "McTrackr" def parse_manufacturer(<<_, "\"", _>>), do: "Hamhud ?" def parse_manufacturer(<<_, "/", _>>), do: "Argent ?" def parse_manufacturer(<<_, "^", _>>), do: "HinzTec anyfrog" def parse_manufacturer(<<_, "*", _>>), do: "APOZxx www.KissOZ.dk Tracker. OZ1EKD and OZ7HVO" def parse_manufacturer(<<_, "~", _>>), do: "Other" def parse_manufacturer(_), do: "Unknown" @spec find_matches(Regex.t(), String.t()) :: [String.t()] defp find_matches(regex, text) do case Regex.names(regex) do [] -> matches = Regex.run(regex, text) Enum.reduce(Enum.with_index(matches), %{}, fn {match, index}, acc -> Map.put(acc, index, match) end) _ -> Regex.named_captures(regex, text) end 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 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} = Position.from_aprs(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 = convert_compressed_lat(latitude_compressed) converted_lon = convert_compressed_lon(longitude_compressed) compressed_cs = 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: 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) defp parse_item_position(position_data) do case position_data do # Uncompressed position format <> -> %{latitude: lat, longitude: lon} = Position.from_aprs(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 = convert_compressed_lat(latitude_compressed) converted_lon = convert_compressed_lon(longitude_compressed) compressed_cs = 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 = extract_timestamp(weather_data) weather_data = remove_timestamp(weather_data) # Parse each weather component weather_values = %{ wind_direction: parse_wind_direction(weather_data), wind_speed: parse_wind_speed(weather_data), wind_gust: parse_wind_gust(weather_data), temperature: parse_temperature(weather_data), rain_1h: parse_rainfall_1h(weather_data), rain_24h: parse_rainfall_24h(weather_data), rain_since_midnight: parse_rainfall_since_midnight(weather_data), humidity: parse_humidity(weather_data), pressure: parse_pressure(weather_data), luminosity: parse_luminosity(weather_data), snow: 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 value == nil, do: acc, else: Map.put(acc, key, value) end) end defp parse_temperature(weather_data) do case Regex.run(~r/t(-?\d{3})/, weather_data) do [_, temp] -> String.to_integer(temp) nil -> nil end end defp parse_wind_direction(weather_data) do case Regex.run(~r/(\d{3})\//, weather_data) do [_, direction] -> String.to_integer(direction) nil -> nil end end defp parse_wind_speed(weather_data) do case Regex.run(~r/\/(\d{3})/, weather_data) do [_, speed] -> String.to_integer(speed) nil -> nil end end defp parse_wind_gust(weather_data) do case Regex.run(~r/g(\d{3})/, weather_data) do [_, gust] -> String.to_integer(gust) nil -> nil end end defp parse_rainfall_1h(weather_data) do case Regex.run(~r/r(\d{3})/, weather_data) do [_, rain] -> String.to_integer(rain) nil -> nil end end defp parse_rainfall_24h(weather_data) do case Regex.run(~r/p(\d{3})/, weather_data) do [_, rain] -> String.to_integer(rain) nil -> nil end end defp parse_rainfall_since_midnight(weather_data) do case Regex.run(~r/P(\d{3})/, weather_data) do [_, rain] -> String.to_integer(rain) nil -> nil end end defp parse_humidity(weather_data) do case Regex.run(~r/h(\d{2})/, weather_data) do [_, humidity] -> val = String.to_integer(humidity) if val == 0, do: 100, else: val nil -> nil end end defp parse_pressure(weather_data) do case Regex.run(~r/b(\d{5})/, weather_data) do [_, pressure] -> String.to_integer(pressure) / 10.0 nil -> nil end end defp parse_luminosity(weather_data) do case Regex.run(~r/[lL](\d{3})/, weather_data) do [_, luminosity] -> String.to_integer(luminosity) nil -> nil end end defp parse_snow(weather_data) do case Regex.run(~r/s(\d{3})/, weather_data) do [_, snow] -> String.to_integer(snow) nil -> nil 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: parse_telemetry_sequence(seq), analog_values: parse_analog_values(analog_values), digital_values: 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: parse_coefficient(a), b: parse_coefficient(b), c: 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 # Parse telemetry sequence number defp parse_telemetry_sequence(seq) do case Integer.parse(seq) do {num, _} -> num :error -> nil end end # Parse digital values (convert to integers where possible) defp parse_digital_values(values) do values |> Enum.map(fn value -> case value do "0" -> false "1" -> true binary when is_binary(binary) -> # Handle binary string format (e.g., "00000000") binary |> String.graphemes() |> Enum.map(fn "0" -> false "1" -> true _ -> nil end) _ -> nil end end) |> List.flatten() end # Parse analog values (convert to floats where possible) defp parse_analog_values(values) do Enum.map(values, fn value -> case value do "" -> nil value -> case Float.parse(value) do {float_val, _} -> float_val :error -> nil end end end) end # Parse equation coefficient defp parse_coefficient(coeff) do case Float.parse(coeff) do {float_val, _} -> float_val :error -> case Integer.parse(coeff) do {int_val, _} -> int_val :error -> coeff end end 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(user_id, user_data) do case user_id do "A" -> # Experimental format A %{format: :experimental_a, content: user_data} "B" -> # Experimental format B %{format: :experimental_b, content: user_data} "C" -> # Custom format C %{format: :custom_c, content: user_data} _ -> # Unknown user-defined format %{format: :unknown, content: user_data} end end # Third Party Traffic parsing def parse_third_party_traffic(<<"}", third_party_packet::binary>>) do # Third party traffic contains a complete APRS packet case String.split(third_party_packet, ":", parts: 2) do [header, information] -> case String.split(header, ">", parts: 2) do [sender, path] -> %{ third_party_sender: sender, third_party_path: path, third_party_information: information, data_type: :third_party_traffic, raw_data: third_party_packet } _ -> %{ third_party_data: third_party_packet, data_type: :third_party_traffic } end _ -> %{ third_party_data: third_party_packet, data_type: :third_party_traffic } end end @spec parse_third_party_traffic(String.t()) :: map() def parse_third_party_traffic(data) do %{ third_party_data: data, data_type: :third_party_traffic } end # PHG Data parsing (Power, Height, Gain, Directivity) def parse_phg_data(<<"#PHG", p, h, g, d, rest::binary>>) do %{ power: parse_phg_power(p), height: parse_phg_height(h), gain: parse_phg_gain(g), directivity: parse_phg_directivity(d), comment: rest, data_type: :phg_data } end def parse_phg_data(<<"#DFS", s, h, g, d, rest::binary>>) do %{ df_strength: parse_df_strength(s), height: parse_phg_height(h), gain: parse_phg_gain(g), directivity: parse_phg_directivity(d), comment: rest, data_type: :df_report } end def parse_phg_data(<<"#", phg_data::binary>>) do %{ phg_data: phg_data, data_type: :phg_data } end @spec parse_phg_data(String.t()) :: map() def parse_phg_data(data) do %{ phg_data: data, data_type: :phg_data } end # PHG Power conversion (0-9) defp parse_phg_power(?0), do: {0, "0 watts"} defp parse_phg_power(?1), do: {1, "1 watt"} defp parse_phg_power(?2), do: {4, "4 watts"} defp parse_phg_power(?3), do: {9, "9 watts"} defp parse_phg_power(?4), do: {16, "16 watts"} defp parse_phg_power(?5), do: {25, "25 watts"} defp parse_phg_power(?6), do: {36, "36 watts"} defp parse_phg_power(?7), do: {49, "49 watts"} defp parse_phg_power(?8), do: {64, "64 watts"} defp parse_phg_power(?9), do: {81, "81 watts"} defp parse_phg_power(p), do: {nil, "Unknown power: #{<

>}"} # PHG Height conversion (0-9) defp parse_phg_height(?0), do: {10, "10 feet"} defp parse_phg_height(?1), do: {20, "20 feet"} defp parse_phg_height(?2), do: {40, "40 feet"} defp parse_phg_height(?3), do: {80, "80 feet"} defp parse_phg_height(?4), do: {160, "160 feet"} defp parse_phg_height(?5), do: {320, "320 feet"} defp parse_phg_height(?6), do: {640, "640 feet"} defp parse_phg_height(?7), do: {1280, "1280 feet"} defp parse_phg_height(?8), do: {2560, "2560 feet"} defp parse_phg_height(?9), do: {5120, "5120 feet"} defp parse_phg_height(h), do: {nil, "Unknown height: #{<>}"} # PHG Gain conversion (0-9) defp parse_phg_gain(?0), do: {0, "0 dB"} defp parse_phg_gain(?1), do: {1, "1 dB"} defp parse_phg_gain(?2), do: {2, "2 dB"} defp parse_phg_gain(?3), do: {3, "3 dB"} defp parse_phg_gain(?4), do: {4, "4 dB"} defp parse_phg_gain(?5), do: {5, "5 dB"} defp parse_phg_gain(?6), do: {6, "6 dB"} defp parse_phg_gain(?7), do: {7, "7 dB"} defp parse_phg_gain(?8), do: {8, "8 dB"} defp parse_phg_gain(?9), do: {9, "9 dB"} defp parse_phg_gain(g), do: {nil, "Unknown gain: #{<>}"} # PHG Directivity conversion (0-9) defp parse_phg_directivity(?0), do: {360, "Omni"} defp parse_phg_directivity(?1), do: {45, "45° NE"} defp parse_phg_directivity(?2), do: {90, "90° E"} defp parse_phg_directivity(?3), do: {135, "135° SE"} defp parse_phg_directivity(?4), do: {180, "180° S"} defp parse_phg_directivity(?5), do: {225, "225° SW"} defp parse_phg_directivity(?6), do: {270, "270° W"} defp parse_phg_directivity(?7), do: {315, "315° NW"} defp parse_phg_directivity(?8), do: {360, "360° N"} defp parse_phg_directivity(?9), do: {nil, "Undefined"} defp parse_phg_directivity(d), do: {nil, "Unknown directivity: #{<>}"} # DF Strength conversion (0-9) defp parse_df_strength(?0), do: {0, "0 dB"} defp parse_df_strength(?1), do: {1, "3 dB above S0"} defp parse_df_strength(?2), do: {2, "6 dB above S0"} defp parse_df_strength(?3), do: {3, "9 dB above S0"} defp parse_df_strength(?4), do: {4, "12 dB above S0"} defp parse_df_strength(?5), do: {5, "15 dB above S0"} defp parse_df_strength(?6), do: {6, "18 dB above S0"} defp parse_df_strength(?7), do: {7, "21 dB above S0"} defp parse_df_strength(?8), do: {8, "24 dB above S0"} defp parse_df_strength(?9), do: {9, "27 dB above S0"} defp parse_df_strength(s), do: {nil, "Unknown strength: #{<>}"} # PEET Logging parsing def parse_peet_logging(<<"*", peet_data::binary>>) do %{ peet_data: peet_data, data_type: :peet_logging } end @spec parse_peet_logging(String.t()) :: map() def parse_peet_logging(data) do %{ peet_data: data, data_type: :peet_logging } end # Invalid/Test Data parsing def parse_invalid_test_data(<<",", test_data::binary>>) do %{ test_data: test_data, data_type: :invalid_test_data } end @spec parse_invalid_test_data(String.t()) :: map() def parse_invalid_test_data(data) do %{ test_data: data, data_type: :invalid_test_data } end # Validation functions # Validate position data defp validate_position_data(latitude, longitude) do %{latitude: lat, longitude: lon} = Position.from_aprs(latitude, longitude) cond do not is_number(lat) or not is_number(lon) -> {:error, "Invalid coordinate format"} lat < -90 or lat > 90 -> {:error, "Latitude out of range"} lon < -180 or lon > 180 -> {:error, "Longitude out of range"} true -> {:ok, {lat, lon}} end rescue _ -> {:error, "Position parsing failed"} end # Validate timestamp format @spec validate_timestamp(String.t()) :: String.t() | nil defp validate_timestamp(time) when byte_size(time) == 7 do if Regex.match?(~r/^\d{6}[hz\/]$/, time) do time else "INVALID" end end @spec validate_timestamp(any()) :: nil defp validate_timestamp(_), do: nil defp extract_timestamp(weather_data) do case Regex.run(~r/^(\d{6}[hz\/])/, weather_data) do [_, timestamp] -> timestamp nil -> nil end end defp remove_timestamp(weather_data) do case Regex.run(~r/^\d{6}[hz\/]/, weather_data) do [timestamp] -> String.replace(weather_data, timestamp, "") nil -> weather_data end end end