defmodule Parser do @moduledoc """ Main parsing library """ # import Bitwise alias Aprs.{Convert, Packet} alias Parser.Types.{MicE, Position} require Logger def parse(message) do try do [sender, path, data] = String.split(message, [">", ":"], parts: 3) with [base_callsign, ssid] <- parse_callsign(sender), data_type <- parse_datatype(String.first(data)), data <- String.trim(data), [destination, path] <- String.split(path, ",", parts: 2), data_extended <- parse_data(data_type, destination, data) do {:ok, %Packet{ # TODO: temporary for liveview id: Ecto.UUID.generate(), sender: sender, path: path, destination: destination, information_field: data, data_type: data_type, base_callsign: base_callsign, ssid: ssid, data_extended: data_extended }} else true -> {:error, "PARSE ERROR"} end rescue _ -> # Logger.debug("PARSE ERROR: " <> message) {:ok, file} = File.open("./badpackets.txt", [:append]) IO.binwrite(file, message <> "\n\n") File.close(file) {:error, :invalid_packet} end end def parse_callsign(callsign) do if String.contains?(callsign, "-") do String.split(callsign, "-") else [callsign, nil] 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. def parse_datatype(datatype) when datatype == ":", do: :message def parse_datatype(datatype) when datatype == ">", do: :status def parse_datatype(datatype) when datatype == "!", do: :position def parse_datatype(datatype) when datatype == "/", do: :timestamped_position def parse_datatype(datatype) when datatype == "=", do: :position_with_message def parse_datatype(datatype) when datatype == "@", do: :timestamped_position_with_message def parse_datatype(datatype) when datatype == ";", do: :object def parse_datatype(datatype) when datatype == "`", do: :mic_e def parse_datatype(datatype) when datatype == "'", do: :mic_e_old def parse_datatype(datatype) when datatype == "_", do: :weather def parse_datatype(datatype) when datatype == "T", do: :telemetry def parse_datatype(datatype) when datatype == "$", do: :raw_gps_ultimeter def parse_datatype(datatype) when datatype == "<", do: :station_capabilities def parse_datatype(datatype) when datatype == "?", do: :query def parse_datatype(datatype) when datatype == "{", do: :user_defined def parse_datatype(datatype) when datatype == "}", do: :third_party_traffic def parse_datatype(_datatype), do: :unknown_datatype 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: parse_position_without_timestamp(false, data) def parse_data(:position_with_message, _destination, data), do: parse_position_without_timestamp(true, data) def parse_data(:timestamped_position, _destination, data), do: parse_position_with_timestamp(false, data) def parse_data( :timestamped_position_with_message, _destination, <<_dti::binary-size(1), date_time_position::binary-size(25), "_", weather_report::binary>> ) do parse_position_with_datetime_and_weather(true, date_time_position, weather_report) end def parse_data(:timestamped_position_with_message, _destination, data), do: parse_position_with_timestamp(true, data) def parse_data( :message, _destination, <<":", addressee::binary-size(9), ":", message_text::binary>> ) do # Aprs messages can have an optional message number tacked onto the end # for the purposes of acknowledging message receipt. # The sender tacks the message number onto the end of the message, # and the receiving station is supposed to respond back with an # acknowledgement of that message number. # Example # Sender: Hello world{123 # Receiver: ack123 # Special thanks to Jeff Smith(https://github.com/electricshaman) for the regex regex = ~r/^(?.*?)(?:{(?\w+))?$/i result = find_matches(regex, message_text) message_text = case result["message"] do nil -> "" m -> String.trim(m) end %{ to: String.trim(addressee), message_text: message_text, message_number: result["message_number"] } end def parse_data(_type, _destination, _data), do: nil def parse_position_with_datetime_and_weather( aprs_messaging?, date_time_position_data, weather_report ) do <> = date_time_position_data # position = Parser.Types.Position.from_aprs(latitude, longitude) %{latitude: lat, longitude: lon} = Parser.Types.Position.from_aprs(latitude, longitude) %{ latitude: lat, longitude: lon, timestamp: time, symbol_table_id: sym_table_id, symbol_code: "_", weather: weather_report, data_type: :position_with_datetime_and_weather, aprs_messaging?: aprs_messaging? } end def decode_compressed_position( <<"/", latitude::binary-size(4), longitude::binary-size(4), _symbol::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) [:ok, lat, lon] end 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 def parse_position_without_timestamp(_aprs_messaging?, <<"!!", rest::binary>> = message) do # this is an ultimeter weather station. need to parse its weird format # {:ok, file} = File.open("badpackets.txt") # IO.puts(file, message) # File.close(file) # a = "0000000001FF000427C70002CCD30001026E003A050F00040000" [ wind_speed, wind_direction, temp, rain_long_term_total, barrometer, barrometer_delta_value, barrometer_corr_factor_lsw, barrometer_corr_factor_msw, humidity, _day_of_year, _minute_of_day, today_rain_total, wind_speed_avg ] = rest |> String.codepoints() |> Enum.chunk_every(4) |> Enum.map(&Enum.join/1) |> Enum.map(&hex_decode(&1)) %{ wind_speed: Convert.wind(wind_speed, :ultimeter, :mph), wind_direction: wind_direction, temp_f: Convert.temp(temp, :ultimeter, :f), rain_long_term_total: rain_long_term_total, barrometer: barrometer, barrometer_delta_value: barrometer_delta_value, barrometer_corr_factor_lsw: barrometer_corr_factor_lsw, barrometer_corr_factor_msw: barrometer_corr_factor_msw, humidity: humidity, today_rain_total: today_rain_total, wind_speed_avg: wind_speed_avg } Logger.debug("TODO: PARSE ULTIMETER DATA: " <> message) end def parse_position_without_timestamp( aprs_messaging?, <<_dti::binary-size(1), latitude::binary-size(8), sym_table_id::binary-size(1), longitude::binary-size(9), symbol_code::binary-size(1), comment::binary>> ) do try do # position = Position.from_aprs(latitude, longitude) %{latitude: lat, longitude: lon} = Parser.Types.Position.from_aprs(latitude, longitude) %{ latitude: lat, longitude: lon, symbol_table_id: sym_table_id, symbol_code: symbol_code, comment: comment, data_type: :position, aprs_messaging?: aprs_messaging? } rescue e -> Logger.error(Exception.format(:error, e, __STACKTRACE__)) end end def parse_position_without_timestamp( aprs_messaging?, <<_dti::binary-size(1), "/", latitude::binary-size(4), longitude::binary-size(4), sym_table_id::binary-size(1), _cs::binary-size(2), _compression_type::binary-size(1), comment::binary>> = message ) do # {:ok, file} = File.open("./compressed.txt", [:append]) # IO.binwrite( # file, # message <> # "\n" <> # "aprs_messaging?: #{aprs_messaging?}\n" <> # "dti: #{dti}\n" <> # "latitude: #{latitude}\n" <> # "longitude: #{longitude}\n" <> # "sym_table_id: #{sym_table_id}\n" <> # "cs: #{cs}\n" <> # "compression_type: #{compression_type}\n" <> # "comment: #{comment}\n\n" # ) # File.close(file) # convert_compressed_cs(cs) |> IO.inspect() Logger.debug("TODO: PARSE COMPRESSED LAT/LON: " <> message) converted_lat = convert_compressed_lat(latitude) converted_lon = convert_compressed_lon(longitude) position = Position.from_decimal(converted_lat, converted_lon) # converted_cs = convert_compressed_cs(cs) %{ latitude: converted_lat, longitude: converted_lon, position: position, symbol_table_id: sym_table_id, # symbol_code: symbol_code, comment: comment, data_type: :position, aprs_messaging?: aprs_messaging? } end def parse_position_with_timestamp( aprs_messaging?, <<_dti::binary-size(1), time::binary-size(7), latitude::binary-size(8), sym_table_id::binary-size(1), longitude::binary-size(9), symbol_code::binary-size(1), comment::binary>> ) do try do position = Position.from_aprs(latitude, longitude) %{latitude: lat, longitude: lon} = Parser.Types.Position.from_aprs(latitude, longitude) %{ latitude: lat, longitude: lon, position: position, time: time, symbol_table_id: sym_table_id, symbol_code: symbol_code, comment: comment, data_type: :position, aprs_messaging?: aprs_messaging? } rescue e -> Logger.error(Exception.format(:error, e, __STACKTRACE__)) end end 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 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(_c), do: [:unknown, :unknown, :unknown] 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 = to_string(7 - index) |> 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 def parse_manufacturer(" ", _s2, _s3), do: "Original MIC-E" def parse_manufacturer(">", _s2, "="), do: "Kenwood TH-D72" def parse_manufacturer(">", _s2, "^"), do: "Kenwood TH-D74" def parse_manufacturer(">", _s2, _s3), do: "Kenwood TH-D74A" def parse_manufacturer("]", _s2, "="), do: "Kenwood DM-710" def parse_manufacturer("]", _s2, _s3), 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("`", _s2, _s3), 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("'", _s2, _s3), do: "McTrackr" def parse_manufacturer(_s1, "\"", _s3), do: "Hamhud ?" def parse_manufacturer(_s1, "/", _s3), do: "Argent ?" def parse_manufacturer(_s1, "^", _s3), do: "HinzTec anyfrog" def parse_manufacturer(_s1, "*", _s3), do: "APOZxx www.KissOZ.dk Tracker. OZ1EKD and OZ7HVO" def parse_manufacturer(_s1, "~", _s3), do: "Other" def parse_manufacturer(_symbol1, _symbol2, _symbol3), do: :unknown_manufacturer 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 defp hex_decode(input) do {result, ""} = Integer.parse(input, 16) result end # defp convert_ultimeter_humidity(hum) do # hum * 10 # end 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 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 } _ -> IO.inspect(cs, label: "not parsable") %{ course: 0, speed: 0 } end end end