aprs.me/lib/aprs/packet.ex
2025-06-16 10:08:22 -05:00

452 lines
14 KiB
Elixir

defmodule Aprs.Packet do
@moduledoc false
use Aprs.Schema
import Ecto.Changeset
alias Aprs.DataExtended
alias Parser.Types.MicE
schema "packets" do
field(:base_callsign, :string)
field(:data_type, :string)
field(:destination, :string)
field(:information_field, :string)
field(:path, :string)
field(:sender, :string)
field(:ssid, :string)
field(:received_at, :utc_datetime_usec)
field(:region, :string)
field(:lat, :float, virtual: true)
field(:lon, :float, virtual: true)
field(:location, Aprs.GeometryType)
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)
# 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)
embeds_one(:data_extended, DataExtended)
timestamps()
end
@doc false
def changeset(packet, attrs) do
# Convert atom data_type to string
attrs = normalize_data_type(attrs)
packet
|> cast(attrs, [
:base_callsign,
:data_type,
:destination,
:information_field,
: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,
:manufacturer,
:equipment_type,
:course,
:speed,
:altitude,
:addressee,
:message_text,
:message_number
])
|> validate_required([
:base_callsign,
:data_type,
:destination,
:information_field,
:path,
: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()
end
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} =
case {lat, lon} do
{nil, nil} ->
data_extended = get_change(changeset, :data_extended)
if data_extended do
{data_extended.latitude, data_extended.longitude}
else
{nil, nil}
end
coords ->
coords
end
if is_valid_coordinates?(lat, lon) do
location = Aprs.GeometryType.create_point(lat, lon)
put_change(changeset, :location, location)
else
changeset
end
end
defp maybe_set_has_position(changeset) do
location = get_field(changeset, :location) || get_change(changeset, :location)
if location do
put_change(changeset, :has_position, true)
else
# Check legacy lat/lon fields
lat = get_field(changeset, :lat) || get_change(changeset, :lat)
lon = get_field(changeset, :lon) || get_change(changeset, :lon)
if is_valid_coordinates?(lat, lon) do
put_change(changeset, :has_position, true)
else
changeset
end
end
end
defp is_valid_coordinates?(lat, lon) do
is_number(lat) && is_number(lon) &&
lat >= -90 && lat <= 90 &&
lon >= -180 && lon <= 180
end
# Convert atom data_type to string for storage
defp normalize_data_type(%{data_type: data_type} = attrs) when is_atom(data_type) do
%{attrs | data_type: to_string(data_type)}
end
defp normalize_data_type(%{"data_type" => data_type} = attrs) when is_atom(data_type) do
%{attrs | "data_type" => to_string(data_type)}
end
# Handle :data_type key access format
defp normalize_data_type(attrs) when is_map(attrs) do
if Map.has_key?(attrs, :data_type) and is_atom(attrs.data_type) do
%{attrs | data_type: to_string(attrs.data_type)}
else
attrs
end
end
defp normalize_data_type(attrs), do: attrs
@doc """
Extracts additional data from the parsed packet's data_extended field
and merges it with the packet attributes for storage.
"""
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)
# 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)
%{} when is_map(data_extended) ->
extract_from_map(data_extended)
_ ->
%{}
end
Map.merge(base_attrs, additional_data)
end
# Extract data from standard map-based data_extended
defp extract_from_map(data_extended) do
%{}
|> maybe_put(:symbol_code, data_extended[:symbol_code] || data_extended["symbol_code"])
|> maybe_put(:symbol_table_id, data_extended[:symbol_table_id] || data_extended["symbol_table_id"])
|> maybe_put(:comment, data_extended[:comment] || data_extended["comment"])
|> maybe_put(:timestamp, data_extended[:timestamp] || data_extended["timestamp"])
|> maybe_put(:aprs_messaging, data_extended[:aprs_messaging?] || data_extended["aprs_messaging?"])
|> maybe_put(:temperature, data_extended[:temperature] || data_extended["temperature"])
|> maybe_put(:humidity, data_extended[:humidity] || data_extended["humidity"])
|> maybe_put(:wind_speed, data_extended[:wind_speed] || data_extended["wind_speed"])
|> maybe_put(:wind_direction, data_extended[:wind_direction] || data_extended["wind_direction"])
|> maybe_put(:wind_gust, data_extended[:wind_gust] || data_extended["wind_gust"])
|> maybe_put(:pressure, data_extended[:pressure] || data_extended["pressure"])
|> maybe_put(:rain_1h, data_extended[:rain_1h] || data_extended["rain_1h"])
|> maybe_put(:rain_24h, data_extended[:rain_24h] || data_extended["rain_24h"])
|> maybe_put(:rain_since_midnight, data_extended[:rain_since_midnight] || data_extended["rain_since_midnight"])
|> maybe_put(:manufacturer, data_extended[:manufacturer] || data_extended["manufacturer"])
|> maybe_put(:equipment_type, data_extended[:equipment_type] || data_extended["equipment_type"])
|> maybe_put(:course, data_extended[:course] || data_extended["course"])
|> maybe_put(:speed, data_extended[:speed] || data_extended["speed"])
|> maybe_put(:altitude, data_extended[:altitude] || data_extended["altitude"])
|> 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"])
|> extract_weather_data(data_extended)
end
# Extract data from MicE packets
defp extract_from_mic_e(mic_e) do
%{}
|> maybe_put(:comment, mic_e.message)
|> maybe_put(:manufacturer, mic_e.manufacturer)
|> maybe_put(:equipment_type, mic_e.equipment_type)
|> maybe_put(:course, mic_e.course)
|> maybe_put(:speed, mic_e.speed)
# Default car symbol for MicE
|> maybe_put(:symbol_code, ">")
# Primary table
|> maybe_put(: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(:comment, mic_e_map[:message])
|> maybe_put(:manufacturer, mic_e_map[:manufacturer])
|> maybe_put(:equipment_type, mic_e_map[:equipment_type])
|> maybe_put(:course, mic_e_map[:heading])
|> maybe_put(:speed, mic_e_map[:speed])
# Default car symbol for MicE
|> maybe_put(:symbol_code, ">")
# Primary table
|> maybe_put(:symbol_table_id, "/")
end
# Extract weather data from various formats
defp extract_weather_data(attrs, data_extended) do
# Look for weather report in different possible locations
weather_data =
data_extended[:weather] || data_extended["weather"] ||
data_extended[:weather_report] || data_extended["weather_report"]
case weather_data do
weather when is_binary(weather) ->
parse_weather_string(attrs, weather)
weather when is_map(weather) ->
Map.merge(attrs, weather)
_ ->
attrs
end
end
# Parse weather data from string format (basic implementation)
defp parse_weather_string(attrs, weather_string) do
# This is a simplified parser - a full implementation would handle
# the complete APRS weather format specification
attrs
|> maybe_extract_weather_field(weather_string, ~r/(\d{3})\/(\d{3})/, [:wind_direction, :wind_speed])
|> maybe_extract_weather_field(weather_string, ~r/t(\d{3})/, [:temperature])
|> maybe_extract_weather_field(weather_string, ~r/h(\d{2})/, [:humidity])
|> maybe_extract_weather_field(weather_string, ~r/b(\d{5})/, [:pressure])
end
# Helper to extract weather fields using regex
defp maybe_extract_weather_field(attrs, weather_string, regex, keys) do
case Regex.run(regex, weather_string) do
[_full | matches] ->
keys
|> Enum.zip(matches)
|> Enum.reduce(attrs, fn {key, value}, acc ->
case Integer.parse(value) do
{int_val, _} -> Map.put(acc, key, int_val)
:error -> acc
end
end)
_ ->
attrs
end
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 """
# Spatial query functions for efficient location-based searches
# """
# Temporarily commented out until PostGIS is properly configured
# @doc """
# Find packets within a given radius (in meters) of a point.
# """
# def within_radius(query \\ __MODULE__, lat, lon, radius_meters) do
# point = %Geo.Point{coordinates: {lon, lat}, srid: 4326}
# from p in query,
# where: st_dwithin_in_meters(p.location, ^point, ^radius_meters),
# where: not is_nil(p.location)
# end
# @doc """
# Find packets within a bounding box defined by southwest and northeast corners.
# """
# def within_bbox(query \\ __MODULE__, sw_lat, sw_lon, ne_lat, ne_lon) do
# # Create a polygon representing the bounding box
# bbox = %Geo.Polygon{
# coordinates: [[
# {sw_lon, sw_lat},
# {ne_lon, sw_lat},
# {ne_lon, ne_lat},
# {sw_lon, ne_lat},
# {sw_lon, sw_lat}
# ]],
# srid: 4326
# }
# from p in query,
# where: st_within(p.location, ^bbox),
# where: not is_nil(p.location)
# end
# @doc """
# Find packets ordered by distance from a given point.
# """
# def nearest_to(query \\ __MODULE__, lat, lon, limit \\ 100) do
# point = %Geo.Point{coordinates: {lon, lat}, srid: 4326}
# from p in query,
# where: not is_nil(p.location),
# order_by: st_distance(p.location, ^point),
# limit: ^limit,
# select: %{p | distance: st_distance_in_meters(p.location, ^point)}
# end
# @doc """
# Find recent packets with location data within the last N hours.
# """
# def recent_with_location(query \\ __MODULE__, hours_back \\ 24) do
# cutoff_time = DateTime.utc_now() |> DateTime.add(-hours_back, :hour)
# from p in query,
# where: p.has_position == true,
# where: not is_nil(p.location),
# where: p.received_at > ^cutoff_time,
# order_by: [desc: p.received_at]
# end
# @doc """
# Get statistics for packets in a geographic area.
# """
# def location_stats(query \\ __MODULE__, lat, lon, radius_meters) do
# point = %Geo.Point{coordinates: {lon, lat}, srid: 4326}
# from p in query,
# where: st_dwithin_in_meters(p.location, ^point, ^radius_meters),
# where: not is_nil(p.location),
# group_by: p.base_callsign,
# select: %{
# callsign: p.base_callsign,
# packet_count: count(p.id),
# latest_position: max(p.received_at),
# avg_lat: avg(fragment("ST_Y(?)", p.location)),
# avg_lon: avg(fragment("ST_X(?)", p.location))
# }
# end
@doc """
Create a geometry point from lat/lon coordinates.
"""
def create_point(lat, lon), do: Aprs.GeometryType.create_point(lat, lon)
@doc """
Extract lat/lon from a PostGIS geometry point.
"""
def extract_coordinates(geometry), do: Aprs.GeometryType.extract_coordinates(geometry)
@doc """
Get latitude from a packet's location geometry.
"""
def lat(%__MODULE__{location: %Geo.Point{coordinates: {_lon, lat}}}), do: lat
def lat(_), do: nil
@doc """
Get longitude from a packet's location geometry.
"""
def lon(%__MODULE__{location: %Geo.Point{coordinates: {lon, _lat}}}), do: lon
def lon(_), do: nil
# @doc """
# Calculate distance between two packets in meters.
# """
# def distance_between(%__MODULE__{location: %Geo.Point{} = p1}, %__MODULE__{location: %Geo.Point{} = p2}) do
# Repo.one(
# from p in "packets",
# select: fragment("ST_Distance_Sphere(?, ?)", ^p1, ^p2),
# limit: 1
# )
# end
# def distance_between(_, _), do: nil
end