defmodule Aprsme.Packets do @moduledoc """ The Packets context. """ @behaviour Aprsme.PacketsBehaviour import Ecto.Query, warn: false alias Aprs.Types.MicE alias Aprsme.BadPacket alias Aprsme.Packet alias Aprsme.Repo @doc """ Stores a packet in the database. ## Parameters * `packet_data` - Map containing packet data to be stored """ @spec store_packet(map()) :: {:ok, struct()} | {:error, :validation_error | :storage_exception} def store_packet(packet_data) do require Logger try do packet_attrs = packet_data |> Aprsme.Packet.extract_additional_data(packet_data[:raw_packet] || packet_data["raw_packet"] || "") |> normalize_packet_attrs() |> set_received_at() |> patch_lat_lon_from_data_extended() |> then(fn attrs -> {lat, lon} = extract_position(attrs) set_lat_lon(attrs, lat, lon) end) |> normalize_ssid() |> then(fn attrs -> device_identifier = Aprsme.DeviceParser.extract_device_identifier(packet_data) matched_device = Aprsme.DeviceIdentification.lookup_device_by_identifier(device_identifier) canonical_identifier = if matched_device, do: matched_device.identifier, else: device_identifier Map.put(attrs, :device_identifier, canonical_identifier) end) |> sanitize_packet_strings() # require Logger # Logger.debug("Sanitized packet_attrs before insert: #{inspect(packet_attrs)}") packet_attrs = Map.new(packet_attrs, fn {k, v} -> {k, sanitize_packet_strings(v)} end) # Set device_identifier to parsed value, fallback to destination if nil parsed_device_id = Aprsme.DeviceParser.extract_device_identifier(packet_data) device_id = parsed_device_id || Map.get(packet_attrs, :destination) packet_attrs = Map.put(packet_attrs, :device_identifier, device_id) # Logger.debug("Inserting packet with device_identifier=#{inspect(device_id)}, destination=#{inspect(Map.get(packet_attrs, :destination))}") insert_packet(packet_attrs, packet_data) rescue error -> Logger.error("Exception in store_packet for #{inspect(packet_data[:sender])}: #{inspect(error)}") store_bad_packet(packet_data, error) {:error, :storage_exception} end end defp normalize_packet_attrs(packet_data) do case_result = case packet_data do %Packet{} = packet -> packet |> Map.from_struct() |> Map.delete(:__meta__) %{} -> packet_data end case_result |> sanitize_packet_strings() |> normalize_data_type() end defp normalize_data_type(attrs) do Aprsme.EncodingUtils.normalize_data_type(attrs) end defp set_received_at(attrs) do current_time = DateTime.truncate(DateTime.utc_now(), :microsecond) Map.put(attrs, :received_at, current_time) end defp patch_lat_lon_from_data_extended(attrs) do case attrs[:data_extended] do %{} = ext -> ext_map = if Map.has_key?(ext, :__struct__), do: Map.from_struct(ext), else: ext lat = extract_lat_from_ext_map(ext_map) lon = extract_lon_from_ext_map(ext_map) latd = to_decimal(lat) lond = to_decimal(lon) if not is_nil(latd) and not is_nil(lond) do attrs |> Map.put(:lat, latd) |> Map.put(:lon, lond) else attrs end _ -> attrs end end defp extract_lat_from_ext_map(ext_map) when is_map(ext_map) and not is_struct(ext_map) do ext_map[:latitude] || ext_map["latitude"] || (Map.has_key?(ext_map, :position) && (ext_map[:position][:latitude] || ext_map[:position]["latitude"])) || (Map.has_key?(ext_map, "position") && (ext_map["position"][:latitude] || ext_map["position"]["latitude"])) end defp extract_lat_from_ext_map(_), do: nil defp extract_lon_from_ext_map(ext_map) when is_map(ext_map) and not is_struct(ext_map) do ext_map[:longitude] || ext_map["longitude"] || (Map.has_key?(ext_map, :position) && (ext_map[:position][:longitude] || ext_map[:position]["longitude"])) || (Map.has_key?(ext_map, "position") && (ext_map["position"][:longitude] || ext_map["position"]["longitude"])) end defp extract_lon_from_ext_map(_), do: nil defp set_lat_lon(attrs, lat, lon) do round6 = fn nil -> nil %Decimal{} = d -> Decimal.round(d, 6) n when is_float(n) -> Float.round(n, 6) n when is_integer(n) -> n * 1.0 n when is_binary(n) -> case Float.parse(n) do {f, _} -> Float.round(f, 6) :error -> nil end _ -> nil end attrs |> Map.put(:lat, round6.(lat)) |> Map.put(:lon, round6.(lon)) end defp normalize_ssid(attrs) do case Map.get(attrs, :ssid) do nil -> attrs ssid -> Map.put(attrs, :ssid, to_string(ssid)) end end defp insert_packet(attrs, packet_data) do case %Packet{} |> Packet.changeset(attrs) |> Repo.insert() do {:ok, packet} -> # Invalidate cache for this packet's callsign if Map.has_key?(attrs, :sender) do Aprsme.CachedQueries.invalidate_callsign_cache(attrs.sender) end {:ok, packet} {:error, changeset} -> error_message = Enum.map_join(changeset.errors, ", ", fn {field, {msg, _}} -> "#{field}: #{msg}" end) store_bad_packet(packet_data, %{message: error_message, type: "ValidationError"}) {:error, :validation_error} end end @doc """ Stores a bad packet in the database. ## Parameters * `packet_data` - The original packet data that failed to parse * `error` - The error that occurred during parsing """ @spec store_bad_packet(map() | String.t(), any()) :: {:ok, struct()} | {:error, Ecto.Changeset.t()} def store_bad_packet(packet_data, error) when is_binary(packet_data) do error_type = case error do %{type: type} -> type %{__struct__: struct} -> struct _ -> "UnknownError" end error_message = case error do %{message: message} -> message %{__struct__: _} -> Exception.message(error) _ -> inspect(error) end %BadPacket{} |> BadPacket.changeset(%{ raw_packet: Aprsme.EncodingUtils.sanitize_string(packet_data), error_message: error_message, error_type: error_type, attempted_at: DateTime.utc_now() }) |> Repo.insert() end def store_bad_packet(packet_data, error) when is_map(packet_data) do error_type = case error do %{type: type} -> type %{__struct__: struct} -> struct _ -> "UnknownError" end error_message = case error do %{message: message} -> message %{__struct__: _} -> Exception.message(error) _ -> inspect(error) end %BadPacket{} |> BadPacket.changeset(%{ raw_packet: packet_data[:raw_packet] || packet_data["raw_packet"] || inspect(packet_data), error_message: error_message, error_type: error_type, attempted_at: DateTime.utc_now() }) |> Repo.insert() end # Extracts position data from packet, checking various possible locations defp extract_position(packet_data) do # Check for lat/lon at top level if not is_nil(packet_data[:lat]) and not is_nil(packet_data[:lon]) do {to_float(packet_data.lat), to_float(packet_data.lon)} else extract_position_from_data_extended(packet_data[:data_extended]) end end defp extract_position_from_data_extended(nil), do: {nil, nil} defp extract_position_from_data_extended(data_extended) when is_map(data_extended) do if has_standard_position?(data_extended) do extract_standard_position(data_extended) else extract_position_from_data_extended_case(data_extended) end end defp extract_position_from_data_extended(_), do: {nil, nil} defp has_standard_position?(data_extended) when is_map(data_extended) and not is_struct(data_extended) do not is_nil(data_extended[:latitude]) and not is_nil(data_extended[:longitude]) end defp has_standard_position?(_), do: false defp extract_standard_position(data_extended) when is_map(data_extended) and not is_struct(data_extended) do {to_float(data_extended[:latitude]), to_float(data_extended[:longitude])} end defp extract_standard_position(_), do: {nil, nil} defp extract_position_from_data_extended_case(data_extended) do case data_extended do %{__struct__: MicE} = mic_e -> extract_position_from_mic_e_struct(mic_e) %{__struct__: Aprs.Types.ParseError} -> # ParseError structs don't contain position data and don't implement Access behavior {nil, nil} _ -> extract_position_from_mic_e(data_extended) end end defp extract_position_from_mic_e_struct(%{__struct__: MicE} = mic_e) do # Use Access behavior for MicE struct which implements it lat = mic_e[:latitude] lon = mic_e[:longitude] if is_number(lat) and is_number(lon) do {lat, lon} else {nil, nil} end end defp extract_position_from_mic_e(data_extended) do if is_number(data_extended[:lat_degrees]) and is_number(data_extended[:lat_minutes]) and is_number(data_extended[:lon_degrees]) and is_number(data_extended[:lon_minutes]) do lat = data_extended[:lat_degrees] + data_extended[:lat_minutes] / 60.0 lat = if data_extended[:lat_direction] == :south, do: -lat, else: lat lon = data_extended[:lon_degrees] + data_extended[:lon_minutes] / 60.0 lon = if data_extended[:lon_direction] == :west, do: -lon, else: lon {lat, lon} else {nil, nil} end end @doc """ Gets packets for replay. ## Parameters * `opts` - Map of options for filtering and pagination: * `:lat` - Latitude for center point filtering * `:lon` - Longitude for center point filtering * `:radius` - Radius in kilometers for filtering * `:callsign` - Filter by callsign * `:region` - Filter by region * `:start_time` - Start time for replay (DateTime) * `:end_time` - End time for replay (DateTime) * `:limit` - Maximum number of packets to return * `:page` - Page number for pagination """ @impl true def get_packets_for_replay(opts \\ %{}) do base_query = from(p in Packet, order_by: [asc: p.received_at], where: p.has_position == true) query = base_query |> filter_by_time(opts) |> filter_by_region(opts) |> filter_by_callsign(opts) |> filter_by_map_bounds(opts) |> limit_results(opts) |> select_with_virtual_coordinates() Repo.all(query) end @doc """ Gets historical packet count for a map area. """ @impl true @spec get_historical_packet_count(map()) :: non_neg_integer() def get_historical_packet_count(opts \\ %{}) do base_query = from(p in Packet, select: count(p.id), where: p.has_position == true) query = base_query |> filter_by_time(opts) |> filter_by_region(opts) |> filter_by_callsign(opts) |> filter_by_map_bounds(opts) Repo.one(query) end # Adds a select clause to populate virtual lat/lon fields from PostGIS geometry. defp select_with_virtual_coordinates(query) do from p in query, select: %{p | lat: fragment("ST_Y(?)", p.location), lon: fragment("ST_X(?)", p.location)} end # Query building helpers # Handle both start_time and end_time defp filter_by_time(query, %{start_time: start_time, end_time: end_time}) do from p in query, where: p.received_at >= ^start_time and p.received_at <= ^end_time end # Handle only start_time defp filter_by_time(query, %{start_time: start_time}) do from p in query, where: p.received_at >= ^start_time end # Handle only end_time defp filter_by_time(query, %{end_time: end_time}) do from p in query, where: p.received_at <= ^end_time end # Default case defp filter_by_time(query, _), do: query @doc """ Gets recent packets for the map view. This is used for initial map loading to show only recent packets. """ @impl true @spec get_recent_packets(map()) :: [struct()] def get_recent_packets(opts \\ %{}) do # Always limit to the last 24 hours for more symbol variety one_hour_ago = DateTime.add(DateTime.utc_now(), -24 * 3600, :second) # Merge the one-hour limit with any other filters opts_with_time = Map.put(opts, :start_time, one_hour_ago) get_packets_for_replay(opts_with_time) end @doc """ Gets recent packets optimized for initial map load. This uses a more efficient query pattern for the most common use case. """ @spec get_recent_packets_optimized(map()) :: [struct()] def get_recent_packets_optimized(opts \\ %{}) do # Always limit to the last 24 hours for more symbol variety one_hour_ago = DateTime.add(DateTime.utc_now(), -24 * 3600, :second) limit = Map.get(opts, :limit, 200) offset = Map.get(opts, :offset, 0) # Build base query with time and position filters base_query = from(p in Packet, where: p.has_position == true, where: p.received_at >= ^one_hour_ago ) # Apply spatial and other filters BEFORE limiting # This ensures we get the most recent packets within the specified bounds query = base_query |> filter_by_region(opts) |> filter_by_callsign(opts) |> filter_by_map_bounds(opts) |> order_by(desc: :received_at) |> limit(^limit) |> offset(^offset) |> select_with_virtual_coordinates() Repo.all(query) end @doc """ Gets weather packets for a specific callsign within a time range. This is optimized for weather queries by filtering at the database level. """ @impl true @spec get_weather_packets(String.t(), DateTime.t(), DateTime.t(), map()) :: [struct()] def get_weather_packets(callsign, start_time, end_time, opts \\ %{}) do limit = Map.get(opts, :limit, 500) base_query = from(p in Packet, order_by: [desc: p.received_at]) query = base_query |> filter_by_time(%{start_time: start_time, end_time: end_time}) |> filter_by_callsign(%{callsign: callsign}) |> filter_weather_packets() |> limit_results(%{limit: limit}) |> select_with_virtual_coordinates() Repo.all(query) end # Filter for weather packets at the database level defp filter_weather_packets(query) do from p in query, where: p.data_type == "weather" or (p.symbol_table_id == "/" and p.symbol_code == "_") end @doc """ Retrieves a continuous stream of stored packets for replay in chronological order. This function returns a Stream that can be used to process packets in chronological order, preserving the timing between packets. ## Parameters * `opts` - The same options as `get_packets_for_replay/1` * `:playback_speed` - Speed multiplier (1.0 = real-time, 2.0 = 2x speed, etc.) ## Returns * Stream of packets with timing information """ @impl true def stream_packets_for_replay(opts \\ %{}) do packets = get_packets_for_replay(opts) playback_speed = Map.get(opts, :playback_speed, 1.0) # Return a stream that emits packets with their original timing Stream.unfold({packets, nil}, fn {[], _} -> nil {[packet | rest], nil} -> {{0, packet}, {rest, packet}} {[next | rest], prev} -> # Calculate delay between packets in milliseconds, then convert to seconds delay_ms = DateTime.diff(next.received_at, prev.received_at, :millisecond) adjusted_delay = delay_ms / (playback_speed * 1000) {{adjusted_delay, next}, {rest, next}} end) end defp filter_by_region(query, %{region: region}) do from p in query, where: p.region == ^region end defp filter_by_region(query, _), do: query defp filter_by_callsign(query, %{callsign: callsign}) do # Use sender field for exact callsign matching # Sanitize callsign to prevent SQL injection sanitized_callsign = sanitize_callsign(callsign) from p in query, where: ilike(p.sender, ^sanitized_callsign) end defp filter_by_callsign(query, _), do: query defp filter_by_map_bounds(query, %{bounds: [min_lon, min_lat, max_lon, max_lat]}) when not is_nil(min_lon) and not is_nil(min_lat) and not is_nil(max_lon) and not is_nil(max_lat) do bbox_wkt = create_bounding_box_wkt(min_lon, min_lat, max_lon, max_lat) from p in query, where: p.has_position == true, # Use PostGIS spatial query if location is available # Fall back to lat/lon comparison if location is null where: fragment( "(? IS NOT NULL and ST_Within(?, ST_GeomFromText(?, 4326))) or (? IS NULL and ? >= ? and ? <= ? and ? >= ? and ? <= ?)", p.location, p.location, ^bbox_wkt, p.location, p.lat, ^min_lat, p.lat, ^max_lat, p.lon, ^min_lon, p.lon, ^max_lon ) end defp filter_by_map_bounds(query, _), do: query defp create_bounding_box_wkt(min_lon, min_lat, max_lon, max_lat) do # Validate and sanitize coordinates to prevent SQL injection with {:ok, min_lon} <- validate_coordinate(min_lon), {:ok, min_lat} <- validate_coordinate(min_lat), {:ok, max_lon} <- validate_coordinate(max_lon), {:ok, max_lat} <- validate_coordinate(max_lat) do "POLYGON((#{min_lon} #{min_lat}, #{max_lon} #{min_lat}, #{max_lon} #{max_lat}, #{min_lon} #{max_lat}, #{min_lon} #{min_lat}))" else _ -> raise ArgumentError, "Invalid coordinates provided" end end defp limit_results(query, %{limit: limit, page: page}) when not is_nil(limit) and not is_nil(page) do offset = (page - 1) * limit from p in query, limit: ^limit, offset: ^offset end defp limit_results(query, %{limit: limit}) when not is_nil(limit) do from p in query, limit: ^limit end defp limit_results(query, _), do: query @doc """ Gets the total count of stored packets in the database. """ @spec get_total_packet_count() :: non_neg_integer() def get_total_packet_count do # Use the new index for faster counting Repo.one(from p in Packet, select: count(p.id)) || 0 rescue DBConnection.ConnectionError -> require Logger Logger.warning("Database connection error in get_total_packet_count, returning 0") 0 error -> require Logger Logger.error("Error getting total packet count: #{inspect(error)}") 0 end @doc """ Configure packet retention policy. Packets are retained based on these rules: - Default retention is 365 days (1 year) (configurable via :packet_retention_days) - Returns the number of packets deleted """ @impl true def clean_old_packets do retention_days = Application.get_env(:aprsme, :packet_retention_days, 365) cutoff_time = DateTime.add(DateTime.utc_now(), -retention_days * 86_400, :second) {deleted_count, _} = Repo.delete_all(from(p in Packet, where: p.received_at < ^cutoff_time)) deleted_count end @doc """ Clean packets older than a specific number of days. This function allows for more granular cleanup operations by specifying the exact age threshold for packet deletion. ## Parameters - `days` - Number of days to keep (packets older than this will be deleted) ## Returns - Number of packets deleted """ @impl true @spec clean_packets_older_than(pos_integer()) :: {:ok, non_neg_integer()} | {:error, any()} def clean_packets_older_than(days) when is_integer(days) and days > 0 do cutoff_time = DateTime.add(DateTime.utc_now(), -days * 86_400, :second) {deleted_count, _} = Repo.delete_all(from(p in Packet, where: p.received_at < ^cutoff_time)) {:ok, deleted_count} end # Helper to convert various types to float defp to_float(value), do: Aprsme.EncodingUtils.to_float(value) # Helper to convert various types to Decimal defp to_decimal(value), do: Aprsme.EncodingUtils.to_decimal(value) # Helper to sanitize all string fields in packet data before database storage defp sanitize_packet_strings(value), do: Aprsme.EncodingUtils.sanitize_packet_strings(value) # Get packets from last hour only - used to initialize the map @spec get_last_hour_packets() :: [struct()] def get_last_hour_packets do one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second) Packet |> where([p], p.has_position == true) |> where([p], p.received_at >= ^one_hour_ago) |> order_by([p], asc: p.received_at) |> limit(500) |> select_with_virtual_coordinates() |> Repo.all() end # @doc """ # Spatial query functions for efficient location-based searches using PostGIS # """ # Temporarily commented out PostGIS functions until PostGIS is properly configured # @doc """ # Find packets within a given radius (in meters) of a point. # Uses PostGIS spatial indexes for efficient querying. # """ # def find_packets_within_radius(lat, lon, radius_meters, opts \\ %{}) do # point = %Geo.Point{coordinates: {lon, lat}, srid: 4326} # base_query = # Packet # |> where([p], not is_nil(p.location)) # |> where([p], st_dwithin_in_meters(p.location, ^point, ^radius_meters)) # |> order_by([p], st_distance(p.location, ^point)) # base_query # |> apply_common_filters(opts) # |> maybe_limit(opts) # |> Repo.all() # end # Additional PostGIS functions commented out for now... # Helper functions for spatial queries - commented out for now # defp apply_common_filters(query, opts) do # query # |> filter_by_time_range(opts) # |> filter_by_callsign(opts) # |> filter_by_data_type(opts) # end # defp filter_by_time_range(query, %{start_time: start_time, end_time: end_time}) do # from p in query, # where: p.received_at >= ^start_time and p.received_at <= ^end_time # end # defp filter_by_time_range(query, %{start_time: start_time}) do # from p in query, where: p.received_at >= ^start_time # end # defp filter_by_time_range(query, %{end_time: end_time}) do # from p in query, where: p.received_at <= ^end_time # end # defp filter_by_time_range(query, %{hours_back: hours}) do # cutoff_time = DateTime.utc_now() |> DateTime.add(-hours, :hour) # from p in query, where: p.received_at >= ^cutoff_time # end # defp filter_by_time_range(query, _), do: query # defp filter_by_data_type(query, %{data_type: data_type}) do # from p in query, where: p.data_type == ^data_type # end # defp filter_by_data_type(query, _), do: query # defp maybe_limit(query, %{limit: limit}) when is_integer(limit) and limit > 0 do # from p in query, limit: ^limit # end # defp maybe_limit(query, _), do: query # Calculate clustering distance based on zoom level # Higher zoom levels need smaller clustering distances # defp calculate_cluster_distance(zoom_level) when zoom_level >= 15, do: 100 # 100m # defp calculate_cluster_distance(zoom_level) when zoom_level >= 12, do: 500 # 500m # defp calculate_cluster_distance(zoom_level) when zoom_level >= 9, do: 2000 # 2km # defp calculate_cluster_distance(zoom_level) when zoom_level >= 6, do: 10000 # 10km # defp calculate_cluster_distance(_), do: 50000 # 50km @doc """ Gets the most recent packet for a callsign regardless of type or age. This is used for API endpoints that need the latest packet from a source. """ @spec get_latest_packet_for_callsign(String.t()) :: struct() | nil def get_latest_packet_for_callsign(callsign) when is_binary(callsign) do sanitized_callsign = sanitize_callsign(callsign) from(p in Packet, where: ilike(p.sender, ^sanitized_callsign), order_by: [desc: p.received_at], limit: 1 ) |> select_with_virtual_coordinates() |> Repo.one() end # Sanitization functions to prevent SQL injection defp sanitize_callsign(callsign) when is_binary(callsign) do # APRS callsigns are alphanumeric with hyphens and up to 9 characters # Remove any characters that could be used for SQL injection callsign |> String.upcase() |> String.replace(~r/[^A-Z0-9\-]/, "") |> String.slice(0, 9) end defp sanitize_callsign(_), do: "" defp validate_coordinate(coord) when is_number(coord) do # Validate coordinate ranges if coord >= -180 and coord <= 180 do {:ok, coord} else {:error, :invalid_coordinate} end end defp validate_coordinate(coord) when is_binary(coord) do case Float.parse(coord) do {float_val, ""} -> validate_coordinate(float_val) _ -> {:error, :invalid_coordinate} end end defp validate_coordinate(_), do: {:error, :invalid_coordinate} end