Normalize wind direction values to valid range (0-359 degrees) during packet insertion. Invalid wind direction values (360 or > 360, or negative) are automatically set to 0, preventing bad data from entering the database. This complements the fix in the APRS parser and ensures data integrity at the application level as well.
864 lines
26 KiB
Elixir
864 lines
26 KiB
Elixir
defmodule Aprsme.Packet do
|
|
@moduledoc false
|
|
use Aprsme.Schema
|
|
|
|
import Ecto.Changeset
|
|
|
|
alias Aprs.Types.MicE
|
|
alias Aprsme.DataExtended
|
|
alias AprsmeWeb.Live.Shared.CoordinateUtils
|
|
|
|
schema "packets" do
|
|
field(:base_callsign, :string)
|
|
field(:data_type, :string)
|
|
field(:destination, :string)
|
|
field(:path, :string)
|
|
field(:sender, :string)
|
|
field(:ssid, :string)
|
|
field(:received_at, :utc_datetime)
|
|
field(:region, :string)
|
|
field(:lat, :decimal)
|
|
field(:lon, :decimal)
|
|
field(:location, Geo.PostGIS.Geometry)
|
|
field(:has_position, :boolean, default: false)
|
|
|
|
# Original raw packet and symbol information
|
|
field(:raw_packet, :string)
|
|
field(:symbol_code, :string)
|
|
field(:symbol_table_id, :string)
|
|
|
|
# Additional packet data
|
|
field(:comment, :string)
|
|
field(:timestamp, :string)
|
|
field(:aprs_messaging, :boolean, default: false)
|
|
|
|
# Weather data
|
|
field(:temperature, :float)
|
|
field(:humidity, :float)
|
|
field(:wind_speed, :float)
|
|
field(:wind_direction, :integer)
|
|
field(:wind_gust, :float)
|
|
field(:pressure, :float)
|
|
field(:rain_1h, :float)
|
|
field(:rain_24h, :float)
|
|
field(:rain_since_midnight, :float)
|
|
field(:snow, :float)
|
|
|
|
# Equipment/status information
|
|
field(:manufacturer, :string)
|
|
field(:equipment_type, :string)
|
|
field(:course, :integer)
|
|
field(:speed, :float)
|
|
field(:altitude, :float)
|
|
|
|
# Message-specific fields
|
|
field(:addressee, :string)
|
|
field(:message_text, :string)
|
|
field(:message_number, :string)
|
|
|
|
field(:has_weather, :boolean, default: false)
|
|
|
|
# JSONB column for display-only fields (PHG, telemetry, radiorange, etc.)
|
|
field(:data, :map, default: %{})
|
|
|
|
field(:device_identifier, :string)
|
|
|
|
# APRS Items/Objects support
|
|
field(:item_name, :string)
|
|
field(:object_name, :string)
|
|
field(:is_item, :boolean, default: false)
|
|
field(:is_object, :boolean, default: false)
|
|
|
|
# DAO (Datum As Offset) extension for extra position precision
|
|
field(:dao, :map)
|
|
|
|
embeds_one(:data_extended, DataExtended)
|
|
|
|
timestamps(type: :utc_datetime)
|
|
end
|
|
|
|
@type t :: %__MODULE__{}
|
|
|
|
@doc false
|
|
@spec changeset(Aprsme.Packet.t(), map()) :: Ecto.Changeset.t()
|
|
def changeset(packet, attrs) do
|
|
# Convert atom data_type to string
|
|
attrs = normalize_data_type(attrs)
|
|
|
|
packet
|
|
|> cast(attrs, [
|
|
:base_callsign,
|
|
:data_type,
|
|
:destination,
|
|
:path,
|
|
:sender,
|
|
:ssid,
|
|
:received_at,
|
|
:region,
|
|
:lat,
|
|
:lon,
|
|
:location,
|
|
:has_position,
|
|
:raw_packet,
|
|
:symbol_code,
|
|
:symbol_table_id,
|
|
:comment,
|
|
:timestamp,
|
|
:aprs_messaging,
|
|
:temperature,
|
|
:humidity,
|
|
:wind_speed,
|
|
:wind_direction,
|
|
:wind_gust,
|
|
:pressure,
|
|
:rain_1h,
|
|
:rain_24h,
|
|
:rain_since_midnight,
|
|
:snow,
|
|
:manufacturer,
|
|
:equipment_type,
|
|
:course,
|
|
:speed,
|
|
:altitude,
|
|
:addressee,
|
|
:message_text,
|
|
:message_number,
|
|
:has_weather,
|
|
:data,
|
|
:device_identifier,
|
|
:item_name,
|
|
:object_name,
|
|
:is_item,
|
|
:is_object,
|
|
:dao
|
|
])
|
|
|> validate_required([
|
|
:base_callsign,
|
|
:data_type,
|
|
:destination,
|
|
:sender,
|
|
:ssid,
|
|
:received_at
|
|
])
|
|
|> maybe_set_location_and_position()
|
|
end
|
|
|
|
defp maybe_set_location_and_position(changeset) do
|
|
changeset
|
|
|> maybe_create_geometry_from_lat_lon()
|
|
|> maybe_set_has_position()
|
|
|> normalize_symbols()
|
|
|> normalize_course()
|
|
|> normalize_wind_direction()
|
|
end
|
|
|
|
defp normalize_symbols(changeset) do
|
|
# Only normalize if packet has position
|
|
if get_field(changeset, :has_position) do
|
|
symbol_code = get_field(changeset, :symbol_code)
|
|
symbol_table_id = get_field(changeset, :symbol_table_id)
|
|
|
|
changeset
|
|
|> normalize_symbol_code(symbol_code)
|
|
|> normalize_symbol_table_id(symbol_table_id)
|
|
else
|
|
changeset
|
|
end
|
|
end
|
|
|
|
defp normalize_symbol_code(changeset, code) when is_binary(code) and byte_size(code) == 1 do
|
|
changeset
|
|
end
|
|
|
|
defp normalize_symbol_code(changeset, _invalid_code) do
|
|
# Default to '>' (car) if symbol_code is missing or invalid
|
|
put_change(changeset, :symbol_code, ">")
|
|
end
|
|
|
|
defp normalize_symbol_table_id(changeset, table) when is_binary(table) and byte_size(table) == 1 do
|
|
changeset
|
|
end
|
|
|
|
defp normalize_symbol_table_id(changeset, _invalid_table) do
|
|
# Default to '/' (primary symbol table) if table_id is missing or invalid
|
|
put_change(changeset, :symbol_table_id, "/")
|
|
end
|
|
|
|
defp normalize_course(changeset) do
|
|
course = get_field(changeset, :course) || get_change(changeset, :course)
|
|
|
|
case course do
|
|
nil ->
|
|
changeset
|
|
|
|
c when is_integer(c) and c >= 0 and c <= 359 ->
|
|
changeset
|
|
|
|
c when is_integer(c) ->
|
|
# Invalid course value (negative or >= 360), normalize to 0
|
|
put_change(changeset, :course, 0)
|
|
|
|
_ ->
|
|
changeset
|
|
end
|
|
end
|
|
|
|
defp normalize_wind_direction(changeset) do
|
|
wind_direction = get_field(changeset, :wind_direction) || get_change(changeset, :wind_direction)
|
|
|
|
case wind_direction do
|
|
nil ->
|
|
changeset
|
|
|
|
dir when is_integer(dir) and dir >= 0 and dir <= 359 ->
|
|
changeset
|
|
|
|
360 ->
|
|
# 360 degrees = 0 degrees (full circle)
|
|
put_change(changeset, :wind_direction, 0)
|
|
|
|
dir when is_integer(dir) ->
|
|
# Invalid wind direction (negative or > 360), normalize to 0
|
|
put_change(changeset, :wind_direction, 0)
|
|
|
|
_ ->
|
|
changeset
|
|
end
|
|
end
|
|
|
|
@spec maybe_create_geometry_from_lat_lon(Ecto.Changeset.t()) :: Ecto.Changeset.t()
|
|
defp maybe_create_geometry_from_lat_lon(changeset) do
|
|
lat = get_field(changeset, :lat) || get_change(changeset, :lat)
|
|
lon = get_field(changeset, :lon) || get_change(changeset, :lon)
|
|
|
|
# Also check data_extended for coordinates
|
|
{lat, lon} = extract_coordinates_from_changeset(changeset, {lat, lon})
|
|
|
|
create_geometry_from_coordinates(changeset, lat, lon)
|
|
end
|
|
|
|
defp extract_coordinates_from_changeset(changeset, {nil, nil}) do
|
|
data_extended = get_change(changeset, :data_extended)
|
|
extract_coordinates_from_data_extended(data_extended)
|
|
end
|
|
|
|
defp extract_coordinates_from_changeset(_changeset, coords), do: coords
|
|
|
|
defp extract_coordinates_from_data_extended(nil), do: {nil, nil}
|
|
|
|
defp extract_coordinates_from_data_extended(data_extended)
|
|
when is_map(data_extended) and not is_struct(data_extended) do
|
|
{data_extended[:latitude], data_extended[:longitude]}
|
|
end
|
|
|
|
defp extract_coordinates_from_data_extended(_), do: {nil, nil}
|
|
|
|
defp create_geometry_from_coordinates(changeset, lat, lon) do
|
|
# Normalize coordinates to valid ranges
|
|
normalized_lat = CoordinateUtils.normalize_latitude(lat)
|
|
normalized_lon = CoordinateUtils.normalize_longitude(lon)
|
|
|
|
if valid_coordinates?(normalized_lat, normalized_lon) do
|
|
create_and_set_location(changeset, normalized_lat, normalized_lon)
|
|
else
|
|
changeset
|
|
end
|
|
end
|
|
|
|
defp create_and_set_location(changeset, lat, lon) do
|
|
case create_point(lat, lon) do
|
|
nil -> changeset
|
|
location -> put_change(changeset, :location, location)
|
|
end
|
|
rescue
|
|
error ->
|
|
require Logger
|
|
|
|
Logger.error("Failed to create geometry for lat=#{lat}, lon=#{lon}: #{inspect(error)}")
|
|
changeset
|
|
end
|
|
|
|
defp maybe_set_has_position(changeset) do
|
|
location = get_field(changeset, :location) || get_change(changeset, :location)
|
|
|
|
case location do
|
|
nil -> check_legacy_coordinates(changeset)
|
|
_location -> put_change(changeset, :has_position, true)
|
|
end
|
|
end
|
|
|
|
defp check_legacy_coordinates(changeset) do
|
|
lat = get_field(changeset, :lat) || get_change(changeset, :lat)
|
|
lon = get_field(changeset, :lon) || get_change(changeset, :lon)
|
|
|
|
if valid_coordinates?(lat, lon) do
|
|
put_change(changeset, :has_position, true)
|
|
else
|
|
changeset
|
|
end
|
|
end
|
|
|
|
defp valid_coordinates?(lat, lon) do
|
|
CoordinateUtils.valid_coordinates_any_type?(lat, lon)
|
|
end
|
|
|
|
defp normalize_coordinate(coord) do
|
|
CoordinateUtils.normalize_coordinate(coord)
|
|
end
|
|
|
|
defp normalize_data_type(attrs), do: Aprsme.EncodingUtils.normalize_data_type(attrs)
|
|
|
|
@doc """
|
|
Extracts additional data from the raw packet and data_extended structure
|
|
and merges it with the packet attributes for storage.
|
|
"""
|
|
@spec extract_additional_data(map(), String.t() | nil) :: map()
|
|
def extract_additional_data(attrs, raw_packet \\ nil) do
|
|
data_extended = attrs[:data_extended] || attrs["data_extended"] || %{}
|
|
|
|
# Start with the base attributes and add the raw packet
|
|
base_attrs = Map.put(attrs, :raw_packet, raw_packet)
|
|
|
|
# Remove raw_weather_data from base attributes
|
|
base_attrs = Map.delete(base_attrs, :raw_weather_data)
|
|
base_attrs = Map.delete(base_attrs, "raw_weather_data")
|
|
|
|
# Check if symbol data exists at the top level of attrs (from APRS parser)
|
|
# and preserve it if not already in base_attrs
|
|
base_attrs = add_symbol_data(base_attrs, attrs)
|
|
|
|
# Extract data based on the type of data_extended
|
|
additional_data =
|
|
case data_extended do
|
|
%MicE{} = mic_e ->
|
|
extract_from_mic_e(mic_e)
|
|
|
|
%{__original_struct__: MicE} = mic_e_map ->
|
|
extract_from_mic_e_map(mic_e_map)
|
|
|
|
# Handle ParseError structs gracefully
|
|
%{__struct__: Aprs.Types.ParseError} ->
|
|
%{}
|
|
|
|
%{} when is_map(data_extended) ->
|
|
extract_from_map(data_extended)
|
|
|
|
_ ->
|
|
%{}
|
|
end
|
|
|
|
# Merge all extracted data, then sweep display-only fields into data map
|
|
# and strip dead parser compat fields
|
|
base_attrs
|
|
|> merge_extracted_data(additional_data, attrs)
|
|
|> collect_into_data_map()
|
|
|> strip_dead_fields()
|
|
end
|
|
|
|
defp add_symbol_data(base_attrs, attrs) do
|
|
base_attrs
|
|
|> maybe_put(:symbol_code, attrs[:symbol_code] || attrs["symbol_code"])
|
|
|> maybe_put(:symbol_table_id, attrs[:symbol_table_id] || attrs["symbol_table_id"])
|
|
end
|
|
|
|
defp merge_extracted_data(base_attrs, additional_data, attrs) do
|
|
# Process telemetry fields from top-level attrs if not already processed
|
|
telemetry_data =
|
|
if Map.has_key?(additional_data, :telemetry_vals) do
|
|
# Already processed from data_extended
|
|
%{}
|
|
else
|
|
# Process from top-level attrs
|
|
put_telemetry_fields(%{}, attrs)
|
|
end
|
|
|
|
# Process radio range from top-level attrs
|
|
radio_range_data = put_radio_range_field(%{}, attrs)
|
|
|
|
base_attrs
|
|
|> Map.merge(additional_data)
|
|
|> Map.merge(telemetry_data)
|
|
|> Map.merge(radio_range_data)
|
|
end
|
|
|
|
# Fields that get swept into the JSONB `data` column
|
|
@data_fields ~w[telemetry_seq telemetry_vals telemetry_bits phg_power phg_height
|
|
phg_gain phg_directivity radiorange information_field format posresolution
|
|
position_ambiguity luminosity rain_midnight]a
|
|
|
|
defp collect_into_data_map(attrs) do
|
|
{data_values, remaining} =
|
|
Enum.reduce(@data_fields, {%{}, attrs}, fn field, {data, rest} ->
|
|
case Map.pop(rest, field) do
|
|
{nil, rest} -> {data, rest}
|
|
{value, rest} -> {Map.put(data, to_string(field), value), rest}
|
|
end
|
|
end)
|
|
|
|
existing_data = Map.get(remaining, :data, %{}) || %{}
|
|
Map.put(remaining, :data, Map.merge(existing_data, data_values))
|
|
end
|
|
|
|
# Dead parser compat fields — never read, safe to strip
|
|
@dead_fields ~w[srccallsign dstcallsign origpacket body header alive
|
|
posambiguity symboltable symbolcode messaging]a
|
|
|
|
defp strip_dead_fields(attrs) do
|
|
Map.drop(attrs, @dead_fields)
|
|
end
|
|
|
|
# Extract data from standard map-based data_extended
|
|
# Convert struct to map if possible, otherwise return empty map
|
|
defp extract_from_map(data_extended) when is_struct(data_extended) do
|
|
data_extended
|
|
|> Map.from_struct()
|
|
|> extract_from_map()
|
|
rescue
|
|
_ -> %{}
|
|
end
|
|
|
|
defp extract_from_map(data_extended) when is_map(data_extended) do
|
|
# Remove raw_weather_data before processing
|
|
data_extended = Map.delete(data_extended, :raw_weather_data)
|
|
data_extended = Map.delete(data_extended, "raw_weather_data")
|
|
|
|
# Handle nested data_extended structures
|
|
nested_data_extended = data_extended[:data_extended] || data_extended["data_extended"]
|
|
|
|
# Merge data from both levels
|
|
combined_data =
|
|
if nested_data_extended do
|
|
Map.merge(data_extended, nested_data_extended)
|
|
else
|
|
data_extended
|
|
end
|
|
|
|
result =
|
|
%{}
|
|
|> put_symbol_fields(combined_data)
|
|
|> extract_weather_data(combined_data)
|
|
|> put_weather_fields(combined_data)
|
|
|> put_equipment_fields(combined_data)
|
|
|> put_message_fields(combined_data)
|
|
|> put_telemetry_fields(combined_data)
|
|
|> put_radio_range_field(combined_data)
|
|
|
|
# Don't override data_type - trust the APRS parser's determination
|
|
# The parser already correctly identifies weather packets by:
|
|
# 1. Data type indicator (e.g., "_" for weather)
|
|
# 2. Symbol (e.g., "/_" for weather station)
|
|
# 3. Actual weather data patterns in the content
|
|
result
|
|
end
|
|
|
|
defp put_symbol_fields(map, data_extended) do
|
|
# First try to get symbol data from the data_extended map
|
|
symbol_code = data_extended[:symbol_code] || data_extended["symbol_code"]
|
|
symbol_table_id = data_extended[:symbol_table_id] || data_extended["symbol_table_id"]
|
|
|
|
# Clean comment by removing altitude and PHG data
|
|
comment = clean_comment(data_extended[:comment] || data_extended["comment"])
|
|
|
|
map
|
|
|> maybe_put(:symbol_code, symbol_code)
|
|
|> maybe_put(:symbol_table_id, symbol_table_id)
|
|
|> maybe_put(:comment, comment)
|
|
|> maybe_put(:timestamp, data_extended[:timestamp] || data_extended["timestamp"])
|
|
|> maybe_put(
|
|
:aprs_messaging,
|
|
data_extended[:aprs_messaging?] || data_extended["aprs_messaging?"]
|
|
)
|
|
|> maybe_put(:position_ambiguity, data_extended[:position_ambiguity] || data_extended["position_ambiguity"])
|
|
|> maybe_put(:posresolution, data_extended[:posresolution] || data_extended["posresolution"])
|
|
|> maybe_put(:format, normalize_format_field(data_extended[:format] || data_extended["format"]))
|
|
end
|
|
|
|
defp put_weather_fields(map, data_extended) do
|
|
Enum.reduce(Aprsme.EncodingUtils.weather_fields(), map, fn field, acc ->
|
|
value = data_extended[field] || data_extended[to_string(field)]
|
|
maybe_put(acc, field, value)
|
|
end)
|
|
end
|
|
|
|
defp put_equipment_fields(map, data_extended) do
|
|
# Extract altitude from comment if not already in data_extended
|
|
altitude =
|
|
get_field_value(data_extended, :altitude) ||
|
|
extract_altitude_from_comment(get_field_value(data_extended, :comment))
|
|
|
|
# Extract PHG from comment if not already in data_extended
|
|
phg =
|
|
get_field_value(data_extended, :phg) ||
|
|
extract_phg_from_comment(get_field_value(data_extended, :comment))
|
|
|
|
# Update data_extended with extracted values
|
|
data_extended =
|
|
data_extended
|
|
|> Map.put(:altitude, altitude)
|
|
|> Map.put(:phg, phg)
|
|
|
|
map
|
|
|> maybe_put(:manufacturer, get_field_value(data_extended, :manufacturer))
|
|
|> maybe_put(:equipment_type, get_field_value(data_extended, :equipment_type))
|
|
|> maybe_put(:course, get_field_value(data_extended, :course))
|
|
|> maybe_put(:speed, get_field_value(data_extended, :speed))
|
|
|> maybe_put(:altitude, altitude)
|
|
# Don't add :phg to the map - it will be split into individual fields by put_phg_fields
|
|
|> put_phg_fields(data_extended)
|
|
end
|
|
|
|
defp put_phg_fields(map, data_extended) do
|
|
phg = get_field_value(data_extended, :phg)
|
|
|
|
cond do
|
|
phg && is_map(phg) ->
|
|
map
|
|
|> maybe_put(:phg_power, get_field_value(phg, :power))
|
|
|> maybe_put(:phg_height, get_field_value(phg, :height))
|
|
|> maybe_put(:phg_gain, get_field_value(phg, :gain))
|
|
|> maybe_put(:phg_directivity, get_field_value(phg, :directivity))
|
|
|
|
phg && is_binary(phg) && String.length(phg) == 4 ->
|
|
# Handle new string format from improved parser (e.g., "1060")
|
|
parse_phg_string(map, phg)
|
|
|
|
true ->
|
|
map
|
|
end
|
|
end
|
|
|
|
# Parse PHG string format (e.g., "1060" -> power=1, height=0, gain=6, dir=0)
|
|
defp parse_phg_string(
|
|
map,
|
|
<<power::binary-size(1), height::binary-size(1), gain::binary-size(1), dir::binary-size(1)>>
|
|
) do
|
|
map
|
|
|> maybe_put(:phg_power, calculate_phg_power(power))
|
|
|> maybe_put(:phg_height, calculate_phg_height(height))
|
|
|> maybe_put(:phg_gain, String.to_integer(gain))
|
|
|> maybe_put(:phg_directivity, calculate_phg_directivity(dir))
|
|
rescue
|
|
_ -> map
|
|
end
|
|
|
|
defp parse_phg_string(map, _), do: map
|
|
|
|
defp put_message_fields(map, data_extended) do
|
|
map
|
|
|> maybe_put(:addressee, data_extended[:addressee] || data_extended["addressee"])
|
|
|> maybe_put(:message_text, data_extended[:message_text] || data_extended["message_text"])
|
|
|> maybe_put(
|
|
:message_number,
|
|
data_extended[:message_number] || data_extended["message_number"]
|
|
)
|
|
end
|
|
|
|
defp put_telemetry_fields(map, data_extended) do
|
|
telemetry = data_extended[:telemetry] || data_extended["telemetry"] || data_extended
|
|
|
|
map
|
|
|> maybe_put(:telemetry_seq, parse_telemetry_seq(telemetry))
|
|
|> maybe_put(:telemetry_vals, parse_telemetry_vals(telemetry))
|
|
|> maybe_put(:telemetry_bits, telemetry[:bits] || telemetry["bits"])
|
|
end
|
|
|
|
defp parse_telemetry_seq(telemetry) do
|
|
case telemetry[:seq] || telemetry["seq"] do
|
|
nil -> nil
|
|
seq when is_integer(seq) -> seq
|
|
seq when is_binary(seq) -> parse_integer_string(seq)
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
defp parse_telemetry_vals(telemetry) do
|
|
case telemetry[:vals] || telemetry["vals"] do
|
|
nil -> nil
|
|
vals when is_list(vals) -> Enum.map(vals, &normalize_telemetry_value/1)
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
defp parse_integer_string(str) do
|
|
case Integer.parse(str) do
|
|
{num, _} -> num
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
defp normalize_telemetry_value(val) when is_integer(val), do: val
|
|
defp normalize_telemetry_value(val) when is_float(val), do: round(val)
|
|
|
|
defp normalize_telemetry_value(val) when is_binary(val) do
|
|
case Float.parse(val) do
|
|
{num, _} -> round(num)
|
|
_ -> 0
|
|
end
|
|
end
|
|
|
|
defp normalize_telemetry_value(_), do: 0
|
|
|
|
# Extract data from MicE packets
|
|
defp extract_from_mic_e(mic_e) do
|
|
%{}
|
|
|> maybe_put(:lat, mic_e[:latitude])
|
|
|> maybe_put(:lon, mic_e[:longitude])
|
|
|> maybe_put(:comment, mic_e[:message])
|
|
|> maybe_put(:manufacturer, mic_e[:manufacturer])
|
|
|> maybe_put(:course, mic_e[:heading])
|
|
|> maybe_put(:speed, mic_e[:speed])
|
|
|> maybe_put(:symbol_code, mic_e[:symbol_code])
|
|
|> maybe_put(:symbol_table_id, mic_e[:symbol_table_id])
|
|
end
|
|
|
|
# Extract data from converted MicE map (from struct_to_map conversion)
|
|
defp extract_from_mic_e_map(mic_e_map) do
|
|
%{}
|
|
|> maybe_put(:lat, mic_e_map[:latitude])
|
|
|> maybe_put(:lon, mic_e_map[:longitude])
|
|
|> maybe_put(:comment, mic_e_map[:message])
|
|
|> maybe_put(:manufacturer, mic_e_map[:manufacturer])
|
|
|> maybe_put(:course, mic_e_map[:heading])
|
|
|> maybe_put(:speed, mic_e_map[:speed])
|
|
# Use symbol data from MicE if available, otherwise use default car symbol
|
|
|> maybe_put(:symbol_code, mic_e_map[:symbol_code] || mic_e_map["symbol_code"] || ">")
|
|
|> maybe_put(:symbol_table_id, mic_e_map[:symbol_table_id] || mic_e_map["symbol_table_id"] || "/")
|
|
end
|
|
|
|
# Extract weather data from various formats, including new wx field
|
|
defp extract_weather_data(attrs, data_extended) do
|
|
weather_data = find_weather_data(data_extended)
|
|
|
|
# Check for dedicated wx field from improved parser
|
|
wx_data = data_extended[:wx] || data_extended["wx"]
|
|
|
|
final_weather_data = wx_data || weather_data
|
|
process_weather_data(attrs, final_weather_data)
|
|
end
|
|
|
|
defp find_weather_data(data_extended) do
|
|
data_extended[:weather] || data_extended["weather"] ||
|
|
data_extended[:weather_report] || data_extended["weather_report"] ||
|
|
data_extended[:raw_weather_data] || data_extended["raw_weather_data"]
|
|
end
|
|
|
|
defp process_weather_data(attrs, weather_data) do
|
|
case weather_data do
|
|
weather when is_binary(weather) ->
|
|
process_binary_weather_data(attrs, weather)
|
|
|
|
weather when is_map(weather) ->
|
|
process_map_weather_data(attrs, weather)
|
|
|
|
_ ->
|
|
attrs
|
|
end
|
|
end
|
|
|
|
defp process_binary_weather_data(attrs, _weather) do
|
|
# If you have a weather parsing function, call it here
|
|
attrs
|
|
end
|
|
|
|
defp process_map_weather_data(attrs, weather) do
|
|
weather = Map.drop(weather, [:raw_weather_data, "raw_weather_data"])
|
|
|
|
# Only merge weather data, don't override data_type
|
|
Map.merge(attrs, weather)
|
|
end
|
|
|
|
# Helper to put a value only if it's not nil
|
|
defp maybe_put(map, _key, nil), do: map
|
|
defp maybe_put(map, _key, ""), do: map
|
|
defp maybe_put(map, key, value), do: Map.put(map, key, value)
|
|
|
|
@doc """
|
|
Create a geometry point from lat/lon coordinates.
|
|
"""
|
|
@spec create_point(number() | nil, number() | nil) :: Geo.Point.t() | nil
|
|
def create_point(lat, lon)
|
|
when (is_number(lat) or is_struct(lat, Decimal)) and (is_number(lon) or is_struct(lon, Decimal)) do
|
|
lat = normalize_coordinate(lat)
|
|
lon = normalize_coordinate(lon)
|
|
|
|
if valid_coordinates?(lat, lon) do
|
|
%Geo.Point{coordinates: {lon, lat}, srid: 4326}
|
|
end
|
|
end
|
|
|
|
def create_point(_, _), do: nil
|
|
|
|
@doc """
|
|
Extract lat/lon from a PostGIS geometry point.
|
|
"""
|
|
@spec extract_coordinates(Geo.Point.t() | any()) :: {number() | nil, number() | nil}
|
|
def extract_coordinates(%Geo.Point{coordinates: {lon, lat}}), do: {lat, lon}
|
|
def extract_coordinates(_), do: {nil, nil}
|
|
|
|
@doc """
|
|
Get latitude from a packet's location geometry.
|
|
"""
|
|
@spec lat(Aprsme.Packet.t()) :: number() | nil
|
|
def lat(%__MODULE__{location: %Geo.Point{coordinates: {_lon, lat}}}), do: lat
|
|
def lat(_), do: nil
|
|
|
|
@doc """
|
|
Get longitude from a packet's location geometry.
|
|
"""
|
|
@spec lon(Aprsme.Packet.t()) :: number() | nil
|
|
def lon(%__MODULE__{location: %Geo.Point{coordinates: {lon, _lat}}}), do: lon
|
|
def lon(_), do: nil
|
|
|
|
# Clean comment by removing altitude and PHG data
|
|
defp clean_comment(nil), do: nil
|
|
|
|
defp clean_comment(comment) when is_binary(comment) do
|
|
comment
|
|
|> strip_weather_data()
|
|
# Remove altitude
|
|
|> String.replace(~r/\s*\/A=-?\d{5,6}/, "")
|
|
# Remove PHG
|
|
|> String.replace(~r/PHG\d{4}\s*/, "")
|
|
# Remove RNG
|
|
|> String.replace(~r/RNG\d{4}\s*/, "")
|
|
|> String.trim()
|
|
|> case do
|
|
"" -> nil
|
|
cleaned -> cleaned
|
|
end
|
|
end
|
|
|
|
defp clean_comment(comment), do: comment
|
|
|
|
# APRS weather fields: each is a letter prefix + fixed number of digits/spaces
|
|
@wx_field_patterns [
|
|
~r/^g[\d .]{3}/,
|
|
~r/^t[\d .-]{3}/,
|
|
~r/^r[\d .]{3}/,
|
|
~r/^p[\d .]{3}/,
|
|
~r/^P[\d .]{3}/,
|
|
~r/^h[\d .]{2}/,
|
|
~r/^b[\d .]{5}/,
|
|
~r/^s[\d .]{3}/,
|
|
~r/^[Ll][\d .]{3}/
|
|
]
|
|
|
|
# Matches position-included format (XXX/XXX) and positionless format (cXXXsXXX)
|
|
@wx_preamble_pattern ~r/^_?(?:[\d .]{3}\/[\d .]{3}|c[\d .]{3}s[\d .]{3})/
|
|
|
|
defp strip_weather_data(comment) do
|
|
case Regex.run(@wx_preamble_pattern, comment) do
|
|
[match] ->
|
|
rest = String.slice(comment, String.length(match)..-1//1)
|
|
|
|
# Only strip if at least one weather field follows the preamble
|
|
if match_wx_field(rest) do
|
|
strip_wx_fields(rest)
|
|
else
|
|
comment
|
|
end
|
|
|
|
nil ->
|
|
comment
|
|
end
|
|
end
|
|
|
|
defp strip_wx_fields(str) do
|
|
case match_wx_field(str) do
|
|
nil -> str
|
|
match -> str |> String.slice(String.length(match)..-1//1) |> strip_wx_fields()
|
|
end
|
|
end
|
|
|
|
defp match_wx_field(str) do
|
|
Enum.find_value(@wx_field_patterns, fn pat ->
|
|
case Regex.run(pat, str) do
|
|
[match] -> match
|
|
nil -> nil
|
|
end
|
|
end)
|
|
end
|
|
|
|
# Extract altitude from APRS comment field (e.g., "/A=000680" means 680 feet)
|
|
defp extract_altitude_from_comment(nil), do: nil
|
|
|
|
defp extract_altitude_from_comment(comment) when is_binary(comment) do
|
|
case Regex.run(~r/\/A=(\d{6})/, comment) do
|
|
[_, altitude_str] ->
|
|
# Convert string to integer (altitude in feet)
|
|
case Integer.parse(altitude_str) do
|
|
# Convert to float
|
|
{altitude, _} -> altitude * 1.0
|
|
_ -> nil
|
|
end
|
|
|
|
_ ->
|
|
nil
|
|
end
|
|
end
|
|
|
|
defp extract_altitude_from_comment(_), do: nil
|
|
|
|
# Extract PHG (Power-Height-Gain) from APRS comment field (e.g., "PHG5530")
|
|
defp extract_phg_from_comment(nil), do: nil
|
|
|
|
defp extract_phg_from_comment(comment) when is_binary(comment) do
|
|
case Regex.run(~r/PHG(\d)(\d)(\d)(\d)/, comment) do
|
|
[_, power, height, gain, dir] ->
|
|
%{
|
|
power: calculate_phg_power(power),
|
|
height: calculate_phg_height(height),
|
|
gain: String.to_integer(gain),
|
|
directivity: calculate_phg_directivity(dir)
|
|
}
|
|
|
|
_ ->
|
|
nil
|
|
end
|
|
end
|
|
|
|
defp extract_phg_from_comment(_), do: nil
|
|
|
|
# PHG power calculation: power = n^2 watts
|
|
defp calculate_phg_power(n) when is_binary(n) do
|
|
case Integer.parse(n) do
|
|
{num, _} -> num * num
|
|
_ -> 0
|
|
end
|
|
end
|
|
|
|
# PHG height calculation: height = 10 * 2^n feet
|
|
defp calculate_phg_height(n) when is_binary(n) do
|
|
case Integer.parse(n) do
|
|
{num, _} -> 10 * trunc(:math.pow(2, num))
|
|
_ -> 10
|
|
end
|
|
end
|
|
|
|
# PHG directivity: 0-8 = directional (n * 45 degrees), 9 = omni (360 degrees)
|
|
defp calculate_phg_directivity(n) when is_binary(n) do
|
|
case Integer.parse(n) do
|
|
{9, _} -> 360
|
|
{num, _} when num >= 0 and num <= 8 -> num * 45
|
|
_ -> 0
|
|
end
|
|
end
|
|
|
|
# Normalize format field from atom to string
|
|
defp normalize_format_field(nil), do: nil
|
|
defp normalize_format_field(format) when is_atom(format), do: to_string(format)
|
|
defp normalize_format_field(format) when is_binary(format), do: format
|
|
defp normalize_format_field(_), do: nil
|
|
|
|
# Extract radio range field
|
|
defp put_radio_range_field(map, data) do
|
|
maybe_put(map, :radiorange, get_field_value(data, :radiorange))
|
|
end
|
|
|
|
# Helper to get field value from either atom or string key
|
|
defp get_field_value(data, field) when is_map(data) do
|
|
data[field] || data[to_string(field)]
|
|
end
|
|
|
|
defp get_field_value(_, _), do: nil
|
|
end
|