1299 lines
40 KiB
Elixir
1299 lines
40 KiB
Elixir
# @dialyzer {:nowarn_function, parse_data: 3}
|
|
# @dialyzer {:nowarn_function, parse_object: 1}
|
|
defmodule Parser do
|
|
@moduledoc """
|
|
Main parsing library
|
|
"""
|
|
# import Bitwise
|
|
alias Aprs.Convert
|
|
alias Parser.MicE
|
|
|
|
require Logger
|
|
|
|
# Simple APRS position parsing to replace parse_aprs_position
|
|
defp parse_aprs_position(lat, lon) do
|
|
# Regex for latitude: 2 deg, 2+ min, 1 dir (N/S)
|
|
# Regex for longitude: 3 deg, 2+ min, 1 dir (E/W)
|
|
lat_re = ~r/^(\d{2})(\d{2}\.\d+)([NS])$/
|
|
lon_re = ~r/^(\d{3})(\d{2}\.\d+)([EW])$/
|
|
|
|
with [_, lat_deg, lat_min, lat_dir] <- Regex.run(lat_re, lat),
|
|
[_, lon_deg, lon_min, lon_dir] <- Regex.run(lon_re, lon) do
|
|
lat_val =
|
|
Decimal.add(Decimal.new(lat_deg), Decimal.div(Decimal.new(lat_min), Decimal.new("60")))
|
|
|
|
lon_val =
|
|
Decimal.add(Decimal.new(lon_deg), Decimal.div(Decimal.new(lon_min), Decimal.new("60")))
|
|
|
|
lat = if lat_dir == "S", do: Decimal.negate(lat_val), else: lat_val
|
|
lon = if lon_dir == "W", do: Decimal.negate(lon_val), else: lon_val
|
|
%{latitude: lat, longitude: lon}
|
|
else
|
|
_ -> %{latitude: nil, longitude: nil}
|
|
end
|
|
end
|
|
|
|
@type packet :: %{
|
|
id: String.t(),
|
|
sender: String.t(),
|
|
path: String.t(),
|
|
destination: String.t(),
|
|
information_field: String.t(),
|
|
data_type: atom(),
|
|
base_callsign: String.t(),
|
|
ssid: String.t(),
|
|
data_extended: map() | nil,
|
|
received_at: DateTime.t()
|
|
}
|
|
|
|
@type parse_result :: {:ok, packet()} | {:error, atom() | String.t()}
|
|
|
|
@type position_ambiguity :: 0..4
|
|
|
|
@spec parse(String.t()) :: parse_result()
|
|
def parse(message) when is_binary(message) do
|
|
do_parse(message)
|
|
rescue
|
|
error ->
|
|
Logger.debug("PARSE ERROR: #{inspect(error)} for message: #{message}")
|
|
{:error, :invalid_packet}
|
|
end
|
|
|
|
def parse(_), do: {:error, :invalid_packet}
|
|
|
|
defp do_parse(message) do
|
|
with {:ok, [sender, path, data]} <- split_packet(message),
|
|
{:ok, callsign_parts} <- parse_callsign(sender),
|
|
{:ok, data_type} <- parse_datatype_safe(data),
|
|
{:ok, [destination, path]} <- split_path(path),
|
|
:ok <- validate_path(path) do
|
|
data_trimmed = String.trim(data)
|
|
data_without_type = String.slice(data_trimmed, 1..-1//1)
|
|
data_extended = parse_data(data_type, destination, data_without_type)
|
|
|
|
base_callsign = List.first(callsign_parts)
|
|
|
|
ssid =
|
|
case List.last(callsign_parts) do
|
|
nil ->
|
|
0
|
|
|
|
s when is_binary(s) ->
|
|
case Integer.parse(s) do
|
|
{i, _} -> i
|
|
:error -> 0
|
|
end
|
|
|
|
i when is_integer(i) ->
|
|
i
|
|
|
|
_ ->
|
|
0
|
|
end
|
|
|
|
{:ok,
|
|
%{
|
|
id: 16 |> :crypto.strong_rand_bytes() |> Base.encode16(case: :lower),
|
|
sender: sender,
|
|
path: path,
|
|
destination: destination,
|
|
information_field: data_trimmed,
|
|
data_type: data_type,
|
|
base_callsign: base_callsign,
|
|
ssid: ssid,
|
|
data_extended: data_extended,
|
|
received_at: DateTime.truncate(DateTime.utc_now(), :microsecond)
|
|
}}
|
|
else
|
|
{:error, reason} when is_binary(reason) ->
|
|
case reason do
|
|
"Invalid packet format" -> {:error, "Invalid packet format"}
|
|
_ -> {:error, reason}
|
|
end
|
|
end
|
|
rescue
|
|
error ->
|
|
Logger.debug("PARSE ERROR: #{inspect(error)} for message: #{message}")
|
|
{:error, :invalid_packet}
|
|
end
|
|
|
|
# Validate path for too many components
|
|
def validate_path(path) when is_binary(path) and path != "" do
|
|
if length(String.split(path, ",")) > 8 do
|
|
{:error, "Too many path components"}
|
|
else
|
|
:ok
|
|
end
|
|
end
|
|
|
|
def validate_path(_), do: :ok
|
|
|
|
# Safely split packet into components
|
|
@spec split_packet(String.t()) :: {:ok, [String.t()]} | {:error, String.t()}
|
|
def split_packet(message) do
|
|
split_packet_parts(String.split(message, [">", ":"], parts: 3))
|
|
end
|
|
|
|
@spec split_packet_parts([String.t()]) :: {:ok, [String.t()]} | {:error, String.t()}
|
|
defp split_packet_parts([sender, path, data]) when byte_size(sender) > 0 and byte_size(path) > 0 do
|
|
{:ok, [sender, path, data]}
|
|
end
|
|
|
|
@spec split_packet_parts(list()) :: {:error, String.t()}
|
|
defp split_packet_parts(_), do: {:error, "Invalid packet format"}
|
|
|
|
# Safely split path into destination and digipeater path
|
|
@spec split_path(String.t()) :: {:ok, [String.t()]} | {:error, String.t()}
|
|
def split_path(path) when is_binary(path) do
|
|
split = String.split(path, ",", parts: 2)
|
|
split_path_parts(split)
|
|
end
|
|
|
|
defp split_path_parts([destination, digi_path]), do: {:ok, [destination, digi_path]}
|
|
defp split_path_parts([destination]), do: {:ok, [destination, ""]}
|
|
defp split_path_parts(_), do: {:error, "Invalid path format"}
|
|
|
|
# Safe version of parse_datatype that returns {:ok, type} or {:error, reason}
|
|
@spec parse_datatype_safe(String.t()) :: {:ok, atom()} | {:error, String.t()}
|
|
def parse_datatype_safe(""), do: {:error, "Empty data"}
|
|
def parse_datatype_safe(data), do: {:ok, parse_datatype(data)}
|
|
|
|
@spec parse_callsign(String.t()) :: {:ok, [String.t()]} | {:error, String.t()}
|
|
def parse_callsign(callsign) do
|
|
case Parser.AX25.parse_callsign(callsign) do
|
|
{:ok, {base, ssid}} -> {:ok, [base, ssid]}
|
|
{:error, reason} -> {:error, reason}
|
|
end
|
|
end
|
|
|
|
# One of the nutty exceptions in the APRS protocol has to do with this
|
|
# data type indicator. It's usually the first character of the message.
|
|
# However, in some rare cases, the ! indicator can be anywhere in the
|
|
# first 40 characters of the message. I'm not going to deal with that
|
|
# weird case right now. It seems like its for a specific type of old
|
|
# TNC hardware that probably doesn't even exist anymore.
|
|
@spec parse_datatype(String.t()) :: atom()
|
|
def parse_datatype(<<":", _::binary>>), do: :message
|
|
def parse_datatype(<<">", _::binary>>), do: :status
|
|
def parse_datatype("!" <> _), do: :position
|
|
def parse_datatype("/" <> _), do: :timestamped_position
|
|
def parse_datatype("=" <> _), do: :position_with_message
|
|
def parse_datatype("@" <> _), do: :timestamped_position_with_message
|
|
def parse_datatype(";" <> _), do: :object
|
|
def parse_datatype("`" <> _), do: :mic_e_old
|
|
def parse_datatype("'" <> _), do: :mic_e_old
|
|
def parse_datatype("_" <> _), do: :weather
|
|
def parse_datatype("T" <> _), do: :telemetry
|
|
def parse_datatype("$" <> _), do: :raw_gps_ultimeter
|
|
def parse_datatype("<" <> _), do: :station_capabilities
|
|
def parse_datatype("?" <> _), do: :query
|
|
def parse_datatype("{" <> _), do: :user_defined
|
|
def parse_datatype("}" <> _), do: :third_party_traffic
|
|
def parse_datatype("%" <> _), do: :item
|
|
def parse_datatype(")" <> _), do: :item
|
|
def parse_datatype("*" <> _), do: :peet_logging
|
|
def parse_datatype("," <> _), do: :invalid_test_data
|
|
def parse_datatype("#DFS" <> _), do: :df_report
|
|
def parse_datatype("#PHG" <> _), do: :phg_data
|
|
def parse_datatype("#" <> _), do: :phg_data
|
|
def parse_datatype(_), do: :unknown_datatype
|
|
|
|
@spec parse_data(atom(), String.t(), String.t()) :: map() | nil
|
|
def parse_data(:mic_e, destination, data), do: MicE.parse(data, destination)
|
|
def parse_data(:mic_e_old, destination, data), do: MicE.parse(data, destination)
|
|
|
|
def parse_data(:position, destination, <<"!", rest::binary>>) do
|
|
parse_data(:position, destination, rest)
|
|
end
|
|
|
|
def parse_data(:position, _destination, <<"/", _::binary>> = data) do
|
|
result = parse_position_without_timestamp(data)
|
|
if result.data_type == :malformed_position, do: result, else: %{result | data_type: :position}
|
|
end
|
|
|
|
def parse_data(:position, _destination, data) do
|
|
result = parse_position_without_timestamp(data)
|
|
if result.data_type == :malformed_position, do: result, else: %{result | data_type: :position}
|
|
end
|
|
|
|
def parse_data(:position_with_message, _destination, data) do
|
|
result = parse_position_with_message_without_timestamp(data)
|
|
%{result | data_type: :position}
|
|
end
|
|
|
|
def parse_data(:timestamped_position, _destination, data) do
|
|
parse_position_with_timestamp(false, data, :timestamped_position)
|
|
end
|
|
|
|
def parse_data(:timestamped_position_with_message, _destination, data) do
|
|
# Correct binary pattern match for APRS position with weather
|
|
case data do
|
|
<<time::binary-size(7), latitude::binary-size(8), sym_table_id::binary-size(1), longitude::binary-size(9),
|
|
symbol_code::binary-size(1), rest::binary>> ->
|
|
weather_start = String.starts_with?(rest, "_")
|
|
|
|
if weather_start do
|
|
result =
|
|
Parser.parse_position_with_datetime_and_weather(
|
|
true,
|
|
time,
|
|
latitude,
|
|
sym_table_id,
|
|
longitude,
|
|
symbol_code,
|
|
rest
|
|
)
|
|
|
|
Map.put(
|
|
result,
|
|
:has_location,
|
|
(is_number(result[:latitude]) or is_struct(result[:latitude], Decimal)) and
|
|
(is_number(result[:longitude]) or is_struct(result[:longitude], Decimal))
|
|
)
|
|
else
|
|
result = parse_position_with_timestamp(true, data, :timestamped_position_with_message)
|
|
|
|
Map.put(
|
|
result,
|
|
:has_location,
|
|
(is_number(result[:latitude]) or is_struct(result[:latitude], Decimal)) and
|
|
(is_number(result[:longitude]) or is_struct(result[:longitude], Decimal))
|
|
)
|
|
end
|
|
|
|
_ ->
|
|
result = parse_position_with_timestamp(true, data, :timestamped_position_with_message)
|
|
|
|
Map.put(
|
|
result,
|
|
:has_location,
|
|
(is_number(result[:latitude]) or is_struct(result[:latitude], Decimal)) and
|
|
(is_number(result[:longitude]) or is_struct(result[:longitude], Decimal))
|
|
)
|
|
end
|
|
end
|
|
|
|
def parse_data(:message, _destination, data) do
|
|
case Regex.run(~r/^:([^:]+):(.+?)(\{(\d+)\})?$/s, data) do
|
|
[_, addressee, message_text, _full_ack, message_number] ->
|
|
%{
|
|
data_type: :message,
|
|
addressee: String.trim(addressee),
|
|
message_text: String.trim(message_text),
|
|
message_number: message_number
|
|
}
|
|
|
|
[_, addressee, message_text] ->
|
|
%{
|
|
data_type: :message,
|
|
addressee: String.trim(addressee),
|
|
message_text: String.trim(message_text)
|
|
}
|
|
|
|
_ ->
|
|
nil
|
|
end
|
|
end
|
|
|
|
def parse_data(:status, _destination, data), do: Parser.Status.parse(data)
|
|
def parse_data(:object, _destination, data), do: Parser.Object.parse(data)
|
|
def parse_data(:item, _destination, data), do: Parser.Item.parse(data)
|
|
def parse_data(:weather, _destination, data), do: Parser.Weather.parse(data)
|
|
def parse_data(:telemetry, _destination, data), do: Parser.Telemetry.parse(data)
|
|
def parse_data(:station_capabilities, _destination, data), do: parse_station_capabilities(data)
|
|
def parse_data(:query, _destination, data), do: parse_query(data)
|
|
def parse_data(:user_defined, _destination, data), do: parse_user_defined(data)
|
|
def parse_data(:third_party_traffic, _destination, data), do: parse_third_party_traffic(data)
|
|
|
|
def parse_data(:peet_logging, _destination, data), do: Parser.Helpers.parse_peet_logging(data)
|
|
|
|
def parse_data(:invalid_test_data, _destination, data), do: Parser.Helpers.parse_invalid_test_data(data)
|
|
|
|
def parse_data(:raw_gps_ultimeter, _destination, data) do
|
|
case Parser.Helpers.parse_nmea_sentence(data) do
|
|
{:error, error} when is_binary(error) ->
|
|
%{
|
|
data_type: :raw_gps_ultimeter,
|
|
error: error,
|
|
nmea_type: nil,
|
|
raw_data: data,
|
|
latitude: nil,
|
|
longitude: nil
|
|
}
|
|
|
|
_ ->
|
|
%{
|
|
data_type: :raw_gps_ultimeter,
|
|
error: "Invalid NMEA sentence",
|
|
nmea_type: nil,
|
|
raw_data: data,
|
|
latitude: nil,
|
|
longitude: nil
|
|
}
|
|
end
|
|
end
|
|
|
|
def parse_data(:df_report, _destination, data) do
|
|
if String.starts_with?(data, "DFS") and byte_size(data) >= 7 do
|
|
<<"DFS", s, h, g, d, rest::binary>> = data
|
|
|
|
%{
|
|
df_strength: Parser.Helpers.parse_df_strength(s),
|
|
height: Parser.Helpers.parse_phg_height(h),
|
|
gain: Parser.Helpers.parse_phg_gain(g),
|
|
directivity: Parser.Helpers.parse_phg_directivity(d),
|
|
comment: rest,
|
|
data_type: :df_report
|
|
}
|
|
else
|
|
%{
|
|
df_data: data,
|
|
data_type: :df_report
|
|
}
|
|
end
|
|
end
|
|
|
|
def parse_data(:phg_data, _destination, data), do: parse_phg_data(data)
|
|
|
|
def parse_data(_type, _destination, _data), do: nil
|
|
|
|
@spec parse_position_with_datetime_and_weather(
|
|
boolean(),
|
|
binary(),
|
|
binary(),
|
|
binary(),
|
|
binary(),
|
|
binary(),
|
|
binary()
|
|
) :: map()
|
|
def parse_position_with_datetime_and_weather(
|
|
aprs_messaging?,
|
|
time,
|
|
latitude,
|
|
sym_table_id,
|
|
longitude,
|
|
symbol_code,
|
|
weather_report
|
|
) do
|
|
pos = parse_aprs_position(latitude, longitude)
|
|
weather_data = parse_weather_data(weather_report)
|
|
|
|
%{
|
|
latitude: pos.latitude,
|
|
longitude: pos.longitude,
|
|
timestamp: time,
|
|
symbol_table_id: sym_table_id,
|
|
symbol_code: symbol_code,
|
|
weather: weather_data,
|
|
data_type: :position_with_datetime_and_weather,
|
|
aprs_messaging?: aprs_messaging?
|
|
}
|
|
end
|
|
|
|
@spec decode_compressed_position(binary()) :: map()
|
|
def decode_compressed_position(
|
|
<<"/", latitude::binary-size(4), longitude::binary-size(4), symbol_code::binary-size(1), _cs::binary-size(2),
|
|
_compression_type::binary-size(2), _rest::binary>>
|
|
) do
|
|
lat = Parser.Helpers.convert_to_base91(latitude)
|
|
lon = Parser.Helpers.convert_to_base91(longitude)
|
|
|
|
%{
|
|
latitude: lat,
|
|
longitude: lon,
|
|
symbol_code: symbol_code
|
|
}
|
|
end
|
|
|
|
@spec convert_to_base91(binary()) :: integer()
|
|
def convert_to_base91(<<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
|
|
|
|
# Helper to extract course and speed from comment field (e.g., "/123/045" or "123/045")
|
|
@spec extract_course_and_speed(String.t()) :: {integer() | nil, float() | nil}
|
|
defp extract_course_and_speed(comment) do
|
|
# Match "/123/045" or "123/045" at the start of the comment
|
|
case Regex.run(~r"/?(\d{3})/(\d{3})", comment) do
|
|
[_, course, speed] -> {String.to_integer(course), String.to_integer(speed) * 1.0}
|
|
_ -> {nil, nil}
|
|
end
|
|
end
|
|
|
|
# Patch parse_position_without_timestamp to include course/speed
|
|
@spec parse_position_without_timestamp(String.t()) :: map()
|
|
def parse_position_without_timestamp(position_data) do
|
|
case position_data do
|
|
<<latitude::binary-size(8), sym_table_id::binary-size(1), longitude::binary-size(9), symbol_code::binary-size(1),
|
|
comment::binary>> ->
|
|
%{latitude: lat, longitude: lon} = parse_aprs_position(latitude, longitude)
|
|
ambiguity = Parser.Helpers.calculate_position_ambiguity(latitude, longitude)
|
|
dao_data = parse_dao_extension(comment)
|
|
{course, speed} = extract_course_and_speed(comment)
|
|
|
|
has_position =
|
|
(is_number(lat) or is_struct(lat, Decimal)) and
|
|
(is_number(lon) or is_struct(lon, Decimal))
|
|
|
|
base_map = %{
|
|
latitude: lat,
|
|
longitude: lon,
|
|
timestamp: nil,
|
|
symbol_table_id: sym_table_id,
|
|
symbol_code: symbol_code,
|
|
comment: comment,
|
|
data_type: :position,
|
|
aprs_messaging?: false,
|
|
compressed?: false,
|
|
position_ambiguity: ambiguity,
|
|
dao: dao_data,
|
|
course: course,
|
|
speed: speed,
|
|
has_position: has_position
|
|
}
|
|
|
|
if sym_table_id == "/" and symbol_code == "_" do
|
|
weather_map = Parser.Weather.parse_weather_data(comment)
|
|
Map.merge(base_map, weather_map)
|
|
else
|
|
base_map
|
|
end
|
|
|
|
<<latitude::binary-size(8), sym_table_id::binary-size(1), longitude::binary-size(9)>> ->
|
|
%{latitude: lat, longitude: lon} = parse_aprs_position(latitude, longitude)
|
|
ambiguity = Parser.Helpers.calculate_position_ambiguity(latitude, longitude)
|
|
|
|
has_position =
|
|
(is_number(lat) or is_struct(lat, Decimal)) and
|
|
(is_number(lon) or is_struct(lon, Decimal))
|
|
|
|
%{
|
|
latitude: lat,
|
|
longitude: lon,
|
|
timestamp: nil,
|
|
symbol_table_id: sym_table_id,
|
|
symbol_code: "_",
|
|
data_type: :position,
|
|
aprs_messaging?: false,
|
|
compressed?: false,
|
|
position_ambiguity: ambiguity,
|
|
dao: nil,
|
|
course: nil,
|
|
speed: nil,
|
|
has_position: has_position
|
|
}
|
|
|
|
<<"/", latitude_compressed::binary-size(4), longitude_compressed::binary-size(4), symbol_code::binary-size(1),
|
|
cs::binary-size(2), compression_type::binary-size(1), comment::binary>> ->
|
|
try do
|
|
converted_lat = Parser.Helpers.convert_compressed_lat(latitude_compressed)
|
|
converted_lon = Parser.Helpers.convert_compressed_lon(longitude_compressed)
|
|
compressed_cs = Parser.Helpers.convert_compressed_cs(cs)
|
|
ambiguity = Parser.Helpers.calculate_compressed_ambiguity(compression_type)
|
|
|
|
has_position =
|
|
(is_number(converted_lat) or is_struct(converted_lat, Decimal)) and
|
|
(is_number(converted_lon) or is_struct(converted_lon, Decimal))
|
|
|
|
base_data = %{
|
|
latitude: converted_lat,
|
|
longitude: converted_lon,
|
|
symbol_table_id: "/",
|
|
symbol_code: symbol_code,
|
|
comment: comment,
|
|
position_format: :compressed,
|
|
compression_type: compression_type,
|
|
data_type: :position,
|
|
compressed?: true,
|
|
position_ambiguity: ambiguity,
|
|
dao: nil,
|
|
has_position: has_position
|
|
}
|
|
|
|
# Merge in course/speed from compressed_cs
|
|
Map.merge(base_data, compressed_cs)
|
|
rescue
|
|
_e ->
|
|
%{
|
|
latitude: nil,
|
|
longitude: nil,
|
|
symbol_table_id: "/",
|
|
symbol_code: symbol_code,
|
|
comment: comment,
|
|
position_format: :compressed,
|
|
compression_type: compression_type,
|
|
data_type: :position,
|
|
compressed?: true,
|
|
position_ambiguity: Parser.Helpers.calculate_compressed_ambiguity(compression_type),
|
|
dao: nil,
|
|
course: nil,
|
|
speed: nil,
|
|
has_position: false
|
|
}
|
|
end
|
|
|
|
_ ->
|
|
%{
|
|
latitude: nil,
|
|
longitude: nil,
|
|
timestamp: nil,
|
|
symbol_table_id: nil,
|
|
symbol_code: nil,
|
|
data_type: :malformed_position,
|
|
aprs_messaging?: false,
|
|
compressed?: false,
|
|
comment: String.trim(position_data),
|
|
dao: nil,
|
|
course: nil,
|
|
speed: nil,
|
|
has_position: false
|
|
}
|
|
end
|
|
end
|
|
|
|
# Patch parse_position_with_message_without_timestamp to propagate course/speed
|
|
@spec parse_position_with_message_without_timestamp(String.t()) :: map()
|
|
def parse_position_with_message_without_timestamp(position_data) do
|
|
result = parse_position_without_timestamp(position_data)
|
|
Map.put(result, :aprs_messaging?, true)
|
|
end
|
|
|
|
# Patch parse_position_with_timestamp to extract course/speed from comment
|
|
@spec parse_position_with_timestamp(boolean(), binary(), atom()) :: map()
|
|
def parse_position_with_timestamp(
|
|
aprs_messaging?,
|
|
<<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>>,
|
|
_data_type
|
|
) do
|
|
case Parser.Helpers.validate_position_data(latitude, longitude) do
|
|
{:ok, {lat, lon}} ->
|
|
position = parse_aprs_position(latitude, longitude)
|
|
{course, speed} = extract_course_and_speed(comment)
|
|
|
|
%{
|
|
latitude: lat,
|
|
longitude: lon,
|
|
position: position,
|
|
time: Parser.Helpers.validate_timestamp(time),
|
|
symbol_table_id: sym_table_id,
|
|
symbol_code: symbol_code,
|
|
comment: comment,
|
|
data_type: :position,
|
|
aprs_messaging?: aprs_messaging?,
|
|
compressed?: false,
|
|
course: course,
|
|
speed: speed
|
|
}
|
|
|
|
_ ->
|
|
# Fallback: try to extract lat/lon using regex if binary pattern match fails
|
|
regex =
|
|
~r/^(?<time>\w{7})(?<lat>\d{4,5}\.\d+[NS])(?<sym_table>.)(?<lon>\d{5,6}\.\d+[EW])(?<sym_code>.)(?<comment>.*)$/
|
|
|
|
case Regex.named_captures(
|
|
regex,
|
|
time <> latitude <> sym_table_id <> longitude <> symbol_code <> comment
|
|
) do
|
|
%{
|
|
"lat" => lat,
|
|
"lon" => lon,
|
|
"time" => time,
|
|
"sym_table" => sym_table,
|
|
"sym_code" => sym_code,
|
|
"comment" => comment
|
|
} ->
|
|
pos = parse_aprs_position(lat, lon)
|
|
|
|
%{
|
|
latitude: pos.latitude,
|
|
longitude: pos.longitude,
|
|
time: time,
|
|
symbol_table_id: sym_table,
|
|
symbol_code: sym_code,
|
|
comment: comment,
|
|
data_type: :position,
|
|
aprs_messaging?: aprs_messaging?,
|
|
compressed?: false
|
|
}
|
|
|
|
_ ->
|
|
%{
|
|
data_type: :timestamped_position_error,
|
|
error: "Invalid timestamped position format",
|
|
raw_data: time <> latitude <> sym_table_id <> longitude <> symbol_code <> comment
|
|
}
|
|
end
|
|
end
|
|
end
|
|
|
|
def parse_position_with_timestamp(aprs_messaging?, data, _data_type) do
|
|
# Fallback: try to extract lat/lon using regex if binary pattern match fails
|
|
regex =
|
|
~r/^(?<time>\w{7})(?<lat>\d{4,5}\.\d+[NS])(?<sym_table>.)(?<lon>\d{5,6}\.\d+[EW])(?<sym_code>.)(?<comment>.*)$/
|
|
|
|
case Regex.named_captures(regex, data) do
|
|
%{
|
|
"lat" => lat,
|
|
"lon" => lon,
|
|
"time" => time,
|
|
"sym_table" => sym_table,
|
|
"sym_code" => sym_code,
|
|
"comment" => comment
|
|
} ->
|
|
pos = parse_aprs_position(lat, lon)
|
|
|
|
%{
|
|
latitude: pos.latitude,
|
|
longitude: pos.longitude,
|
|
time: time,
|
|
symbol_table_id: sym_table,
|
|
symbol_code: sym_code,
|
|
comment: comment,
|
|
data_type: :position,
|
|
aprs_messaging?: aprs_messaging?,
|
|
compressed?: false
|
|
}
|
|
|
|
_ ->
|
|
%{
|
|
data_type: :timestamped_position_error,
|
|
error: "Invalid timestamped position format",
|
|
raw_data: data
|
|
}
|
|
end
|
|
end
|
|
|
|
@spec parse_manufacturer(binary()) :: String.t()
|
|
def parse_manufacturer(symbols) do
|
|
Aprs.DeviceIdentification.identify_device(symbols)
|
|
end
|
|
|
|
@spec convert_compressed_lat(binary()) :: float()
|
|
def convert_compressed_lat(lat) do
|
|
[l1, l2, l3, l4] = to_charlist(lat)
|
|
90 - ((l1 - 33) * 91 ** 3 + (l2 - 33) * 91 ** 2 + (l3 - 33) * 91 + l4 - 33) / 380_926
|
|
end
|
|
|
|
@spec convert_compressed_lon(binary()) :: float()
|
|
def convert_compressed_lon(lon) do
|
|
[l1, l2, l3, l4] = to_charlist(lon)
|
|
-180 + ((l1 - 33) * 91 ** 3 + (l2 - 33) * 91 ** 2 + (l3 - 33) * 91 + l4 - 33) / 190_463
|
|
end
|
|
|
|
@spec convert_compressed_cs(binary()) :: map()
|
|
def convert_compressed_cs(cs) do
|
|
[c, s] = to_charlist(cs)
|
|
c = c - 33
|
|
s = s - 33
|
|
|
|
case c do
|
|
x when x in ?!..?z ->
|
|
# compressed speed/course value
|
|
# speed is returned in knots
|
|
%{
|
|
course: s * 4,
|
|
speed: Convert.speed(1.08 ** s - 1, :knots, :mph)
|
|
}
|
|
|
|
?Z ->
|
|
# pre-calculated radio range
|
|
%{
|
|
range: 2 * 1.08 ** s
|
|
}
|
|
|
|
_ ->
|
|
%{}
|
|
end
|
|
end
|
|
|
|
# Status Report parsing
|
|
def parse_status(<<">", status_text::binary>>) do
|
|
%{
|
|
status_text: status_text,
|
|
data_type: :status
|
|
}
|
|
end
|
|
|
|
@spec parse_status(String.t()) :: map()
|
|
def parse_status(data) do
|
|
%{
|
|
status_text: data,
|
|
data_type: :status
|
|
}
|
|
end
|
|
|
|
# Object Report parsing
|
|
def parse_object(
|
|
<<";", object_name::binary-size(9), live_killed::binary-size(1), timestamp::binary-size(7), rest::binary>>
|
|
) do
|
|
position_data =
|
|
case rest do
|
|
# Uncompressed position format
|
|
<<latitude::binary-size(8), sym_table_id::binary-size(1), longitude::binary-size(9), symbol_code::binary-size(1),
|
|
comment::binary>> ->
|
|
%{latitude: lat, longitude: lon} = parse_aprs_position(latitude, longitude)
|
|
|
|
%{
|
|
latitude: lat,
|
|
longitude: lon,
|
|
symbol_table_id: sym_table_id,
|
|
symbol_code: symbol_code,
|
|
comment: comment,
|
|
position_format: :uncompressed
|
|
}
|
|
|
|
# Compressed position format
|
|
<<"/", latitude_compressed::binary-size(4), longitude_compressed::binary-size(4), symbol_code::binary-size(1),
|
|
cs::binary-size(2), compression_type::binary-size(1), comment::binary>> ->
|
|
try do
|
|
converted_lat = Parser.Helpers.convert_compressed_lat(latitude_compressed)
|
|
converted_lon = Parser.Helpers.convert_compressed_lon(longitude_compressed)
|
|
|
|
if is_number(converted_lat) and is_number(converted_lon) do
|
|
compressed_cs = Parser.Helpers.convert_compressed_cs(cs)
|
|
|
|
base_data = %{
|
|
latitude: converted_lat,
|
|
longitude: converted_lon,
|
|
symbol_table_id: "/",
|
|
symbol_code: symbol_code,
|
|
comment: comment,
|
|
position_format: :compressed,
|
|
compression_type: compression_type
|
|
}
|
|
|
|
Map.merge(base_data, compressed_cs)
|
|
else
|
|
raise "Invalid compressed position"
|
|
end
|
|
rescue
|
|
_ -> %{latitude: nil, longitude: nil, comment: comment, position_format: :compressed}
|
|
end
|
|
|
|
_ ->
|
|
%{comment: rest, position_format: :unknown}
|
|
end
|
|
|
|
Map.merge(
|
|
%{
|
|
object_name: String.trim(object_name),
|
|
live_killed: live_killed,
|
|
timestamp: timestamp,
|
|
data_type: :object
|
|
},
|
|
position_data
|
|
)
|
|
end
|
|
|
|
@spec parse_object(String.t()) :: map()
|
|
def parse_object(data) do
|
|
%{
|
|
raw_data: data,
|
|
data_type: :object
|
|
}
|
|
end
|
|
|
|
# Item Report parsing
|
|
@spec parse_item(binary()) :: map()
|
|
def parse_item(<<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)
|
|
@spec parse_item_position(String.t()) :: map()
|
|
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} = parse_aprs_position(latitude, longitude)
|
|
|
|
%{
|
|
latitude: lat,
|
|
longitude: lon,
|
|
symbol_table_id: sym_table_id,
|
|
symbol_code: symbol_code,
|
|
comment: comment,
|
|
position_format: :uncompressed
|
|
}
|
|
|
|
# Compressed position format
|
|
<<"/", latitude_compressed::binary-size(4), longitude_compressed::binary-size(4), symbol_code::binary-size(1),
|
|
cs::binary-size(2), compression_type::binary-size(1), comment::binary>> ->
|
|
converted_lat = Parser.Helpers.convert_compressed_lat(latitude_compressed)
|
|
converted_lon = Parser.Helpers.convert_compressed_lon(longitude_compressed)
|
|
compressed_cs = Parser.Helpers.convert_compressed_cs(cs)
|
|
|
|
base_data = %{
|
|
latitude: converted_lat,
|
|
longitude: converted_lon,
|
|
symbol_table_id: "/",
|
|
symbol_code: symbol_code,
|
|
comment: comment,
|
|
position_format: :compressed,
|
|
compression_type: compression_type
|
|
}
|
|
|
|
Map.merge(base_data, compressed_cs)
|
|
|
|
_ ->
|
|
%{
|
|
comment: position_data,
|
|
position_format: :unknown
|
|
}
|
|
end
|
|
end
|
|
|
|
# Weather Report parsing
|
|
def parse_weather(<<"_", timestamp::binary-size(8), rest::binary>>) do
|
|
weather_data = parse_weather_data(rest)
|
|
|
|
Map.merge(
|
|
%{
|
|
timestamp: timestamp,
|
|
data_type: :weather
|
|
},
|
|
weather_data
|
|
)
|
|
end
|
|
|
|
@spec parse_weather(String.t()) :: map()
|
|
def parse_weather(data) do
|
|
weather_data = parse_weather_data(data)
|
|
|
|
Map.merge(
|
|
%{
|
|
data_type: :weather
|
|
},
|
|
weather_data
|
|
)
|
|
end
|
|
|
|
# Comprehensive weather data parsing
|
|
defp parse_weather_data(weather_data) do
|
|
# Extract timestamp if present
|
|
timestamp = Parser.Helpers.extract_timestamp(weather_data)
|
|
weather_data = Parser.Helpers.remove_timestamp(weather_data)
|
|
|
|
# Parse each weather component
|
|
weather_values = %{
|
|
wind_direction: Parser.Helpers.parse_wind_direction(weather_data),
|
|
wind_speed: Parser.Helpers.parse_wind_speed(weather_data),
|
|
wind_gust: Parser.Helpers.parse_wind_gust(weather_data),
|
|
temperature: Parser.Helpers.parse_temperature(weather_data),
|
|
rain_1h: Parser.Helpers.parse_rainfall_1h(weather_data),
|
|
rain_24h: Parser.Helpers.parse_rainfall_24h(weather_data),
|
|
rain_since_midnight: Parser.Helpers.parse_rainfall_since_midnight(weather_data),
|
|
humidity: Parser.Helpers.parse_humidity(weather_data),
|
|
pressure: Parser.Helpers.parse_pressure(weather_data),
|
|
luminosity: Parser.Helpers.parse_luminosity(weather_data),
|
|
snow: Parser.Helpers.parse_snow(weather_data)
|
|
}
|
|
|
|
# Build base result map
|
|
result = %{
|
|
timestamp: timestamp,
|
|
data_type: :weather,
|
|
raw_weather_data: weather_data
|
|
}
|
|
|
|
# Add non-nil weather values to result
|
|
Enum.reduce(weather_values, result, fn {key, value}, acc ->
|
|
if is_nil(value), do: acc, else: Map.put(acc, key, value)
|
|
end)
|
|
end
|
|
|
|
# Telemetry parsing
|
|
def parse_telemetry(<<"T#", rest::binary>>) do
|
|
case String.split(rest, ",") do
|
|
[seq | [_ | _] = values] ->
|
|
analog_values = Enum.take(values, 5)
|
|
digital_values = values |> Enum.drop(5) |> Enum.take(8)
|
|
|
|
%{
|
|
sequence_number: Parser.Helpers.parse_telemetry_sequence(seq),
|
|
analog_values: Parser.Helpers.parse_analog_values(analog_values),
|
|
digital_values: Parser.Helpers.parse_digital_values(digital_values),
|
|
data_type: :telemetry,
|
|
raw_data: rest
|
|
}
|
|
|
|
_ ->
|
|
%{
|
|
raw_data: rest,
|
|
data_type: :telemetry
|
|
}
|
|
end
|
|
end
|
|
|
|
# Handle telemetry parameter definitions (PARM)
|
|
def parse_telemetry(<<":PARM.", rest::binary>>) do
|
|
%{
|
|
data_type: :telemetry_parameters,
|
|
parameter_names: String.split(rest, ",", trim: true),
|
|
raw_data: rest
|
|
}
|
|
end
|
|
|
|
# Handle telemetry equation definitions (EQNS)
|
|
def parse_telemetry(<<":EQNS.", rest::binary>>) do
|
|
equations =
|
|
rest
|
|
|> String.split(",", trim: true)
|
|
|> Enum.chunk_every(3)
|
|
|> Enum.map(fn [a, b, c] ->
|
|
%{
|
|
a: Parser.Helpers.parse_coefficient(a),
|
|
b: Parser.Helpers.parse_coefficient(b),
|
|
c: Parser.Helpers.parse_coefficient(c)
|
|
}
|
|
end)
|
|
|
|
%{
|
|
data_type: :telemetry_equations,
|
|
equations: equations,
|
|
raw_data: rest
|
|
}
|
|
end
|
|
|
|
# Handle telemetry unit definitions (UNIT)
|
|
def parse_telemetry(<<":UNIT.", rest::binary>>) do
|
|
%{
|
|
data_type: :telemetry_units,
|
|
units: String.split(rest, ",", trim: true),
|
|
raw_data: rest
|
|
}
|
|
end
|
|
|
|
# Handle telemetry bit sense definitions (BITS)
|
|
def parse_telemetry(<<":BITS.", rest::binary>>) do
|
|
case String.split(rest, ",", trim: true) do
|
|
[bits_sense | project_names] ->
|
|
%{
|
|
data_type: :telemetry_bits,
|
|
bits_sense: String.to_charlist(bits_sense),
|
|
project_names: project_names,
|
|
raw_data: rest
|
|
}
|
|
|
|
[] ->
|
|
%{
|
|
data_type: :telemetry_bits,
|
|
bits_sense: [],
|
|
project_names: [],
|
|
raw_data: rest
|
|
}
|
|
end
|
|
end
|
|
|
|
@spec parse_telemetry(String.t()) :: map()
|
|
def parse_telemetry(data) do
|
|
%{
|
|
raw_data: data,
|
|
data_type: :telemetry
|
|
}
|
|
end
|
|
|
|
# Station Capabilities parsing
|
|
def parse_station_capabilities(<<"<", capabilities::binary>>) do
|
|
%{
|
|
capabilities: capabilities,
|
|
data_type: :station_capabilities
|
|
}
|
|
end
|
|
|
|
@spec parse_station_capabilities(String.t()) :: map()
|
|
def parse_station_capabilities(data) do
|
|
%{
|
|
capabilities: data,
|
|
data_type: :station_capabilities
|
|
}
|
|
end
|
|
|
|
# Query parsing
|
|
def parse_query(<<"?", query_type::binary-size(1), query_data::binary>>) do
|
|
%{
|
|
query_type: query_type,
|
|
query_data: query_data,
|
|
data_type: :query
|
|
}
|
|
end
|
|
|
|
@spec parse_query(String.t()) :: map()
|
|
def parse_query(data) do
|
|
%{
|
|
query_data: data,
|
|
data_type: :query
|
|
}
|
|
end
|
|
|
|
# User Defined parsing
|
|
def parse_user_defined(<<"{", user_id::binary-size(1), user_data::binary>>) do
|
|
parsed_data = parse_user_defined_format(user_id, user_data)
|
|
|
|
Map.merge(
|
|
%{
|
|
user_id: user_id,
|
|
data_type: :user_defined,
|
|
raw_data: user_data
|
|
},
|
|
parsed_data
|
|
)
|
|
end
|
|
|
|
@spec parse_user_defined(String.t()) :: map()
|
|
def parse_user_defined(data) do
|
|
%{
|
|
user_data: data,
|
|
data_type: :user_defined
|
|
}
|
|
end
|
|
|
|
# Parse specific user-defined formats
|
|
defp parse_user_defined_format("A", user_data), do: %{format: :experimental_a, content: user_data}
|
|
|
|
defp parse_user_defined_format("B", user_data), do: %{format: :experimental_b, content: user_data}
|
|
|
|
defp parse_user_defined_format("C", user_data), do: %{format: :custom_c, content: user_data}
|
|
defp parse_user_defined_format(_, user_data), do: %{format: :unknown, content: user_data}
|
|
|
|
# Third Party Traffic parsing
|
|
def parse_third_party_traffic(packet) do
|
|
if Parser.Helpers.count_leading_braces(packet) + 1 > 3 do
|
|
%{
|
|
error: "Maximum tunnel depth exceeded"
|
|
}
|
|
else
|
|
case parse_tunneled_packet(packet) do
|
|
{:ok, parsed_packet} ->
|
|
build_third_party_traffic_result(packet, parsed_packet)
|
|
|
|
{:error, reason} ->
|
|
%{
|
|
error: reason
|
|
}
|
|
end
|
|
end
|
|
end
|
|
|
|
defp build_third_party_traffic_result(packet, parsed_packet) do
|
|
case parse_nested_tunnel(packet) do
|
|
{:ok, nested_packet} ->
|
|
%{
|
|
third_party_packet: nested_packet,
|
|
data_type: :third_party_traffic,
|
|
raw_data: packet
|
|
}
|
|
|
|
{:error, _} ->
|
|
%{
|
|
third_party_packet: parsed_packet,
|
|
data_type: :third_party_traffic,
|
|
raw_data: packet
|
|
}
|
|
end
|
|
end
|
|
|
|
@spec parse_tunneled_packet(String.t()) :: {:ok, map()} | {:error, String.t()}
|
|
defp parse_tunneled_packet(packet) do
|
|
case String.split(packet, ":", parts: 2) do
|
|
[header, information] ->
|
|
parse_tunneled_packet_with_header(header, information)
|
|
|
|
_ ->
|
|
{:error, "Invalid tunneled packet format"}
|
|
end
|
|
end
|
|
|
|
defp parse_tunneled_packet_with_header(header, information) do
|
|
case parse_tunneled_header(header) do
|
|
{:ok, header_data} ->
|
|
parse_tunneled_packet_with_information(header_data, information)
|
|
|
|
{:error, reason} ->
|
|
{:error, "Invalid header: #{reason}"}
|
|
end
|
|
end
|
|
|
|
defp parse_tunneled_packet_with_information(header_data, information) do
|
|
case parse_datatype_safe(information) do
|
|
{:ok, data_type} ->
|
|
data_without_type = String.slice(information, 1..-1//1)
|
|
data_extended = parse_data(data_type, header_data.destination, data_without_type)
|
|
|
|
{:ok,
|
|
Map.merge(header_data, %{
|
|
information_field: information,
|
|
data_type: data_type,
|
|
data_extended: data_extended
|
|
})}
|
|
|
|
{:error, reason} ->
|
|
{:error, "Invalid data type: #{reason}"}
|
|
end
|
|
end
|
|
|
|
@spec parse_tunneled_header(String.t()) :: {:ok, map()} | {:error, String.t()}
|
|
defp parse_tunneled_header(header) do
|
|
case String.split(header, ">", parts: 2) do
|
|
[sender, path] ->
|
|
case parse_callsign(sender) do
|
|
{:ok, callsign_parts} ->
|
|
base_callsign = List.first(callsign_parts)
|
|
|
|
ssid =
|
|
case List.last(callsign_parts) do
|
|
nil ->
|
|
0
|
|
|
|
s when is_binary(s) ->
|
|
case Integer.parse(s) do
|
|
{i, _} -> i
|
|
:error -> 0
|
|
end
|
|
|
|
i when is_integer(i) ->
|
|
i
|
|
|
|
_ ->
|
|
0
|
|
end
|
|
|
|
case split_path(path) do
|
|
{:ok, [destination, digi_path]} ->
|
|
{:ok,
|
|
%{
|
|
sender: sender,
|
|
base_callsign: base_callsign,
|
|
ssid: ssid,
|
|
destination: destination,
|
|
digi_path: digi_path
|
|
}}
|
|
|
|
{:error, reason} ->
|
|
{:error, "Invalid path: #{reason}"}
|
|
end
|
|
|
|
{:error, reason} ->
|
|
{:error, "Invalid callsign: #{reason}"}
|
|
end
|
|
|
|
_ ->
|
|
{:error, "Invalid header format"}
|
|
end
|
|
end
|
|
|
|
# Add network tunneling support
|
|
@spec parse_network_tunnel(String.t()) :: {:ok, map()} | {:error, String.t()}
|
|
defp parse_network_tunnel(packet) do
|
|
# Network tunneling packets start with "}" and contain a complete APRS packet
|
|
case String.slice(packet, 1..-1//1) do
|
|
tunneled_packet ->
|
|
case parse_tunneled_packet(tunneled_packet) do
|
|
{:ok, parsed_packet} ->
|
|
{:ok,
|
|
Map.merge(parsed_packet, %{
|
|
tunnel_type: :network,
|
|
raw_data: packet
|
|
})}
|
|
|
|
{:error, reason} ->
|
|
{:error, "Invalid tunneled packet: #{reason}"}
|
|
end
|
|
end
|
|
end
|
|
|
|
# Add support for multiple levels of tunneling
|
|
defp parse_nested_tunnel(packet, depth \\ 0) do
|
|
cond do
|
|
depth > 3 ->
|
|
{:error, "Maximum tunnel depth exceeded"}
|
|
|
|
String.starts_with?(packet, "}") ->
|
|
case parse_network_tunnel(packet) do
|
|
{:ok, parsed_packet} -> handle_parsed_network_tunnel(parsed_packet, depth)
|
|
{:error, reason} -> {:error, reason}
|
|
end
|
|
|
|
true ->
|
|
{:error, "Not a tunneled packet"}
|
|
end
|
|
end
|
|
|
|
defp handle_parsed_network_tunnel(parsed_packet, depth) do
|
|
case Map.get(parsed_packet, :data_extended) do
|
|
%{raw_data: nested_data} when is_binary(nested_data) ->
|
|
case parse_nested_tunnel(nested_data, depth + 1) do
|
|
{:ok, nested_packet} -> {:ok, Map.put(parsed_packet, :nested_packet, nested_packet)}
|
|
{:error, _} -> {:ok, parsed_packet}
|
|
end
|
|
|
|
_ ->
|
|
{:ok, parsed_packet}
|
|
end
|
|
end
|
|
|
|
# PHG Data parsing (Power, Height, Gain, Directivity)
|
|
def parse_phg_data(<<"#", rest::binary>>) do
|
|
parse_phg_data(rest)
|
|
end
|
|
|
|
def parse_phg_data(<<"PHG", p, h, g, d, rest::binary>>) do
|
|
%{
|
|
power: Parser.Helpers.parse_phg_power(p),
|
|
height: Parser.Helpers.parse_phg_height(h),
|
|
gain: Parser.Helpers.parse_phg_gain(g),
|
|
directivity: Parser.Helpers.parse_phg_directivity(d),
|
|
comment: rest,
|
|
data_type: :phg_data
|
|
}
|
|
end
|
|
|
|
def parse_phg_data(data) do
|
|
%{raw_data: data, data_type: :phg_data}
|
|
end
|
|
|
|
# Add DAO (Datum) extension support
|
|
@spec parse_dao_extension(String.t()) :: map() | nil
|
|
defp parse_dao_extension(comment) do
|
|
case Regex.run(~r/!([A-Za-z])([A-Za-z])([A-Za-z])!/, comment) do
|
|
[_, lat_dao, lon_dao, _] ->
|
|
%{
|
|
lat_dao: lat_dao,
|
|
lon_dao: lon_dao,
|
|
datum: "WGS84"
|
|
}
|
|
|
|
_ ->
|
|
nil
|
|
end
|
|
end
|
|
end
|