1436 lines
42 KiB
Elixir
1436 lines
42 KiB
Elixir
defmodule Parser do
|
|
@moduledoc """
|
|
Main parsing library
|
|
"""
|
|
# import Bitwise
|
|
alias Aprs.Convert
|
|
alias Parser.Types.MicE
|
|
alias Parser.Types.Position
|
|
|
|
require Logger
|
|
|
|
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,
|
|
%{
|
|
# TODO: temporary for liveview
|
|
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,
|
|
# Ensure ssid is never nil
|
|
ssid: ssid || "0",
|
|
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
|
|
defp split_packet(message) do
|
|
case String.split(message, [">", ":"], parts: 3) do
|
|
[sender, path, data] when byte_size(sender) > 0 and byte_size(path) > 0 ->
|
|
{:ok, [sender, path, data]}
|
|
|
|
_ ->
|
|
{:error, "Invalid packet format"}
|
|
end
|
|
end
|
|
|
|
# Safely split path into destination and digipeater path
|
|
defp 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}
|
|
defp parse_datatype_safe(data) do
|
|
case String.first(data) do
|
|
nil -> {:error, "Empty data field"}
|
|
first_char -> {:ok, parse_datatype(first_char)}
|
|
end
|
|
end
|
|
|
|
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.
|
|
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) when datatype == "%", do: :item
|
|
def parse_datatype(datatype) when datatype == ")", do: :item
|
|
def parse_datatype(datatype) when datatype == "*", do: :peet_logging
|
|
def parse_datatype(datatype) when datatype == ",", do: :invalid_test_data
|
|
def parse_datatype(datatype) when datatype == "#", do: :phg_data
|
|
def parse_datatype(datatype) when datatype == "(", do: :unused
|
|
def parse_datatype(datatype) when datatype == "&", do: :reserved
|
|
|
|
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
|
|
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)
|
|
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
|
|
|
|
def parse_position_with_datetime_and_weather(aprs_messaging?, date_time_position_data, weather_report) do
|
|
case date_time_position_data do
|
|
<<time::binary-size(7), latitude::binary-size(8), sym_table_id::binary-size(1), longitude::binary-size(9)>> ->
|
|
%{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
|
|
|
|
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
|
|
|
|
def convert_to_base91(<<value::binary-size(4)>>) 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?, position_data) do
|
|
case position_data do
|
|
<<latitude::binary-size(8), sym_table_id::binary-size(1), longitude::binary-size(9), symbol_code::binary-size(1),
|
|
comment::binary>> ->
|
|
%{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::binary-size(8), sym_table_id::binary-size(1), longitude::binary-size(9)>> ->
|
|
%{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
|
|
|
|
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
|
|
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
|
|
|
|
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
|
|
|
|
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(data) do
|
|
case data do
|
|
<<dti::binary-size(1), latitude::binary-size(6), longitude::binary-size(7), symbol_code::binary-size(1),
|
|
speed::binary-size(1), course::binary-size(1), symbol_table_id::binary-size(1), rest::binary>> ->
|
|
%{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
|
|
|
|
def parse_mic_e_digit(<<c>>) when c in ?0..?9, do: [c - ?0, 0, nil]
|
|
def parse_mic_e_digit(<<c>>) when c in ?A..?J, do: [c - ?A, 1, :custom]
|
|
def parse_mic_e_digit(<<c>>) 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 = (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(
|
|
<<dti::binary-size(1), d28::integer, m28::integer, f28::integer, sp28::integer, dc28::integer, se28::integer,
|
|
symbol::binary-size(1), table::binary-size(1), message::binary>> = _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/^(?<first>.?)(?<msg>.*)(?<secondtolast>.)(?<last>.)$/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-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"
|
|
|
|
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
|
|
|
|
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
|
|
}
|
|
|
|
_ ->
|
|
%{}
|
|
end
|
|
end
|
|
|
|
# Status Report parsing
|
|
def parse_status(<<">", status_text::binary>>) do
|
|
%{
|
|
status_text: status_text,
|
|
data_type: :status
|
|
}
|
|
end
|
|
|
|
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::binary-size(8), sym_table_id::binary-size(1), longitude::binary-size(9), symbol_code::binary-size(1),
|
|
comment::binary>> ->
|
|
%{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
|
|
|
|
def parse_object(data) do
|
|
%{
|
|
raw_data: data,
|
|
data_type: :object
|
|
}
|
|
end
|
|
|
|
# Item Report parsing
|
|
def parse_item(<<item_indicator, item_name_and_data::binary>>) 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_indicator>> <> 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::binary-size(8), sym_table_id::binary-size(1), longitude::binary-size(9), symbol_code::binary-size(1),
|
|
comment::binary>> ->
|
|
%{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
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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_data::binary>>) do
|
|
case phg_data do
|
|
<<"PHG", power::binary-size(1), height::binary-size(1), gain::binary-size(1), directivity::binary-size(1),
|
|
rest::binary>> ->
|
|
%{
|
|
power: parse_phg_power(power),
|
|
height: parse_phg_height(height),
|
|
gain: parse_phg_gain(gain),
|
|
directivity: parse_phg_directivity(directivity),
|
|
comment: rest,
|
|
data_type: :phg_data
|
|
}
|
|
|
|
<<"DFS", strength::binary-size(1), height::binary-size(1), gain::binary-size(1), directivity::binary-size(1),
|
|
rest::binary>> ->
|
|
%{
|
|
df_strength: parse_df_strength(strength),
|
|
height: parse_phg_height(height),
|
|
gain: parse_phg_gain(gain),
|
|
directivity: parse_phg_directivity(directivity),
|
|
comment: rest,
|
|
data_type: :df_report
|
|
}
|
|
|
|
_ ->
|
|
%{
|
|
phg_data: phg_data,
|
|
data_type: :phg_data
|
|
}
|
|
end
|
|
end
|
|
|
|
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: #{p}"}
|
|
|
|
# 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: #{h}"}
|
|
|
|
# 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: #{g}"}
|
|
|
|
# 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: #{d}"}
|
|
|
|
# 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: #{s}"}
|
|
|
|
# PEET Logging parsing
|
|
def parse_peet_logging(<<"*", peet_data::binary>>) do
|
|
%{
|
|
peet_data: peet_data,
|
|
data_type: :peet_logging
|
|
}
|
|
end
|
|
|
|
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
|
|
|
|
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
|
|
defp validate_timestamp(time) when byte_size(time) == 7 do
|
|
if Regex.match?(~r/^\d{6}[hz\/]$/, time) do
|
|
time
|
|
else
|
|
"INVALID"
|
|
end
|
|
end
|
|
|
|
defp validate_timestamp(time), do: time
|
|
|
|
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
|