refactor and performance improvements

This commit is contained in:
Graham McIntire 2025-07-10 10:53:34 -05:00
parent 79c0a0a43a
commit 48447ab42e
No known key found for this signature in database
26 changed files with 1323 additions and 656 deletions

View file

@ -3,7 +3,9 @@ defmodule Aprsme.CachedQueries do
Caching layer for database queries to improve performance Caching layer for database queries to improve performance
""" """
alias Aprsme.Packet
alias Aprsme.Packets alias Aprsme.Packets
alias Aprsme.Repo
# 2 minutes for frequently changing data # 2 minutes for frequently changing data
@cache_ttl_short to_timeout(minute: 2) @cache_ttl_short to_timeout(minute: 2)
@ -79,6 +81,54 @@ defmodule Aprsme.CachedQueries do
end end
end end
@doc """
Get latest weather packet for callsign with caching.
Uses the optimized query that checks recent data first.
"""
def get_latest_weather_packet_cached(callsign) do
cache_key = generate_cache_key("latest_weather_packet", callsign)
case Cachex.get(:query_cache, cache_key) do
{:ok, result} when not is_nil(result) ->
result
_ ->
result = Packets.get_latest_weather_packet_optimized(callsign)
# Cache for 5 minutes since weather updates are less frequent
Cachex.put(:query_cache, cache_key, result, ttl: @cache_ttl_short)
result
end
end
@doc """
Check if a callsign has weather packets with caching.
Uses exact match for performance.
"""
def has_weather_packets_cached?(callsign) do
cache_key = generate_cache_key("has_weather_packets", callsign)
case Cachex.get(:query_cache, cache_key) do
{:ok, result} when not is_nil(result) ->
result
_ ->
# Use exact match with proper index instead of ilike
import Ecto.Query
query =
from p in Packet,
where: p.sender == ^callsign,
where: p.data_type == "weather" or (p.symbol_table_id == "/" and p.symbol_code == "_"),
limit: 1,
select: true
result = Repo.exists?(query)
# Cache for 15 minutes
Cachex.put(:query_cache, cache_key, result, ttl: @cache_ttl_medium)
result
end
end
@doc """ @doc """
Invalidate cache entries for a specific callsign Invalidate cache entries for a specific callsign
""" """
@ -86,6 +136,8 @@ defmodule Aprsme.CachedQueries do
# Pattern-based cache invalidation # Pattern-based cache invalidation
patterns = [ patterns = [
"latest_packet:#{callsign}", "latest_packet:#{callsign}",
"latest_weather_packet:#{callsign}",
"has_weather_packets:#{callsign}",
"weather:#{callsign}:*" "weather:#{callsign}:*"
] ]

133
lib/aprsme/callsign.ex Normal file
View file

@ -0,0 +1,133 @@
defmodule Aprsme.Callsign do
@moduledoc """
Utilities for callsign normalization, validation, and manipulation.
"""
@doc """
Normalizes a callsign by trimming whitespace and converting to uppercase.
"""
@spec normalize(String.t() | nil) :: String.t()
def normalize(nil), do: ""
def normalize(callsign) when is_binary(callsign) do
callsign
|> String.trim()
|> String.upcase()
end
def normalize(_), do: ""
@doc """
Validates if a callsign format is reasonable for amateur radio use.
## Examples
iex> Aprsme.Callsign.valid?("W5ABC")
true
iex> Aprsme.Callsign.valid?("W5ABC-15")
true
iex> Aprsme.Callsign.valid?("")
false
iex> Aprsme.Callsign.valid?("A")
false
"""
@spec valid?(String.t() | nil) :: boolean()
def valid?(nil), do: false
def valid?(callsign) when is_binary(callsign) do
trimmed = String.trim(callsign)
cond do
trimmed == "" -> false
byte_size(trimmed) < 3 -> false
byte_size(trimmed) > 15 -> false
true -> Regex.match?(~r/^[A-Z0-9]+(-[A-Z0-9]{1,2})?$/i, trimmed)
end
end
def valid?(_), do: false
@doc """
Checks if a packet's sender matches the target callsign.
Both callsigns are normalized before comparison.
"""
@spec matches?(String.t() | nil, String.t() | nil) :: boolean()
def matches?(packet_callsign, target_callsign) do
normalize(packet_callsign) == normalize(target_callsign)
end
@doc """
Extracts the base callsign from a full callsign (removes SSID if present).
## Examples
iex> Aprsme.Callsign.extract_base("W5ABC-15")
"W5ABC"
iex> Aprsme.Callsign.extract_base("W5ABC")
"W5ABC"
"""
@spec extract_base(String.t() | nil) :: String.t()
def extract_base(nil), do: ""
def extract_base(callsign) when is_binary(callsign) do
case String.split(callsign, "-") do
[base, _ssid] -> base
[base] -> base
_ -> ""
end
end
def extract_base(_), do: ""
@doc """
Extracts the SSID from a callsign, returning "0" if no SSID is present.
## Examples
iex> Aprsme.Callsign.extract_ssid("W5ABC-15")
"15"
iex> Aprsme.Callsign.extract_ssid("W5ABC")
"0"
"""
@spec extract_ssid(String.t() | nil) :: String.t()
def extract_ssid(nil), do: "0"
def extract_ssid(callsign) when is_binary(callsign) do
case String.split(callsign, "-") do
[_base, ssid] -> ssid
[_base] -> "0"
_ -> "0"
end
end
def extract_ssid(_), do: "0"
@doc """
Extracts both base callsign and SSID as a tuple.
## Examples
iex> Aprsme.Callsign.extract_parts("W5ABC-15")
{"W5ABC", "15"}
iex> Aprsme.Callsign.extract_parts("W5ABC")
{"W5ABC", "0"}
"""
@spec extract_parts(String.t() | nil) :: {String.t(), String.t()}
def extract_parts(nil), do: {"", "0"}
def extract_parts(callsign) when is_binary(callsign) do
case String.split(callsign, "-") do
[base, ssid] -> {base, ssid}
[base] -> {base, "0"}
_ -> {"", "0"}
end
end
def extract_parts(_), do: {"", "0"}
end

View file

@ -26,18 +26,56 @@ defmodule Aprsme.EncodingUtils do
""" """
@spec sanitize_string(binary() | nil | any()) :: binary() | nil | any() @spec sanitize_string(binary() | nil | any()) :: binary() | nil | any()
def sanitize_string(binary) when is_binary(binary) do def sanitize_string(binary) when is_binary(binary) do
# First, handle the encoding conversion
cleaned = cleaned =
if String.valid?(binary) do if String.valid?(binary) do
binary binary
else else
:unicode.characters_to_binary(binary, :latin1, :utf8) case :unicode.characters_to_binary(binary, :latin1, :utf8) do
{:error, _, _} ->
# If conversion fails, try to extract valid parts
binary
|> :binary.bin_to_list()
|> Enum.filter(fn byte -> byte >= 32 and byte <= 126 end)
|> :binary.list_to_bin()
{:incomplete, partial, _} ->
partial
result when is_binary(result) ->
result
end
end end
# Remove control characters including null bytes
# We filter at the codepoint level to handle all Unicode control characters
cleaned cleaned
|> String.replace(<<0>>, "") |> String.codepoints()
|> String.replace(~r/[\x01-\x08\x0B\x0C\x0E-\x1F\x7F]/, "") |> Enum.filter(fn cp ->
|> String.graphemes() case :unicode.characters_to_list(cp) do
|> Enum.filter(&String.valid?/1) [codepoint] ->
# Allow printable characters and common whitespace
# Remove C0 controls (0x00-0x1F except tab, newline, carriage return)
# Remove C1 controls (0x80-0x9F)
# Remove DEL (0x7F)
cond do
# Tab
codepoint == 0x09 -> true
# Newline
codepoint == 0x0A -> true
# Carriage return
codepoint == 0x0D -> true
codepoint >= 0x00 and codepoint <= 0x1F -> false
codepoint == 0x7F -> false
codepoint >= 0x80 and codepoint <= 0x9F -> false
true -> true
end
_ ->
# Multi-codepoint grapheme, keep it
true
end
end)
|> Enum.join() |> Enum.join()
|> String.trim() |> String.trim()
end end
@ -119,7 +157,7 @@ defmodule Aprsme.EncodingUtils do
iex> Aprsme.EncodingUtils.sanitize_packet_strings(["abc", <<255>>]) iex> Aprsme.EncodingUtils.sanitize_packet_strings(["abc", <<255>>])
["abc", "ÿ"] ["abc", "ÿ"]
iex> Aprsme.EncodingUtils.sanitize_packet_strings(%{"foo" => <<0, 65, 66, 67>>}) iex> Aprsme.EncodingUtils.sanitize_packet_strings(%{"foo" => <<0, 65, 66, 67>>})
%{"foo" => "\0ABC"} %{"foo" => "ABC"}
iex> Aprsme.EncodingUtils.sanitize_packet_strings(nil) iex> Aprsme.EncodingUtils.sanitize_packet_strings(nil)
nil nil
""" """
@ -129,6 +167,12 @@ defmodule Aprsme.EncodingUtils do
def sanitize_packet_strings(%_struct{} = struct), do: struct |> Map.from_struct() |> sanitize_packet_strings() def sanitize_packet_strings(%_struct{} = struct), do: struct |> Map.from_struct() |> sanitize_packet_strings()
def sanitize_packet_strings(list) when is_list(list), do: Enum.map(list, &sanitize_packet_strings/1) def sanitize_packet_strings(list) when is_list(list), do: Enum.map(list, &sanitize_packet_strings/1)
def sanitize_packet_strings(map) when is_map(map) do
Enum.reduce(map, %{}, fn {key, value}, acc ->
Map.put(acc, key, sanitize_packet_strings(value))
end)
end
def sanitize_packet_strings(binary) when is_binary(binary) do def sanitize_packet_strings(binary) when is_binary(binary) do
s = sanitize_string(binary) s = sanitize_string(binary)
if is_binary(s), do: s, else: "" if is_binary(s), do: s, else: ""
@ -206,9 +250,9 @@ defmodule Aprsme.EncodingUtils do
## Examples ## Examples
iex> result = Aprsme.EncodingUtils.sanitize_packet(%{"information_field" => <<0, 65, 66, 67>>, "data_extended" => %{"comment" => <<0, 68, 69, 70>>}}) iex> result = Aprsme.EncodingUtils.sanitize_packet(%{"information_field" => <<0, 65, 66, 67>>, "data_extended" => %{"comment" => <<0, 68, 69, 70>>}})
iex> result["information_field"] == "\0ABC" iex> result["information_field"] == "ABC"
true true
iex> result["data_extended"]["comment"] == "\0DEF" iex> result["data_extended"]["comment"] == "DEF"
true true
""" """
@spec sanitize_packet(struct() | map()) :: struct() | map() @spec sanitize_packet(struct() | map()) :: struct() | map()
@ -221,9 +265,56 @@ defmodule Aprsme.EncodingUtils do
end end
def sanitize_packet(packet) when is_map(packet) do def sanitize_packet(packet) when is_map(packet) do
packet # Handle all known string fields, checking for both atom and string keys
|> Map.update(:information_field, nil, &sanitize_string/1) string_fields = [
|> Map.update(:data_extended, nil, &sanitize_data_extended/1) :information_field,
:comment,
:path,
:raw_packet,
:destination,
:sender,
:base_callsign,
:ssid,
:manufacturer,
:equipment_type,
:message_text,
:addressee,
:symbol_code,
:symbol_table_id,
:dao,
:timestamp,
:device_identifier
]
# Sanitize all string fields
sanitized =
Enum.reduce(string_fields, packet, fn field, acc ->
atom_key = field
string_key = to_string(field)
cond do
Map.has_key?(acc, atom_key) ->
Map.update(acc, atom_key, nil, &sanitize_string/1)
Map.has_key?(acc, string_key) ->
Map.update(acc, string_key, nil, &sanitize_string/1)
true ->
acc
end
end)
# Handle data_extended separately
cond do
Map.has_key?(sanitized, :data_extended) ->
Map.update(sanitized, :data_extended, nil, &sanitize_data_extended/1)
Map.has_key?(sanitized, "data_extended") ->
Map.update(sanitized, "data_extended", nil, &sanitize_data_extended/1)
true ->
sanitized
end
end end
@doc """ @doc """
@ -247,7 +338,7 @@ defmodule Aprsme.EncodingUtils do
%{mic_e | message: sanitize_string(message)} %{mic_e | message: sanitize_string(message)}
end end
def sanitize_data_extended(data_extended) when is_map(data_extended) do def sanitize_data_extended(data_extended) when is_map(data_extended) and not is_struct(data_extended) do
# Handle generic maps by sanitizing all string values # Handle generic maps by sanitizing all string values
Enum.reduce(data_extended, %{}, fn {key, value}, acc -> Enum.reduce(data_extended, %{}, fn {key, value}, acc ->
sanitized_value = sanitize_map_value(value) sanitized_value = sanitize_map_value(value)

View file

@ -221,8 +221,6 @@ defmodule Aprsme.PacketConsumer do
# Remove embedded field for batch insert # Remove embedded field for batch insert
|> Map.delete(:data_extended) |> Map.delete(:data_extended)
|> normalize_numeric_types() |> normalize_numeric_types()
|> sanitize_raw_packet()
|> then(fn attrs -> Map.new(attrs, fn {k, v} -> {k, sanitize_packet_strings(v)} end) end)
|> truncate_datetimes_to_second() |> truncate_datetimes_to_second()
# Explicitly remove raw_weather_data to prevent insert_all errors # Explicitly remove raw_weather_data to prevent insert_all errors
|> Map.delete(:raw_weather_data) |> Map.delete(:raw_weather_data)
@ -442,9 +440,4 @@ defmodule Aprsme.PacketConsumer do
end end
end) end)
end end
defp sanitize_raw_packet(attrs) do
# Use the exact same sanitization approach as the original working code
sanitize_packet_strings(attrs)
end
end end

View file

@ -10,7 +10,9 @@ defmodule Aprsme.Packets do
alias Aprs.Types.MicE alias Aprs.Types.MicE
alias Aprsme.BadPacket alias Aprsme.BadPacket
alias Aprsme.Packet alias Aprsme.Packet
alias Aprsme.Packets.QueryBuilder
alias Aprsme.Repo alias Aprsme.Repo
alias AprsmeWeb.TimeUtils
@doc """ @doc """
Stores a packet in the database. Stores a packet in the database.
@ -23,9 +25,14 @@ defmodule Aprsme.Packets do
require Logger require Logger
try do try do
# First sanitize the input data
sanitized_packet_data = Aprsme.EncodingUtils.sanitize_packet(packet_data)
packet_attrs = packet_attrs =
packet_data sanitized_packet_data
|> Aprsme.Packet.extract_additional_data(packet_data[:raw_packet] || packet_data["raw_packet"] || "") |> Aprsme.Packet.extract_additional_data(
sanitized_packet_data[:raw_packet] || sanitized_packet_data["raw_packet"] || ""
)
|> normalize_packet_attrs() |> normalize_packet_attrs()
|> set_received_at() |> set_received_at()
|> patch_lat_lon_from_data_extended() |> patch_lat_lon_from_data_extended()
@ -40,11 +47,9 @@ defmodule Aprsme.Packets do
canonical_identifier = if matched_device, do: matched_device.identifier, else: device_identifier canonical_identifier = if matched_device, do: matched_device.identifier, else: device_identifier
Map.put(attrs, :device_identifier, canonical_identifier) Map.put(attrs, :device_identifier, canonical_identifier)
end) end)
|> sanitize_packet_strings()
# require Logger # require Logger
# Logger.debug("Sanitized packet_attrs before insert: #{inspect(packet_attrs)}") # 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 # Set device_identifier to parsed value, fallback to destination if nil
parsed_device_id = Aprsme.DeviceParser.extract_device_identifier(packet_data) parsed_device_id = Aprsme.DeviceParser.extract_device_identifier(packet_data)
device_id = parsed_device_id || Map.get(packet_attrs, :destination) device_id = parsed_device_id || Map.get(packet_attrs, :destination)
@ -74,7 +79,7 @@ defmodule Aprsme.Packets do
end end
case_result case_result
|> sanitize_packet_strings() |> Aprsme.EncodingUtils.sanitize_packet()
|> normalize_data_type() |> normalize_data_type()
end end
@ -166,6 +171,34 @@ defmodule Aprsme.Packets do
end end
defp insert_packet(attrs, packet_data) do defp insert_packet(attrs, packet_data) do
# Ensure data_extended is properly sanitized before insertion
attrs =
if attrs[:data_extended] do
sanitized_extended = Aprsme.EncodingUtils.sanitize_data_extended(attrs[:data_extended])
# Double-check all values are sanitized
# Only do additional sanitization for plain maps, not structs
sanitized_extended =
if is_struct(sanitized_extended) do
sanitized_extended
else
Enum.reduce(sanitized_extended, %{}, fn {k, v}, acc ->
sanitized_value = if is_binary(v), do: Aprsme.EncodingUtils.sanitize_string(v), else: v
Map.put(acc, k, sanitized_value)
end)
end
Map.put(attrs, :data_extended, sanitized_extended)
else
attrs
end
# Debug log to see what we're trying to insert
if attrs[:data_extended] do
require Logger
Logger.debug("Final data_extended before insert: #{inspect(attrs[:data_extended], binaries: :as_binaries)}")
end
case %Packet{} |> Packet.changeset(attrs) |> Repo.insert() do case %Packet{} |> Packet.changeset(attrs) |> Repo.insert() do
{:ok, packet} -> {:ok, packet} ->
# Invalidate cache for this packet's callsign # Invalidate cache for this packet's callsign
@ -335,64 +368,55 @@ defmodule Aprsme.Packets do
""" """
@impl true @impl true
def get_packets_for_replay(opts \\ %{}) do def get_packets_for_replay(opts \\ %{}) do
base_query = from(p in Packet, order_by: [asc: p.received_at], where: p.has_position == true) limit = Map.get(opts, :limit, 1000)
bounds = Map.get(opts, :bounds)
query = query =
base_query Packet
|> filter_by_time(opts) |> QueryBuilder.with_position()
|> filter_by_region(opts) |> QueryBuilder.with_time_range(opts)
|> filter_by_callsign(opts) |> QueryBuilder.maybe_filter_region(opts)
|> filter_by_map_bounds(opts) |> maybe_filter_by_callsign(opts)
|> limit_results(opts) |> maybe_filter_by_bounds(bounds)
|> select_with_virtual_coordinates() |> QueryBuilder.chronological()
|> QueryBuilder.paginate(limit)
|> QueryBuilder.with_coordinates()
Repo.all(query) Repo.all(query)
end end
defp maybe_filter_by_callsign(query, %{callsign: callsign}) when not is_nil(callsign) do
QueryBuilder.for_callsign(query, callsign)
end
defp maybe_filter_by_callsign(query, _), do: query
defp maybe_filter_by_bounds(query, bounds) when is_list(bounds) and length(bounds) == 4 do
QueryBuilder.within_bounds(query, bounds)
end
defp maybe_filter_by_bounds(query, _), do: query
@doc """ @doc """
Gets historical packet count for a map area. Gets historical packet count for a map area.
""" """
@impl true @impl true
@spec get_historical_packet_count(map()) :: non_neg_integer() @spec get_historical_packet_count(map()) :: non_neg_integer()
def get_historical_packet_count(opts \\ %{}) do def get_historical_packet_count(opts \\ %{}) do
base_query = from(p in Packet, select: count(p.id), where: p.has_position == true) bounds = Map.get(opts, :bounds)
query = query =
base_query Packet
|> filter_by_time(opts) |> QueryBuilder.with_position()
|> filter_by_region(opts) |> QueryBuilder.with_time_range(opts)
|> filter_by_callsign(opts) |> QueryBuilder.maybe_filter_region(opts)
|> filter_by_map_bounds(opts) |> maybe_filter_by_callsign(opts)
|> maybe_filter_by_bounds(bounds)
|> select(count())
Repo.one(query) Repo.one(query) || 0
end 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 """ @doc """
Gets recent packets for the map view. Gets recent packets for the map view.
This is used for initial map loading to show only recent packets. This is used for initial map loading to show only recent packets.
@ -401,22 +425,22 @@ defmodule Aprsme.Packets do
@spec get_recent_packets(map()) :: [struct()] @spec get_recent_packets(map()) :: [struct()]
def get_recent_packets(opts \\ %{}) do def get_recent_packets(opts \\ %{}) do
# Always limit to the last 24 hours for more symbol variety # Always limit to the last 24 hours for more symbol variety
one_hour_ago = DateTime.add(DateTime.utc_now(), -24 * 3600, :second) opts_with_time = Map.put(opts, :hours_back, 24)
# Merge the one-hour limit with any other filters opts_with_time
opts_with_time = Map.put(opts, :start_time, one_hour_ago) |> QueryBuilder.recent_position_packets()
|> Repo.all()
get_packets_for_replay(opts_with_time)
end end
@doc """ @doc """
Gets recent packets optimized for initial map load. Gets recent packets optimized for initial map load.
This uses a more efficient query pattern for the most common use case. This uses a more efficient query pattern for the most common use case.
""" """
@impl true
@spec get_recent_packets_optimized(map()) :: [struct()] @spec get_recent_packets_optimized(map()) :: [struct()]
def get_recent_packets_optimized(opts \\ %{}) do def get_recent_packets_optimized(opts \\ %{}) do
# Always limit to the last 24 hours for more symbol variety # Always limit to the last 24 hours for more symbol variety
one_hour_ago = DateTime.add(DateTime.utc_now(), -24 * 3600, :second) one_hour_ago = TimeUtils.one_day_ago()
limit = Map.get(opts, :limit, 200) limit = Map.get(opts, :limit, 200)
offset = Map.get(opts, :offset, 0) offset = Map.get(opts, :offset, 0)
@ -429,19 +453,68 @@ defmodule Aprsme.Packets do
# Apply spatial and other filters BEFORE limiting # Apply spatial and other filters BEFORE limiting
# This ensures we get the most recent packets within the specified bounds # This ensures we get the most recent packets within the specified bounds
bounds = Map.get(opts, :bounds)
query = query =
base_query base_query
|> filter_by_region(opts) |> QueryBuilder.maybe_filter_region(opts)
|> filter_by_callsign(opts) |> maybe_filter_by_callsign(opts)
|> filter_by_map_bounds(opts) |> maybe_filter_by_bounds(bounds)
|> order_by(desc: :received_at) |> order_by(desc: :received_at)
|> limit(^limit) |> limit(^limit)
|> offset(^offset) |> offset(^offset)
|> select_with_virtual_coordinates() |> QueryBuilder.with_coordinates()
Repo.all(query) Repo.all(query)
end end
@doc """
Gets the closest stations to a given point.
Uses PostGIS spatial indexes for efficient querying.
Returns only the most recent packet per callsign, ordered by distance.
"""
@impl true
@spec get_nearby_stations(float(), float(), String.t() | nil, map()) :: [struct()]
def get_nearby_stations(lat, lon, exclude_callsign \\ nil, opts \\ %{}) do
limit = Map.get(opts, :limit, 10)
hours_back = Map.get(opts, :hours_back, 1)
cutoff_time = TimeUtils.hours_ago(hours_back)
# Use a CTE with window function to get the most recent packet per callsign
# This approach maintains proper distance ordering while deduplicating by callsign
query = """
WITH ranked_packets AS (
SELECT p.*,
ST_Distance(p.location::geography, ST_SetSRID(ST_MakePoint($2, $1), 4326)::geography) as distance,
ROW_NUMBER() OVER (PARTITION BY p.base_callsign ORDER BY p.received_at DESC) as row_num
FROM packets p
WHERE p.has_position = true
AND p.received_at >= $3
#{if exclude_callsign, do: "AND p.sender != $4", else: ""}
)
SELECT *
FROM ranked_packets
WHERE row_num = 1
ORDER BY distance ASC
LIMIT #{if exclude_callsign, do: "$5", else: "$4"}
"""
params =
if exclude_callsign do
[lat, lon, cutoff_time, exclude_callsign, limit]
else
[lat, lon, cutoff_time, limit]
end
case Repo.query(query, params) do
{:ok, result} ->
Enum.map(result.rows, &Repo.load(Packet, {result.columns, &1}))
{:error, _} ->
[]
end
end
@doc """ @doc """
Gets weather packets for a specific callsign within a time range. Gets weather packets for a specific callsign within a time range.
This is optimized for weather queries by filtering at the database level. This is optimized for weather queries by filtering at the database level.
@ -449,26 +522,21 @@ defmodule Aprsme.Packets do
@impl true @impl true
@spec get_weather_packets(String.t(), DateTime.t(), DateTime.t(), map()) :: [struct()] @spec get_weather_packets(String.t(), DateTime.t(), DateTime.t(), map()) :: [struct()]
def get_weather_packets(callsign, start_time, end_time, opts \\ %{}) do def get_weather_packets(callsign, start_time, end_time, opts \\ %{}) do
limit = Map.get(opts, :limit, 500) opts =
Map.merge(opts, %{
callsign: callsign,
start_time: start_time,
end_time: end_time,
limit: Map.get(opts, :limit, 500)
})
base_query = from(p in Packet, order_by: [desc: p.received_at]) opts
|> QueryBuilder.weather_packets()
query = |> QueryBuilder.with_coordinates()
base_query |> Repo.all()
|> 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 end
# Filter for weather packets at the database level # 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 """ @doc """
Retrieves a continuous stream of stored packets for replay in chronological order. Retrieves a continuous stream of stored packets for replay in chronological order.
@ -504,72 +572,6 @@ defmodule Aprsme.Packets do
end) end)
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 """ @doc """
Gets the total count of stored packets in the database. Gets the total count of stored packets in the database.
""" """
@ -636,20 +638,12 @@ defmodule Aprsme.Packets do
# Helper to convert various types to Decimal # Helper to convert various types to Decimal
defp to_decimal(value), do: Aprsme.EncodingUtils.to_decimal(value) 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 # Get packets from last hour only - used to initialize the map
@spec get_last_hour_packets() :: [struct()] @spec get_last_hour_packets() :: [struct()]
def get_last_hour_packets do def get_last_hour_packets do
one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second) %{hours_back: 1, limit: 500}
|> QueryBuilder.recent_position_packets()
Packet |> QueryBuilder.chronological()
|> 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() |> Repo.all()
end end
@ -735,44 +729,49 @@ defmodule Aprsme.Packets do
""" """
@spec get_latest_packet_for_callsign(String.t()) :: struct() | nil @spec get_latest_packet_for_callsign(String.t()) :: struct() | nil
def get_latest_packet_for_callsign(callsign) when is_binary(callsign) do def get_latest_packet_for_callsign(callsign) when is_binary(callsign) do
sanitized_callsign = sanitize_callsign(callsign) callsign
|> QueryBuilder.callsign_history(%{limit: 1})
from(p in Packet, |> QueryBuilder.with_coordinates()
where: ilike(p.sender, ^sanitized_callsign),
order_by: [desc: p.received_at],
limit: 1
)
|> select_with_virtual_coordinates()
|> Repo.one() |> Repo.one()
end end
# Sanitization functions to prevent SQL injection @doc """
defp sanitize_callsign(callsign) when is_binary(callsign) do Gets the most recent weather packet for a callsign.
# APRS callsigns are alphanumeric with hyphens and up to 9 characters Optimized to check only recent data first before expanding the search window.
# Remove any characters that could be used for SQL injection """
callsign @spec get_latest_weather_packet_optimized(String.t()) :: struct() | nil
|> String.upcase() def get_latest_weather_packet_optimized(callsign) when is_binary(callsign) do
|> String.replace(~r/[^A-Z0-9\-]/, "") # First try last 24 hours
|> String.slice(0, 9) one_day_ago = TimeUtils.one_day_ago()
end
defp sanitize_callsign(_), do: "" recent_packet =
Repo.one(
from(p in Packet,
where: p.sender == ^callsign,
where: p.data_type == "weather",
where: p.received_at >= ^one_day_ago,
order_by: [desc: p.received_at],
limit: 1
)
)
defp validate_coordinate(coord) when is_number(coord) do case recent_packet do
# Validate coordinate ranges nil ->
if coord >= -180 and coord <= 180 do # If no recent packet, expand to 7 days
{:ok, coord} one_week_ago = TimeUtils.one_week_ago()
else
{:error, :invalid_coordinate} Repo.one(
from(p in Packet,
where: p.sender == ^callsign,
where: p.data_type == "weather",
where: p.received_at >= ^one_week_ago,
order_by: [desc: p.received_at],
limit: 1
)
)
packet ->
packet
end end
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 end

View file

@ -0,0 +1,220 @@
defmodule Aprsme.Packets.QueryBuilder do
@moduledoc """
Query builder functions for composing Packet queries.
Provides reusable, composable functions for common query patterns.
"""
import Ecto.Query
alias Aprsme.Packet
@doc """
Filters query by time range. Supports start_time, end_time, and hours_back options.
## Options
* `:start_time` - DateTime to filter packets after
* `:end_time` - DateTime to filter packets before
* `:hours_back` - Alternative to start_time, filters packets from X hours ago
"""
@spec with_time_range(Ecto.Query.t(), map() | keyword()) :: Ecto.Query.t()
def with_time_range(query, opts) when is_list(opts) do
with_time_range(query, Map.new(opts))
end
def with_time_range(query, opts) when is_map(opts) do
query
|> maybe_filter_start_time(opts[:start_time] || opts["start_time"])
|> maybe_filter_end_time(opts[:end_time] || opts["end_time"])
|> maybe_filter_hours_back(opts[:hours_back] || opts["hours_back"])
end
@doc """
Filters query to only include packets with position data.
"""
@spec with_position(Ecto.Query.t()) :: Ecto.Query.t()
def with_position(query) do
from p in query, where: p.has_position == true
end
@doc """
Filters query by exact callsign match (case-insensitive).
"""
@spec for_callsign(Ecto.Query.t(), String.t()) :: Ecto.Query.t()
def for_callsign(query, callsign) when is_binary(callsign) do
normalized = String.upcase(String.trim(callsign))
from p in query, where: ilike(p.sender, ^normalized)
end
@doc """
Filters query by base callsign (without SSID).
"""
@spec for_base_callsign(Ecto.Query.t(), String.t()) :: Ecto.Query.t()
def for_base_callsign(query, base_callsign) when is_binary(base_callsign) do
from p in query, where: p.base_callsign == ^base_callsign
end
@doc """
Orders query by received_at descending (most recent first).
"""
@spec recent_first(Ecto.Query.t()) :: Ecto.Query.t()
def recent_first(query) do
from p in query, order_by: [desc: p.received_at]
end
@doc """
Orders query by received_at ascending (oldest first).
"""
@spec chronological(Ecto.Query.t()) :: Ecto.Query.t()
def chronological(query) do
from p in query, order_by: [asc: p.received_at]
end
@doc """
Filters query to only weather packets.
"""
@spec weather_only(Ecto.Query.t()) :: Ecto.Query.t()
def weather_only(query) do
from p in query,
where: p.data_type == "weather" or (p.symbol_table_id == "/" and p.symbol_code == "_")
end
@doc """
Applies pagination with limit and optional offset.
"""
@spec paginate(Ecto.Query.t(), integer(), integer()) :: Ecto.Query.t()
def paginate(query, limit, offset \\ 0) when is_integer(limit) and is_integer(offset) do
from p in query, limit: ^limit, offset: ^offset
end
@doc """
Adds PostGIS coordinate extraction to select clause.
"""
@spec with_coordinates(Ecto.Query.t()) :: Ecto.Query.t()
def with_coordinates(query) do
from p in query,
select: %{p | lat: fragment("ST_Y(?)", p.location), lon: fragment("ST_X(?)", p.location)}
end
@doc """
Filters by region if provided in options.
"""
@spec maybe_filter_region(Ecto.Query.t(), map() | keyword()) :: Ecto.Query.t()
def maybe_filter_region(query, opts) when is_list(opts) do
maybe_filter_region(query, Map.new(opts))
end
def maybe_filter_region(query, %{region: region}) when not is_nil(region) do
from p in query, where: p.region == ^region
end
def maybe_filter_region(query, _), do: query
@doc """
Filters by map bounds using bounding box coordinates.
## Example
within_bounds(query, [-74.0, 40.0, -73.0, 41.0])
# [west, south, east, north]
"""
@spec within_bounds(Ecto.Query.t(), list(number())) :: Ecto.Query.t()
def within_bounds(query, [west, south, east, north])
when is_number(west) and is_number(south) and is_number(east) and is_number(north) do
from p in query,
where: p.has_position == true,
where: p.lat >= ^south and p.lat <= ^north,
where: p.lon >= ^west and p.lon <= ^east
end
def within_bounds(query, _), do: query
@doc """
Common query composition for recent position packets.
## Options
* `:limit` - Number of packets to return (default: 100)
* `:start_time` - Filter packets after this time
* `:end_time` - Filter packets before this time
* `:region` - Filter by region
"""
@spec recent_position_packets(map() | keyword()) :: Ecto.Query.t()
def recent_position_packets(opts \\ %{}) do
limit = opts[:limit] || opts["limit"] || 100
Packet
|> with_position()
|> with_time_range(opts)
|> maybe_filter_region(opts)
|> recent_first()
|> paginate(limit)
|> with_coordinates()
end
@doc """
Common query composition for callsign packet history.
## Options
* `:limit` - Number of packets to return (default: 100)
* `:start_time` - Filter packets after this time
* `:end_time` - Filter packets before this time
"""
@spec callsign_history(String.t(), map() | keyword()) :: Ecto.Query.t()
def callsign_history(callsign, opts \\ %{}) do
limit = opts[:limit] || opts["limit"] || 100
Packet
|> for_callsign(callsign)
|> with_time_range(opts)
|> recent_first()
|> paginate(limit)
end
@doc """
Common query composition for weather packets.
## Options
* `:callsign` - Filter by specific callsign
* `:limit` - Number of packets to return (default: 100)
* `:start_time` - Filter packets after this time
* `:end_time` - Filter packets before this time
"""
@spec weather_packets(map() | keyword()) :: Ecto.Query.t()
def weather_packets(opts \\ %{}) do
limit = opts[:limit] || opts["limit"] || 100
callsign = opts[:callsign] || opts["callsign"]
query =
Packet
|> weather_only()
|> with_time_range(opts)
|> recent_first()
|> paginate(limit)
if callsign do
for_callsign(query, callsign)
else
query
end
end
# Private helper functions
defp maybe_filter_start_time(query, nil), do: query
defp maybe_filter_start_time(query, start_time) do
from p in query, where: p.received_at >= ^start_time
end
defp maybe_filter_end_time(query, nil), do: query
defp maybe_filter_end_time(query, end_time) do
from p in query, where: p.received_at <= ^end_time
end
defp maybe_filter_hours_back(query, nil), do: query
defp maybe_filter_hours_back(query, hours) when is_number(hours) do
start_time = DateTime.add(DateTime.utc_now(), -hours * 3600, :second)
from p in query, where: p.received_at >= ^start_time
end
end

View file

@ -7,6 +7,8 @@ defmodule Aprsme.PacketsBehaviour do
@callback stream_packets_for_replay(map()) :: Enumerable.t() @callback stream_packets_for_replay(map()) :: Enumerable.t()
@callback get_packets_for_replay(map()) :: list() @callback get_packets_for_replay(map()) :: list()
@callback get_recent_packets(map()) :: list() @callback get_recent_packets(map()) :: list()
@callback get_recent_packets_optimized(map()) :: list()
@callback get_nearby_stations(float(), float(), String.t() | nil, map()) :: list()
@callback get_weather_packets(String.t(), DateTime.t(), DateTime.t(), map()) :: list() @callback get_weather_packets(String.t(), DateTime.t(), DateTime.t(), map()) :: list()
@callback clean_old_packets() :: {:ok, non_neg_integer()} | {:error, any()} @callback clean_old_packets() :: {:ok, non_neg_integer()} | {:error, any()}
@callback clean_packets_older_than(pos_integer()) :: {:ok, non_neg_integer()} | {:error, any()} @callback clean_packets_older_than(pos_integer()) :: {:ok, non_neg_integer()} | {:error, any()}

View file

@ -61,6 +61,7 @@ defmodule AprsmeWeb.ApiDocsLive do
defp make_http_request(callsign) do defp make_http_request(callsign) do
alias Aprsme.Packets alias Aprsme.Packets
alias AprsmeWeb.TimeUtils
# Validate callsign format (same as controller) # Validate callsign format (same as controller)
callsign_regex = ~r/^[A-Z0-9]{1,3}[0-9][A-Z]{1,4}(-[0-9]{1,2})?$/ callsign_regex = ~r/^[A-Z0-9]{1,3}[0-9][A-Z]{1,4}(-[0-9]{1,2})?$/
@ -76,7 +77,7 @@ defmodule AprsmeWeb.ApiDocsLive do
{:ok, Jason.encode!(response, pretty: true)} {:ok, Jason.encode!(response, pretty: true)}
else else
# Get packet data (same logic as controller) # Get packet data (same logic as controller)
thirty_days_ago = DateTime.add(DateTime.utc_now(), -30, :day) thirty_days_ago = TimeUtils.days_ago(30)
opts = %{ opts = %{
callsign: callsign, callsign: callsign,

View file

@ -3,16 +3,19 @@ defmodule AprsmeWeb.InfoLive.Show do
use AprsmeWeb, :live_view use AprsmeWeb, :live_view
use Gettext, backend: AprsmeWeb.Gettext use Gettext, backend: AprsmeWeb.Gettext
alias Aprsme.Callsign
alias Aprsme.EncodingUtils
alias Aprsme.Packets alias Aprsme.Packets
alias AprsmeWeb.AprsSymbol alias AprsmeWeb.AprsSymbol
alias AprsmeWeb.Live.SharedPacketHandler
alias AprsmeWeb.MapLive.PacketUtils alias AprsmeWeb.MapLive.PacketUtils
alias AprsmeWeb.TimeUtils
@neighbor_radius_km 10
@neighbor_limit 10 @neighbor_limit 10
@impl true @impl true
def mount(%{"callsign" => callsign}, _session, socket) do def mount(%{"callsign" => callsign}, _session, socket) do
normalized_callsign = String.upcase(String.trim(callsign)) normalized_callsign = Callsign.normalize(callsign)
# Subscribe to Postgres notifications for live updates # Subscribe to Postgres notifications for live updates
if connected?(socket) do if connected?(socket) do
@ -20,7 +23,7 @@ defmodule AprsmeWeb.InfoLive.Show do
end end
packet = get_latest_packet(normalized_callsign) packet = get_latest_packet(normalized_callsign)
packet = enrich_packet_with_device_info(packet) packet = if packet, do: SharedPacketHandler.enrich_with_device_info(packet)
# Get locale from socket assigns (set by LocaleHook) # Get locale from socket assigns (set by LocaleHook)
locale = Map.get(socket.assigns, :locale, "en") locale = Map.get(socket.assigns, :locale, "en")
neighbors = get_neighbors(packet, normalized_callsign, locale) neighbors = get_neighbors(packet, normalized_callsign, locale)
@ -41,61 +44,40 @@ defmodule AprsmeWeb.InfoLive.Show do
@impl true @impl true
def handle_info({:postgres_packet, packet}, socket) do def handle_info({:postgres_packet, packet}, socket) do
# Only update if the packet is for our callsign SharedPacketHandler.handle_packet_update(packet, socket,
if packet_matches_callsign?(packet, socket.assigns.callsign) do filter_fn: SharedPacketHandler.callsign_filter(socket.assigns.callsign),
# Refresh data when new packet arrives process_fn: &process_packet_update/2,
packet = get_latest_packet(socket.assigns.callsign) enrich_packet: false
packet = enrich_packet_with_device_info(packet) )
# Get locale from socket assigns
locale = Map.get(socket.assigns, :locale, "en")
neighbors = get_neighbors(packet, socket.assigns.callsign, locale)
has_weather_packets = PacketUtils.has_weather_packets?(socket.assigns.callsign)
other_ssids = get_other_ssids(socket.assigns.callsign)
socket =
socket
|> assign(:packet, packet)
|> assign(:neighbors, neighbors)
|> assign(:has_weather_packets, has_weather_packets)
|> assign(:other_ssids, other_ssids)
{:noreply, socket}
else
{:noreply, socket}
end
end end
def handle_info(_message, socket), do: {:noreply, socket} def handle_info(_message, socket), do: {:noreply, socket}
defp packet_matches_callsign?(packet, callsign) do defp process_packet_update(_packet, socket) do
packet_sender = Map.get(packet, "sender") || Map.get(packet, :sender, "") # Refresh data when new packet arrives
String.upcase(packet_sender) == String.upcase(callsign) packet = get_latest_packet(socket.assigns.callsign)
packet = if packet, do: SharedPacketHandler.enrich_with_device_info(packet)
# Get locale from socket assigns
locale = Map.get(socket.assigns, :locale, "en")
neighbors = get_neighbors(packet, socket.assigns.callsign, locale)
has_weather_packets = PacketUtils.has_weather_packets?(socket.assigns.callsign)
other_ssids = get_other_ssids(socket.assigns.callsign)
socket =
socket
|> assign(:packet, packet)
|> assign(:neighbors, neighbors)
|> assign(:has_weather_packets, has_weather_packets)
|> assign(:other_ssids, other_ssids)
{:noreply, socket}
end end
defp get_latest_packet(callsign) do defp get_latest_packet(callsign) do
# Get the most recent packet for this callsign, regardless of type # Get the most recent packet for this callsign, regardless of type
# This ensures we show the most recent activity, not just position packets # This ensures we show the most recent activity, not just position packets
Packets.get_latest_packet_for_callsign(callsign) # Use cached version for better performance
end Aprsme.CachedQueries.get_latest_packet_for_callsign_cached(callsign)
defp enrich_packet_with_device_info(nil), do: nil
defp enrich_packet_with_device_info(packet) do
device_identifier = Map.get(packet, :device_identifier) || Map.get(packet, "device_identifier")
device =
if is_binary(device_identifier), do: Aprsme.DeviceIdentification.lookup_device_by_identifier(device_identifier)
model = if device, do: device.model
vendor = if device, do: device.vendor
contact = if device, do: device.contact
class = if device, do: device.class
packet
|> Map.put(:device_model, model)
|> Map.put(:device_vendor, vendor)
|> Map.put(:device_contact, contact)
|> Map.put(:device_class, class)
end end
defp get_neighbors(nil, _callsign, _locale), do: [] defp get_neighbors(nil, _callsign, _locale), do: []
@ -107,52 +89,38 @@ defmodule AprsmeWeb.InfoLive.Show do
if is_nil(lat) or is_nil(lon) do if is_nil(lat) or is_nil(lon) do
[] []
else else
# Simple bounding box for ~10km radius # Convert Decimal to float if needed
delta = @neighbor_radius_km / 111.0 lat_float = to_float(lat)
min_lat = lat - delta lon_float = to_float(lon)
max_lat = lat + delta
min_lon = lon - delta
max_lon = lon + delta
opts = %{bounds: [min_lon, min_lat, max_lon, max_lat], limit: 50}
opts # Use the spatial query to get the closest stations
|> Packets.get_recent_packets() # The query already returns them sorted by distance
|> Enum.filter(fn p -> nearby_packets =
(p.sender != callsign and p.lat) && p.lon Packets.get_nearby_stations(
end) lat_float,
|> uniq_by(& &1.sender) lon_float,
|> Enum.map(fn p -> callsign,
%{limit: @neighbor_limit}
)
# Calculate distance and course for each neighbor
# The packets are already sorted by distance from the database
Enum.map(nearby_packets, fn p ->
dist = haversine(lat, lon, p.lat, p.lon) dist = haversine(lat, lon, p.lat, p.lon)
course = calculate_course(lat, lon, p.lat, p.lon) course = calculate_course(lat, lon, p.lat, p.lon)
%{ %{
callsign: p.sender, callsign: p.sender,
distance: format_distance(dist, locale), distance: format_distance(dist, locale),
distance_km: dist,
course: course, course: course,
last_heard: format_timestamp_for_display(p), last_heard: format_timestamp_for_display(p),
packet: p packet: p
} }
end) end)
|> Enum.sort_by(& &1.distance)
|> Enum.take(@neighbor_limit)
end end
end end
defp uniq_by(list, fun) do
list
|> Enum.reduce({MapSet.new(), []}, fn item, {set, acc} ->
key = fun.(item)
if MapSet.member?(set, key) do
{set, acc}
else
{MapSet.put(set, key), [item | acc]}
end
end)
|> elem(1)
|> Enum.reverse()
end
def haversine(lat1, lon1, lat2, lon2) do def haversine(lat1, lon1, lat2, lon2) do
# Returns distance in km # Returns distance in km
# Convert Decimal to float if needed # Convert Decimal to float if needed
@ -174,18 +142,7 @@ defmodule AprsmeWeb.InfoLive.Show do
r * c r * c
end end
defp to_float(%Decimal{} = decimal), do: Decimal.to_float(decimal) defp to_float(value), do: EncodingUtils.to_float(value) || 0.0
defp to_float(value) when is_float(value), do: value
defp to_float(value) when is_integer(value), do: value * 1.0
defp to_float(value) when is_binary(value) do
case Float.parse(value) do
{f, _} -> f
:error -> 0.0
end
end
defp to_float(_), do: 0.0
defp format_timestamp_for_display(packet) do defp format_timestamp_for_display(packet) do
received_at = get_received_at(packet) received_at = get_received_at(packet)
@ -265,46 +222,57 @@ defmodule AprsmeWeb.InfoLive.Show do
end end
defp get_other_ssids(callsign) do defp get_other_ssids(callsign) do
import Ecto.Query
alias Aprsme.Packet alias Aprsme.Packet
alias Aprsme.Repo alias Aprsme.Repo
# Extract base callsign from the full callsign (remove SSID if present) # Extract base callsign from the full callsign (remove SSID if present)
base_callsign = extract_base_callsign(callsign) base_callsign = extract_base_callsign(callsign)
# Query directly for packets with the same base_callsign
# Get recent packets for the base callsign to find other SSIDs # Get recent packets for the base callsign to find other SSIDs
one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second) one_hour_ago = TimeUtils.one_hour_ago()
query = # Use a window function to get the most recent packet per sender
from p in Packet, # This is much more efficient than fetching 100 packets and filtering in Elixir
where: p.base_callsign == ^base_callsign, query = """
where: p.received_at >= ^one_hour_ago, WITH recent_ssids AS (
order_by: [desc: p.received_at], SELECT DISTINCT ON (sender)
limit: 100 sender, ssid, received_at, id
FROM packets
WHERE base_callsign = $1
AND received_at >= $2
AND sender != $3
ORDER BY sender, received_at DESC
)
SELECT * FROM recent_ssids
ORDER BY received_at DESC
LIMIT 10
"""
query case Repo.query(query, [base_callsign, one_hour_ago, callsign]) do
|> Repo.all() {:ok, result} ->
|> Enum.map(fn p -> Enum.map(result.rows, fn [sender, ssid, received_at, id] ->
%{ # Create a minimal packet struct for display
callsign: p.sender, packet = %Packet{
ssid: p.ssid, id: id,
last_heard: format_timestamp_for_display(p), sender: sender,
packet: p ssid: ssid,
} received_at: received_at
end) }
|> uniq_by(& &1.callsign)
|> Enum.filter(fn ssid_info -> ssid_info.callsign != callsign end) %{
|> Enum.sort_by(& &1.last_heard, :desc) callsign: sender,
|> Enum.take(10) ssid: ssid,
last_heard: format_timestamp_for_display(packet),
packet: packet
}
end)
{:error, _} ->
[]
end
end end
defp extract_base_callsign(callsign) do defp extract_base_callsign(callsign) do
case String.split(callsign, "-") do Callsign.extract_base(callsign)
[base, _ssid] -> base
[base] -> base
end
end end
@doc """ @doc """

View file

@ -4,10 +4,13 @@ defmodule AprsmeWeb.MapLive.CallsignView do
import Phoenix.LiveView, only: [connected?: 1, push_event: 3] import Phoenix.LiveView, only: [connected?: 1, push_event: 3]
alias Aprsme.Callsign
alias Aprsme.EncodingUtils
alias Aprsme.Packets alias Aprsme.Packets
alias AprsmeWeb.Endpoint alias AprsmeWeb.Endpoint
alias AprsmeWeb.MapLive.MapHelpers alias AprsmeWeb.MapLive.MapHelpers
alias AprsmeWeb.MapLive.PacketUtils alias AprsmeWeb.MapLive.PacketUtils
alias AprsmeWeb.TimeUtils
@default_center %{lat: 39.0, lng: -98.0} @default_center %{lat: 39.0, lng: -98.0}
@default_zoom 4 @default_zoom 4
@ -16,10 +19,10 @@ defmodule AprsmeWeb.MapLive.CallsignView do
@impl true @impl true
def mount(%{"callsign" => callsign}, _session, socket) do def mount(%{"callsign" => callsign}, _session, socket) do
# Normalize callsign to uppercase # Normalize callsign to uppercase
normalized_callsign = String.upcase(callsign) normalized_callsign = Callsign.normalize(callsign)
# Calculate one hour ago for packet age filtering # Calculate one hour ago for packet age filtering
one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second) one_hour_ago = TimeUtils.one_hour_ago()
socket = socket =
assign(socket, assign(socket,
@ -142,7 +145,7 @@ defmodule AprsmeWeb.MapLive.CallsignView do
end end
def handle_event("adjust_replay_speed", %{"speed" => speed}, socket) do def handle_event("adjust_replay_speed", %{"speed" => speed}, socket) do
speed_float = to_float(speed) speed_float = EncodingUtils.to_float(speed) || 1.0
{:noreply, assign(socket, replay_speed: speed_float)} {:noreply, assign(socket, replay_speed: speed_float)}
end end
@ -207,12 +210,7 @@ defmodule AprsmeWeb.MapLive.CallsignView do
defp handle_bounds_update(bounds, socket) do defp handle_bounds_update(bounds, socket) do
# Convert string keys to atom keys and parse values # Convert string keys to atom keys and parse values
normalized_bounds = %{ normalized_bounds = MapHelpers.normalize_bounds(bounds)
north: to_float(bounds["north"]),
south: to_float(bounds["south"]),
east: to_float(bounds["east"]),
west: to_float(bounds["west"])
}
# Clean up old packets first # Clean up old packets first
socket = cleanup_old_packets(socket) socket = cleanup_old_packets(socket)
@ -252,17 +250,6 @@ defmodule AprsmeWeb.MapLive.CallsignView do
{:noreply, socket} {:noreply, socket}
end end
# Helper function to convert string or float to float
defp to_float(value) when is_float(value), do: value
defp to_float(value) when is_integer(value), do: value * 1.0
defp to_float(value) when is_binary(value) do
case Float.parse(value) do
{float_val, _} -> float_val
:error -> raise ArgumentError, "Invalid float string: #{value}"
end
end
@impl true @impl true
def handle_info({:zoom_to_location, lat, lng, zoom}, socket) do def handle_info({:zoom_to_location, lat, lng, zoom}, socket) do
socket = push_event(socket, "zoom_to_location", %{lat: lat, lng: lng, zoom: zoom}) socket = push_event(socket, "zoom_to_location", %{lat: lat, lng: lng, zoom: zoom})
@ -732,7 +719,7 @@ defmodule AprsmeWeb.MapLive.CallsignView do
defp start_historical_replay(socket) do defp start_historical_replay(socket) do
# Fetch historical packets for the specific callsign from the last hour # Fetch historical packets for the specific callsign from the last hour
now = DateTime.utc_now() now = DateTime.utc_now()
one_hour_ago = DateTime.add(now, -3600, :second) one_hour_ago = TimeUtils.one_hour_ago()
packets = packets =
fetch_historical_packets_for_callsign( fetch_historical_packets_for_callsign(

View file

@ -12,6 +12,7 @@ defmodule AprsmeWeb.MapLive.Index do
alias AprsmeWeb.MapLive.MapHelpers alias AprsmeWeb.MapLive.MapHelpers
alias AprsmeWeb.MapLive.PacketUtils alias AprsmeWeb.MapLive.PacketUtils
alias AprsmeWeb.MapLive.PopupComponent alias AprsmeWeb.MapLive.PopupComponent
alias AprsmeWeb.TimeUtils
alias Phoenix.HTML.Safe alias Phoenix.HTML.Safe
alias Phoenix.LiveView.Socket alias Phoenix.LiveView.Socket
@ -79,7 +80,7 @@ defmodule AprsmeWeb.MapLive.Index do
deployed_at = Aprsme.Release.deployed_at() deployed_at = Aprsme.Release.deployed_at()
# Show 24 hours for more symbol variety # Show 24 hours for more symbol variety
one_hour_ago = DateTime.add(DateTime.utc_now(), -24 * 3600, :second) one_hour_ago = TimeUtils.one_day_ago()
# Parse map state from URL parameters # Parse map state from URL parameters
{map_center, map_zoom} = parse_map_params(params) {map_center, map_zoom} = parse_map_params(params)
@ -1655,12 +1656,7 @@ defmodule AprsmeWeb.MapLive.Index do
@spec handle_bounds_update(map(), Socket.t()) :: {:noreply, Socket.t()} @spec handle_bounds_update(map(), Socket.t()) :: {:noreply, Socket.t()}
defp handle_bounds_update(bounds, socket) do defp handle_bounds_update(bounds, socket) do
# Update the map bounds from the client, converting to atom keys # Update the map bounds from the client, converting to atom keys
map_bounds = %{ map_bounds = MapHelpers.normalize_bounds(bounds)
north: bounds["north"],
south: bounds["south"],
east: bounds["east"],
west: bounds["west"]
}
# Validate bounds to prevent invalid coordinates # Validate bounds to prevent invalid coordinates
if valid_bounds?(map_bounds) do if valid_bounds?(map_bounds) do

View file

@ -106,16 +106,35 @@ defmodule AprsmeWeb.MapLive.MapHelpers do
defp extract_lat_lon_string(packet), do: {packet["lat"], packet["lon"]} defp extract_lat_lon_string(packet), do: {packet["lat"], packet["lon"]}
defp extract_lat_lon_atom_alt(packet), do: {packet.latitude, packet.longitude} defp extract_lat_lon_atom_alt(packet), do: {packet.latitude, packet.longitude}
defp to_float(n) when is_float(n), do: n defp to_float(value) do
defp to_float(n) when is_integer(n), do: n * 1.0 Aprsme.EncodingUtils.to_float(value) || 0.0
defp to_float(%Decimal{} = d), do: Decimal.to_float(d)
defp to_float(n) when is_binary(n) do
case Float.parse(n) do
{f, _} -> f
:error -> 0.0
end
end end
defp to_float(_), do: 0.0 @doc """
Normalizes map bounds from string keys to atom keys and converts values to floats.
## Examples
iex> normalize_bounds(%{"north" => "40.5", "south" => "40.0", "east" => "-73.5", "west" => "-74.0"})
%{north: 40.5, south: 40.0, east: -73.5, west: -74.0}
iex> normalize_bounds(%{"north" => 40.5, "south" => 40.0, "east" => -73.5, "west" => -74.0})
%{north: 40.5, south: 40.0, east: -73.5, west: -74.0}
"""
@spec normalize_bounds(map()) :: map()
def normalize_bounds(%{"north" => n, "south" => s, "east" => e, "west" => w}) do
%{
north: to_float(n),
south: to_float(s),
east: to_float(e),
west: to_float(w)
}
end
def normalize_bounds(%{north: _, south: _, east: _, west: _} = bounds) do
# Already normalized with atom keys
bounds
end
def normalize_bounds(_), do: nil
end end

View file

@ -88,17 +88,8 @@ defmodule AprsmeWeb.MapLive.PacketUtils do
@spec has_weather_packets?(String.t()) :: boolean() @spec has_weather_packets?(String.t()) :: boolean()
# Get recent packets for this callsign and check if any are weather packets # Get recent packets for this callsign and check if any are weather packets
def has_weather_packets?(callsign) when is_binary(callsign) do def has_weather_packets?(callsign) when is_binary(callsign) do
# Use a more efficient query that only checks for existence # Use cached query for better performance
import Ecto.Query Aprsme.CachedQueries.has_weather_packets_cached?(callsign)
query =
from p in Aprsme.Packet,
where: ilike(p.sender, ^callsign),
where: p.data_type == "weather" or (p.symbol_table_id == "/" and p.symbol_code == "_"),
limit: 1,
select: true
Aprsme.Repo.exists?(query)
rescue rescue
_ -> false _ -> false
end end

View file

@ -4,18 +4,20 @@ defmodule AprsmeWeb.PacketsLive.CallsignView do
import Ecto.Query import Ecto.Query
alias Aprsme.Callsign
alias Aprsme.DeviceParser alias Aprsme.DeviceParser
alias Aprsme.EncodingUtils alias Aprsme.EncodingUtils
alias Aprsme.Packet alias Aprsme.Packet
alias Aprsme.Repo alias Aprsme.Repo
alias AprsmeWeb.Endpoint alias AprsmeWeb.Endpoint
alias AprsmeWeb.Live.SharedPacketHandler
@impl true @impl true
def mount(%{"callsign" => callsign}, _session, socket) do def mount(%{"callsign" => callsign}, _session, socket) do
# Validate and normalize callsign # Validate and normalize callsign
normalized_callsign = String.upcase(String.trim(callsign)) normalized_callsign = Callsign.normalize(callsign)
if valid_callsign?(normalized_callsign) do if Callsign.valid?(normalized_callsign) do
# Subscribe to live packet updates if connected # Subscribe to live packet updates if connected
if connected?(socket) do if connected?(socket) do
Endpoint.subscribe("aprs_messages") Endpoint.subscribe("aprs_messages")
@ -55,20 +57,17 @@ defmodule AprsmeWeb.PacketsLive.CallsignView do
@impl true @impl true
def handle_info(%{event: "packet", payload: payload}, socket) do def handle_info(%{event: "packet", payload: payload}, socket) do
# Handle incoming live packets - only process if they match our callsign SharedPacketHandler.handle_packet_update(payload, socket,
if packet_matches_callsign?(payload, socket.assigns.callsign) do filter_fn: SharedPacketHandler.callsign_filter(socket.assigns.callsign),
process_matching_packet(payload, socket) process_fn: &process_matching_packet/2
else )
{:noreply, socket}
end
end end
def handle_info(_message, socket), do: {:noreply, socket} def handle_info(_message, socket), do: {:noreply, socket}
defp process_matching_packet(payload, socket) do defp process_matching_packet(enriched_payload, socket) do
sanitized_payload = EncodingUtils.sanitize_packet(payload) # Device identifier enrichment specific to this module
enriched_payload = enrich_packet_with_device_identifier(sanitized_payload) enriched_payload = enrich_packet_with_device_identifier(enriched_payload)
enriched_payload = enrich_packet_with_device_info(enriched_payload)
current_live = socket.assigns.live_packets current_live = socket.assigns.live_packets
current_stored = socket.assigns.packets current_stored = socket.assigns.packets
@ -123,7 +122,7 @@ defmodule AprsmeWeb.PacketsLive.CallsignView do
filtered_query filtered_query
|> Repo.all() |> Repo.all()
|> Enum.map(&EncodingUtils.sanitize_packet/1) |> Enum.map(&EncodingUtils.sanitize_packet/1)
|> Enum.map(&enrich_packet_with_device_info/1) |> Enum.map(&SharedPacketHandler.enrich_with_device_info/1)
rescue rescue
error -> error ->
require Logger require Logger
@ -132,18 +131,6 @@ defmodule AprsmeWeb.PacketsLive.CallsignView do
[] []
end end
defp packet_matches_callsign?(packet, target_callsign) do
# Check exact match for sender field only
sender = packet.sender || ""
# Convert to uppercase for case-insensitive comparison
sender_upper = String.upcase(sender)
target_upper = String.upcase(target_callsign)
# Exact match only
sender_upper == target_upper
end
# Helper to get all packets (stored + live) in chronological order # Helper to get all packets (stored + live) in chronological order
# Combines stored and live packets, sorts by timestamp (newest first), and limits to 100 # Combines stored and live packets, sorts by timestamp (newest first), and limits to 100
defp get_all_packets_list(stored, live) do defp get_all_packets_list(stored, live) do
@ -188,17 +175,6 @@ defmodule AprsmeWeb.PacketsLive.CallsignView do
end end
end end
# Validates if the callsign format is reasonable
defp valid_callsign?(callsign) do
# Basic validation for amateur radio callsign format
# Should be 3-8 characters, can contain letters, numbers, and one hyphen for SSID
case String.trim(callsign) do
"" -> false
cs when byte_size(cs) < 3 or byte_size(cs) > 15 -> false
cs -> Regex.match?(~r/^[A-Z0-9]+(-[A-Z0-9]{1,2})?$/i, cs)
end
end
# Helper to extract symbol table and code from a packet # Helper to extract symbol table and code from a packet
defp extract_symbol_info(nil), do: {"/", ">"} defp extract_symbol_info(nil), do: {"/", ">"}
@ -208,18 +184,4 @@ defmodule AprsmeWeb.PacketsLive.CallsignView do
code = Map.get(data, :symbol_code) || Map.get(data, "symbol_code") || ">" code = Map.get(data, :symbol_code) || Map.get(data, "symbol_code") || ">"
{table, code} {table, code}
end end
defp enrich_packet_with_device_info(packet) do
device_identifier = Map.get(packet, :device_identifier) || Map.get(packet, "device_identifier")
device =
if is_binary(device_identifier), do: Aprsme.DeviceIdentification.lookup_device_by_identifier(device_identifier)
model = if device, do: device.model
vendor = if device, do: device.vendor
packet
|> Map.put(:device_model, model)
|> Map.put(:device_vendor, vendor)
end
end end

View file

@ -0,0 +1,126 @@
defmodule AprsmeWeb.Live.SharedPacketHandler do
@moduledoc """
Shared functionality for handling packet updates in LiveView modules.
Provides common patterns for packet filtering, enrichment, and processing.
"""
alias Aprsme.Callsign
alias Aprsme.DeviceIdentification
alias Aprsme.DeviceParser
alias Aprsme.EncodingUtils
@doc """
Handles incoming packet updates with filtering and processing.
## Options
* `:filter_fn` - Function to determine if packet should be processed (required)
* `:process_fn` - Function to process matching packets and update socket (required)
* `:enrich_packet` - Whether to enrich packet with device info (default: true)
"""
def handle_packet_update(packet, socket, opts) do
filter_fn = Keyword.fetch!(opts, :filter_fn)
process_fn = Keyword.fetch!(opts, :process_fn)
enrich_packet? = Keyword.get(opts, :enrich_packet, true)
if filter_fn.(packet, socket) do
enriched_packet =
if enrich_packet? do
packet
|> sanitize_packet()
|> enrich_with_device_info()
else
packet
end
process_fn.(enriched_packet, socket)
else
{:noreply, socket}
end
end
@doc """
Checks if packet sender matches the given callsign.
"""
def packet_matches_callsign?(packet, callsign) do
packet_sender = Map.get(packet, "sender") || Map.get(packet, :sender, "")
Callsign.matches?(packet_sender, callsign)
end
@doc """
Checks if packet contains weather data.
"""
def has_weather_data?(packet) do
Enum.any?(EncodingUtils.weather_fields(), fn field ->
value = Map.get(packet, field) || Map.get(packet, to_string(field))
not is_nil(value)
end)
end
@doc """
Sanitizes packet strings to handle encoding issues.
"""
def sanitize_packet(packet) do
EncodingUtils.sanitize_packet(packet)
end
@doc """
Enriches packet with device information.
"""
def enrich_with_device_info(packet) do
device_identifier = extract_device_identifier(packet)
device =
case device_identifier do
nil -> nil
"" -> nil
identifier -> DeviceIdentification.lookup_device_by_identifier(identifier)
end
packet
|> Map.put(:device_model, device && device.model)
|> Map.put(:device_vendor, device && device.vendor)
|> Map.put(:device_contact, device && device.contact)
|> Map.put(:device_class, device && device.class)
end
@doc """
Creates a filter function that checks both callsign and weather data.
"""
def callsign_and_weather_filter(callsign) do
fn packet, _socket ->
packet_matches_callsign?(packet, callsign) and has_weather_data?(packet)
end
end
@doc """
Creates a filter function that only checks callsign.
"""
def callsign_filter(callsign) do
fn packet, _socket ->
packet_matches_callsign?(packet, callsign)
end
end
# Private functions
defp extract_device_identifier(packet) do
comment = Map.get(packet, :comment) || Map.get(packet, "comment") || ""
normalized_comment =
case comment do
comment when is_binary(comment) ->
comment
|> String.trim()
|> String.replace(~r/[^\x20-\x7E]+/, "")
|> String.trim()
_ ->
""
end
case DeviceParser.extract_device_identifier(%{comment: normalized_comment}) do
nil -> nil
identifier -> String.trim(identifier)
end
end
end

View file

@ -5,13 +5,14 @@ defmodule AprsmeWeb.WeatherLive.CallsignView do
import Phoenix.LiveView, only: [push_event: 3, connected?: 1] import Phoenix.LiveView, only: [push_event: 3, connected?: 1]
alias Aprsme.Packets alias Aprsme.Callsign
alias AprsmeWeb.MapLive.PacketUtils alias AprsmeWeb.MapLive.PacketUtils
alias AprsmeWeb.TimeUtils
alias AprsmeWeb.WeatherUnits alias AprsmeWeb.WeatherUnits
@impl true @impl true
def mount(%{"callsign" => callsign}, _session, socket) do def mount(%{"callsign" => callsign}, _session, socket) do
normalized_callsign = String.upcase(String.trim(callsign)) normalized_callsign = Callsign.normalize(callsign)
# Subscribe to weather-specific topic for targeted updates # Subscribe to weather-specific topic for targeted updates
if connected?(socket) do if connected?(socket) do
@ -27,37 +28,7 @@ defmodule AprsmeWeb.WeatherLive.CallsignView do
locale = Map.get(socket.assigns, :locale, "en") locale = Map.get(socket.assigns, :locale, "en")
unit_labels = WeatherUnits.unit_labels(locale) unit_labels = WeatherUnits.unit_labels(locale)
weather_history_json = weather_history_json = convert_weather_history_to_json(weather_history, locale)
weather_history
|> Enum.map(fn pkt ->
dew_point =
if is_number(pkt.temperature) and is_number(pkt.humidity) do
calc_dew_point(pkt.temperature, pkt.humidity)
end
# Convert units based on locale
{temp_value, _} = WeatherUnits.format_temperature(pkt.temperature, locale)
{dew_value, _} = if dew_point, do: WeatherUnits.format_temperature(dew_point, locale), else: {nil, nil}
{wind_speed_value, _} = WeatherUnits.format_wind_speed(pkt.wind_speed, locale)
{rain_1h_value, _} = WeatherUnits.format_rain(pkt.rain_1h, locale)
{rain_24h_value, _} = WeatherUnits.format_rain(pkt.rain_24h, locale)
{rain_since_midnight_value, _} = WeatherUnits.format_rain(pkt.rain_since_midnight, locale)
%{
timestamp: pkt.received_at,
temperature: temp_value,
dew_point: dew_value,
humidity: pkt.humidity,
pressure: pkt.pressure,
wind_direction: pkt.wind_direction,
wind_speed: wind_speed_value,
rain_1h: rain_1h_value,
rain_24h: rain_24h_value,
rain_since_midnight: rain_since_midnight_value,
luminosity: pkt.luminosity
}
end)
|> Jason.encode!()
# Add chart labels for translation with locale-aware units # Add chart labels for translation with locale-aware units
chart_labels = %{ chart_labels = %{
@ -90,8 +61,38 @@ defmodule AprsmeWeb.WeatherLive.CallsignView do
end end
@impl true @impl true
def handle_info({:weather_packet, _packet}, socket) do def handle_info({:weather_packet, packet}, socket) do
# Update weather data when new weather packet arrives for this callsign # Check if this packet is actually newer than what we have
current_packet = socket.assigns.weather_packet
if should_update_weather?(current_packet, packet) do
update_weather_data(socket)
else
{:noreply, socket}
end
end
# Keep the old handler for backward compatibility
def handle_info({:postgres_packet, packet}, socket) do
# Only process if it's a weather packet for this callsign
if packet.sender == socket.assigns.callsign && packet.data_type == "weather" do
handle_info({:weather_packet, packet}, socket)
else
{:noreply, socket}
end
end
def handle_info(_message, socket), do: {:noreply, socket}
defp should_update_weather?(nil, _new_packet), do: true
defp should_update_weather?(current_packet, new_packet) do
# Only update if the new packet is actually newer
DateTime.after?(new_packet.received_at, current_packet.received_at)
end
defp update_weather_data(socket) do
# Fetch updated data only once
weather_packet = get_latest_weather_packet(socket.assigns.callsign) weather_packet = get_latest_weather_packet(socket.assigns.callsign)
{start_time, end_time} = default_time_range() {start_time, end_time} = default_time_range()
weather_history = get_weather_history(socket.assigns.callsign, start_time, end_time) weather_history = get_weather_history(socket.assigns.callsign, start_time, end_time)
@ -99,37 +100,7 @@ defmodule AprsmeWeb.WeatherLive.CallsignView do
# Get locale from socket assigns # Get locale from socket assigns
locale = Map.get(socket.assigns, :locale, "en") locale = Map.get(socket.assigns, :locale, "en")
weather_history_json = weather_history_json = convert_weather_history_to_json(weather_history, locale)
weather_history
|> Enum.map(fn pkt ->
dew_point =
if is_number(pkt.temperature) and is_number(pkt.humidity) do
calc_dew_point(pkt.temperature, pkt.humidity)
end
# Convert units based on locale
{temp_value, _} = WeatherUnits.format_temperature(pkt.temperature, locale)
{dew_value, _} = if dew_point, do: WeatherUnits.format_temperature(dew_point, locale), else: {nil, nil}
{wind_speed_value, _} = WeatherUnits.format_wind_speed(pkt.wind_speed, locale)
{rain_1h_value, _} = WeatherUnits.format_rain(pkt.rain_1h, locale)
{rain_24h_value, _} = WeatherUnits.format_rain(pkt.rain_24h, locale)
{rain_since_midnight_value, _} = WeatherUnits.format_rain(pkt.rain_since_midnight, locale)
%{
timestamp: pkt.received_at,
temperature: temp_value,
dew_point: dew_value,
humidity: pkt.humidity,
pressure: pkt.pressure,
wind_direction: pkt.wind_direction,
wind_speed: wind_speed_value,
rain_1h: rain_1h_value,
rain_24h: rain_24h_value,
rain_since_midnight: rain_since_midnight_value,
luminosity: pkt.luminosity
}
end)
|> Jason.encode!()
socket = socket =
socket socket
@ -143,97 +114,18 @@ defmodule AprsmeWeb.WeatherLive.CallsignView do
{:noreply, socket} {:noreply, socket}
end end
# Keep the old handler for backward compatibility
def handle_info({:postgres_packet, packet}, socket) do
# Only update if the packet is for our callsign and contains weather data
if packet_matches_callsign?(packet, socket.assigns.callsign) and has_weather_data?(packet) do
# Refresh weather data when new weather packet arrives
weather_packet = get_latest_weather_packet(socket.assigns.callsign)
{start_time, end_time} = default_time_range()
weather_history = get_weather_history(socket.assigns.callsign, start_time, end_time)
# Get locale from socket assigns
locale = Map.get(socket.assigns, :locale, "en")
weather_history_json =
weather_history
|> Enum.map(fn pkt ->
dew_point =
if is_number(pkt.temperature) and is_number(pkt.humidity) do
calc_dew_point(pkt.temperature, pkt.humidity)
end
# Convert units based on locale
{temp_value, _} = WeatherUnits.format_temperature(pkt.temperature, locale)
{dew_value, _} = if dew_point, do: WeatherUnits.format_temperature(dew_point, locale), else: {nil, nil}
{wind_speed_value, _} = WeatherUnits.format_wind_speed(pkt.wind_speed, locale)
{rain_1h_value, _} = WeatherUnits.format_rain(pkt.rain_1h, locale)
{rain_24h_value, _} = WeatherUnits.format_rain(pkt.rain_24h, locale)
{rain_since_midnight_value, _} = WeatherUnits.format_rain(pkt.rain_since_midnight, locale)
%{
timestamp: pkt.received_at,
temperature: temp_value,
dew_point: dew_value,
humidity: pkt.humidity,
pressure: pkt.pressure,
wind_direction: pkt.wind_direction,
wind_speed: wind_speed_value,
rain_1h: rain_1h_value,
rain_24h: rain_24h_value,
rain_since_midnight: rain_since_midnight_value,
luminosity: pkt.luminosity
}
end)
|> Jason.encode!()
socket =
socket
|> assign(:weather_packet, weather_packet)
|> assign(:weather_history, weather_history)
|> assign(:weather_history_json, weather_history_json)
# Push event to update all charts with new data
socket = push_event(socket, "update_weather_charts", %{weather_history: weather_history_json})
{:noreply, socket}
else
{:noreply, socket}
end
end
def handle_info(_message, socket), do: {:noreply, socket}
defp packet_matches_callsign?(packet, callsign) do
packet_sender = Map.get(packet, "sender") || Map.get(packet, :sender, "")
String.upcase(packet_sender) == String.upcase(callsign)
end
defp has_weather_data?(packet) do
# Check if packet contains any weather-related fields
Enum.any?(Aprsme.EncodingUtils.weather_fields(), fn field ->
value = Map.get(packet, field) || Map.get(packet, to_string(field))
not is_nil(value)
end)
end
defp get_latest_weather_packet(callsign) do defp get_latest_weather_packet(callsign) do
# Get weather packets from the last 7 days to find the most recent one # Use optimized cached query that checks recent data first
end_time = DateTime.utc_now() Aprsme.CachedQueries.get_latest_weather_packet_cached(callsign)
start_time = DateTime.add(end_time, -7 * 24 * 3600, :second)
callsign
|> Packets.get_weather_packets(start_time, end_time, %{limit: 1})
|> List.first()
end end
defp get_weather_history(callsign, start_time, end_time) do defp get_weather_history(callsign, start_time, end_time) do
Packets.get_weather_packets(callsign, start_time, end_time, %{limit: 500}) # Use cached queries to avoid repeated database hits
Aprsme.CachedQueries.get_weather_packets_cached(callsign, start_time, end_time, %{limit: 500})
end end
defp default_time_range do defp default_time_range do
now = DateTime.utc_now() TimeUtils.time_range(:last_two_days)
{DateTime.add(now, -48 * 3600, :second), now}
end end
defp calc_dew_point(temp, humidity) when is_number(temp) and is_number(humidity) do defp calc_dew_point(temp, humidity) when is_number(temp) and is_number(humidity) do
@ -242,6 +134,39 @@ defmodule AprsmeWeb.WeatherLive.CallsignView do
defp calc_dew_point(_, _), do: nil defp calc_dew_point(_, _), do: nil
defp convert_weather_history_to_json(weather_history, locale) do
weather_history
|> Enum.map(fn pkt ->
dew_point =
if is_number(pkt.temperature) and is_number(pkt.humidity) do
calc_dew_point(pkt.temperature, pkt.humidity)
end
# Convert units based on locale
{temp_value, _} = WeatherUnits.format_temperature(pkt.temperature, locale)
{dew_value, _} = if dew_point, do: WeatherUnits.format_temperature(dew_point, locale), else: {nil, nil}
{wind_speed_value, _} = WeatherUnits.format_wind_speed(pkt.wind_speed, locale)
{rain_1h_value, _} = WeatherUnits.format_rain(pkt.rain_1h, locale)
{rain_24h_value, _} = WeatherUnits.format_rain(pkt.rain_24h, locale)
{rain_since_midnight_value, _} = WeatherUnits.format_rain(pkt.rain_since_midnight, locale)
%{
timestamp: pkt.received_at,
temperature: temp_value,
dew_point: dew_value,
humidity: pkt.humidity,
pressure: pkt.pressure,
wind_direction: pkt.wind_direction,
wind_speed: wind_speed_value,
rain_1h: rain_1h_value,
rain_24h: rain_24h_value,
rain_since_midnight: rain_since_midnight_value,
luminosity: pkt.luminosity
}
end)
|> Jason.encode!()
end
@doc """ @doc """
Gets weather field value, returning "0" instead of "N/A" for missing numeric data. Gets weather field value, returning "0" instead of "N/A" for missing numeric data.
""" """
@ -255,78 +180,44 @@ defmodule AprsmeWeb.WeatherLive.CallsignView do
end end
end end
# Weather formatters mapping
@weather_formatters %{
temperature: {&WeatherUnits.format_temperature/2, ""},
wind_speed: {&WeatherUnits.format_wind_speed/2, " "},
wind_gust: {&WeatherUnits.format_wind_speed/2, " "},
rain_1h: {&WeatherUnits.format_rain/2, " "},
rain_24h: {&WeatherUnits.format_rain/2, " "},
rain_since_midnight: {&WeatherUnits.format_rain/2, " "}
}
@doc """ @doc """
Formats weather values with appropriate units based on locale. Formats weather values with appropriate units based on locale.
""" """
def format_weather_value(packet, key, locale) do def format_weather_value(packet, key, locale) do
value = PacketUtils.get_weather_field(packet, key) value = PacketUtils.get_weather_field(packet, key)
case {key, value} do case value do
{:temperature, value} when is_binary(value) and value != "N/A" -> "N/A" ->
case Float.parse(value) do "N/A"
{num_value, _} ->
{converted_value, unit} = WeatherUnits.format_temperature(num_value, locale)
"#{converted_value}#{unit}"
:error -> value when is_binary(value) ->
value case @weather_formatters[key] do
end {formatter, separator} ->
case Float.parse(value) do
{num_value, _} ->
{converted_value, unit} = formatter.(num_value, locale)
"#{converted_value}#{separator}#{unit}"
{:wind_speed, value} when is_binary(value) and value != "N/A" -> :error ->
case Float.parse(value) do value
{num_value, _} -> end
{converted_value, unit} = WeatherUnits.format_wind_speed(num_value, locale)
"#{converted_value} #{unit}"
:error -> nil ->
value "#{value}"
end
{:wind_gust, value} when is_binary(value) and value != "N/A" ->
case Float.parse(value) do
{num_value, _} ->
{converted_value, unit} = WeatherUnits.format_wind_speed(num_value, locale)
"#{converted_value} #{unit}"
:error ->
value
end
{:rain_1h, value} when is_binary(value) and value != "N/A" ->
case Float.parse(value) do
{num_value, _} ->
{converted_value, unit} = WeatherUnits.format_rain(num_value, locale)
"#{converted_value} #{unit}"
:error ->
value
end
{:rain_24h, value} when is_binary(value) and value != "N/A" ->
case Float.parse(value) do
{num_value, _} ->
{converted_value, unit} = WeatherUnits.format_rain(num_value, locale)
"#{converted_value} #{unit}"
:error ->
value
end
{:rain_since_midnight, value} when is_binary(value) and value != "N/A" ->
case Float.parse(value) do
{num_value, _} ->
{converted_value, unit} = WeatherUnits.format_rain(num_value, locale)
"#{converted_value} #{unit}"
:error ->
value
end end
_ -> _ ->
case value do "#{value}"
"N/A" -> "N/A"
_ -> "#{value}"
end
end end
end end
end end

View file

@ -0,0 +1,52 @@
defmodule AprsmeWeb.TimeUtils do
@moduledoc """
Common time calculation utilities to reduce duplication across the codebase.
"""
@doc """
Returns a DateTime that is the specified number of hours before now.
"""
def hours_ago(hours) when is_number(hours) do
DateTime.add(DateTime.utc_now(), -hours * 3600, :second)
end
@doc """
Returns a DateTime that is one hour before now.
"""
def one_hour_ago, do: hours_ago(1)
@doc """
Returns a DateTime that is 24 hours (one day) before now.
"""
def one_day_ago, do: hours_ago(24)
@doc """
Returns a DateTime that is 48 hours (two days) before now.
"""
def two_days_ago, do: hours_ago(48)
@doc """
Returns a DateTime that is 7 days (one week) before now.
"""
def one_week_ago, do: hours_ago(24 * 7)
@doc """
Returns a DateTime that is the specified number of days before now.
"""
def days_ago(days) when is_number(days) do
DateTime.add(DateTime.utc_now(), -days, :day)
end
@doc """
Returns a tuple of {start_time, end_time} for common time ranges.
"""
def time_range(:last_hour), do: {one_hour_ago(), DateTime.utc_now()}
def time_range(:last_day), do: {one_day_ago(), DateTime.utc_now()}
def time_range(:last_two_days), do: {two_days_ago(), DateTime.utc_now()}
def time_range(:last_week), do: {one_week_ago(), DateTime.utc_now()}
@doc """
Returns a default time range of 48 hours.
"""
def default_time_range, do: time_range(:last_two_days)
end

View file

@ -10,7 +10,6 @@ defmodule Aprsme.MixProject do
start_permanent: Mix.env() == :prod, start_permanent: Mix.env() == :prod,
aliases: aliases(), aliases: aliases(),
deps: deps(), deps: deps(),
test_coverage: [tool: ExCoveralls],
listeners: [Phoenix.CodeReloader], listeners: [Phoenix.CodeReloader],
dialyzer: [ dialyzer: [
ignore_warnings: ".dialyzer_ignore.exs", ignore_warnings: ".dialyzer_ignore.exs",

View file

@ -0,0 +1,63 @@
defmodule Aprsme.Repo.Migrations.OptimizeInfoWeatherPages do
use Ecto.Migration
@disable_ddl_transaction true
@disable_migration_lock true
def up do
# Critical index for get_other_ssids function on info page
create_if_not_exists(
index(:packets, [:base_callsign, :received_at],
name: :packets_base_callsign_received_at_idx,
concurrently: true,
comment: "Index for finding other SSIDs of same base callsign"
)
)
# Add spatial index for get_nearby_stations if not exists
# This is crucial for the ST_Distance calculations
execute """
CREATE INDEX CONCURRENTLY IF NOT EXISTS packets_location_spatial_idx
ON packets USING GIST (location)
WHERE has_position = true;
""",
""
# Optimize the get_nearby_stations query with a specialized index
# This combines spatial and temporal filtering
execute """
CREATE INDEX CONCURRENTLY IF NOT EXISTS packets_spatial_temporal_idx
ON packets (received_at DESC, base_callsign)
WHERE has_position = true;
""",
""
# Add index for weather page callsign history queries
# This optimizes the initial load of weather data
create_if_not_exists(
index(:packets, [:sender, :data_type, :received_at],
name: :packets_weather_history_idx,
where: "data_type = 'weather'",
concurrently: true,
comment: "Optimized index for weather history queries"
)
)
# Analyze tables to update statistics for query planner
execute "ANALYZE packets;", ""
end
def down do
drop_if_exists(
index(:packets, [:base_callsign, :received_at],
name: :packets_base_callsign_received_at_idx
)
)
execute "DROP INDEX CONCURRENTLY IF EXISTS packets_location_spatial_idx;", ""
execute "DROP INDEX CONCURRENTLY IF EXISTS packets_spatial_temporal_idx;", ""
drop_if_exists(
index(:packets, [:sender, :data_type, :received_at], name: :packets_weather_history_idx)
)
end
end

View file

@ -0,0 +1,32 @@
defmodule Aprsme.Repo.Migrations.AddSenderLowerIndex do
use Ecto.Migration
@disable_ddl_transaction true
@disable_migration_lock true
def up do
# Add functional index for case-insensitive sender searches
# This helps if we ever need to do ILIKE queries
execute """
CREATE INDEX CONCURRENTLY IF NOT EXISTS packets_sender_lower_idx
ON packets (LOWER(sender))
WHERE sender IS NOT NULL;
""",
""
# Also add a trigram index for more flexible pattern matching if needed
# Note: This requires pg_trgm extension
execute "CREATE EXTENSION IF NOT EXISTS pg_trgm;", ""
execute """
CREATE INDEX CONCURRENTLY IF NOT EXISTS packets_sender_trgm_idx
ON packets USING gin (sender gin_trgm_ops)
WHERE sender IS NOT NULL;
""",
""
end
def down do
execute "DROP INDEX CONCURRENTLY IF EXISTS packets_sender_lower_idx;", ""
execute "DROP INDEX CONCURRENTLY IF EXISTS packets_sender_trgm_idx;", ""
end
end

View file

@ -96,6 +96,12 @@ defmodule AprsmeWeb.Integration.AprsStatusTest do
describe "LiveView event handling without APRS" do describe "LiveView event handling without APRS" do
test "map events work without APRS connection", %{conn: conn} do test "map events work without APRS connection", %{conn: conn} do
# Set the mock to allow calls from any process
Mox.set_mox_global()
# Stub the function that will be called during bounds changes
Mox.stub(Aprsme.PacketsMock, :get_recent_packets_optimized, fn _opts -> [] end)
{:ok, view, _html} = live(conn, "/") {:ok, view, _html} = live(conn, "/")
# Test map bounds change event # Test map bounds change event
@ -111,6 +117,9 @@ defmodule AprsmeWeb.Integration.AprsStatusTest do
# Should not crash when handling events without APRS connection # Should not crash when handling events without APRS connection
assert render_hook(view, "bounds_changed", bounds_params) assert render_hook(view, "bounds_changed", bounds_params)
# Wait a bit for any async processes
Process.sleep(50)
# Test map ready event # Test map ready event
assert render_hook(view, "map_ready", %{}) assert render_hook(view, "map_ready", %{})

View file

@ -28,9 +28,9 @@ defmodule AprsmeWeb.MapLive.OverlayRenderingTest do
symbol_html = result["symbol_html"] symbol_html = result["symbol_html"]
# Verify the overlay symbol is rendered correctly with overlay character on top # Verify the overlay symbol is rendered correctly with overlay character on top
# The overlay character (D) should be first in the background-image list # The overlay character (D) from table 2, base symbol (&) from table 1
assert symbol_html =~ assert symbol_html =~
"background-image: url(/aprs-symbols/aprs-symbols-128-2@2x.png), url(/aprs-symbols/aprs-symbols-128-2@2x.png)" "background-image: url(/aprs-symbols/aprs-symbols-128-2@2x.png), url(/aprs-symbols/aprs-symbols-128-1@2x.png)"
# Verify the positions (overlay character D first at -96.0px -64.0px, then base & at -160.0px 0.0px) # Verify the positions (overlay character D first at -96.0px -64.0px, then base & at -160.0px 0.0px)
assert symbol_html =~ "background-position: -96.0px -64.0px, -160.0px 0.0px" assert symbol_html =~ "background-position: -96.0px -64.0px, -160.0px 0.0px"

View file

@ -4,12 +4,31 @@ defmodule AprsmeWeb.MapLive.PerformanceTest do
import Aprsme.PacketsFixtures import Aprsme.PacketsFixtures
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
alias Aprsme.Repo
describe "historical packet loading performance" do describe "historical packet loading performance" do
setup do
# Set up query counter using process dictionary
Process.put(:query_count, 0)
# Set up telemetry handler to count queries
:telemetry.attach(
"test-query-counter",
[:aprsme, :repo, :query],
fn _event, _measurements, _metadata, _config ->
Process.put(:query_count, (Process.get(:query_count) || 0) + 1)
end,
nil
)
on_exit(fn ->
:telemetry.detach("test-query-counter")
end)
:ok
end
test "efficiently loads historical packets without N+1 queries", %{conn: conn} do test "efficiently loads historical packets without N+1 queries", %{conn: conn} do
# Create test packets with different callsigns # Create test packets with different callsigns
callsigns = ["TEST1", "TEST2", "TEST3", "WEATHER1", "WEATHER2"] _callsigns = ["TEST1", "TEST2", "TEST3", "WEATHER1", "WEATHER2"]
# Create regular packets # Create regular packets
for callsign <- ["TEST1", "TEST2", "TEST3"] do for callsign <- ["TEST1", "TEST2", "TEST3"] do
@ -35,36 +54,32 @@ defmodule AprsmeWeb.MapLive.PerformanceTest do
}) })
end end
# Reset query counter before the test
Process.put(:query_count, 0)
# Load the live view # Load the live view
{:ok, lv, _html} = live(conn, "/") {:ok, lv, _html} = live(conn, "/")
# Trigger map ready which loads historical packets # Reset counter again to measure only map loading queries
# Count queries to ensure we're not doing N+1 queries Process.put(:query_count, 0)
query_count_before = get_query_count()
# Trigger map ready which loads historical packets
lv lv
|> element("#aprs-map") |> element("#aprs-map")
|> render_hook("map_ready", %{}) |> render_hook("map_ready", %{})
# Wait for historical packets to load # Wait for historical packets to load
:timer.sleep(100) :timer.sleep(200)
query_count_after = get_query_count() queries_executed = Process.get(:query_count) || 0
queries_executed = query_count_after - query_count_before
# Should execute only a few queries: # Should execute only a few queries:
# 1. Main packet query # 1. Initial packet query
# 2. Batch weather callsign query # 2. Batch weather callsign queries (if any)
# Plus maybe a few system queries # 3. Maybe some additional optimized queries
# But definitely not one query per callsign (which would be 5+ queries) # But definitely not one query per packet/callsign (which would be 5+ queries)
assert queries_executed < 10, "Too many queries executed: #{queries_executed}" # Given the batched loading, we expect more queries but they should be efficient
assert queries_executed < 20, "Too many queries executed: #{queries_executed}"
end end
end end
# Helper to get approximate query count from Repo stats
defp get_query_count do
# This is a simplified way to track queries
# In a real test, you might use Ecto telemetry or query logging
System.unique_integer([:positive])
end
end end

View file

@ -0,0 +1,54 @@
defmodule Aprsme.PacketsFixtures do
@moduledoc """
This module defines test helpers for creating
entities via the `Aprsme.Packets` context.
"""
alias Aprsme.Callsign
alias Aprsme.Packet
alias Aprsme.Repo
@doc """
Generate a packet.
"""
def packet_fixture(attrs \\ %{}) do
base_attrs = %{
sender: "TEST-1",
base_callsign: "TEST",
ssid: "1",
destination: "APRS",
received_at: DateTime.utc_now(),
lat: Decimal.new("40.7128"),
lon: Decimal.new("-74.0060"),
has_position: true,
raw_packet: "TEST-1>APRS:=4042.77N/07400.36W>Test packet",
data_type: "position",
path: "APRS",
data_extended: %{
symbol_table_id: "/",
symbol_code: ">",
comment: "Test packet"
}
}
# Extract base_callsign and ssid from sender if provided
final_attrs =
case Map.get(attrs, :sender) do
nil ->
base_attrs
sender ->
{base, ssid} = Callsign.extract_parts(sender)
Map.merge(base_attrs, %{base_callsign: base, ssid: ssid})
end
{:ok, packet} =
attrs
|> Enum.into(final_attrs)
|> then(&Packet.changeset(%Packet{}, &1))
|> Repo.insert()
packet
end
end

View file

@ -16,6 +16,14 @@ defmodule Aprsme.MockHelpers do
Mox.stub(Aprsme.PacketsMock, :get_packets_for_callsign_with_date_range, fn _callsign, _start_date, _end_date -> Mox.stub(Aprsme.PacketsMock, :get_packets_for_callsign_with_date_range, fn _callsign, _start_date, _end_date ->
{:ok, []} {:ok, []}
end) end)
Mox.stub(Aprsme.PacketsMock, :get_recent_packets_optimized, fn _opts ->
[]
end)
Mox.stub(Aprsme.PacketsMock, :get_nearby_stations, fn _lat, _lon, _exclude, _opts ->
[]
end)
end end
def stub_badpackets_mock do def stub_badpackets_mock do

View file

@ -6,6 +6,10 @@ Mox.defmock(Aprsme.PacketsMock, for: Aprsme.PacketsBehaviour)
Mox.defmock(Aprsme.PacketReplayMock, for: Aprsme.PacketReplayBehaviour) Mox.defmock(Aprsme.PacketReplayMock, for: Aprsme.PacketReplayBehaviour)
Mox.defmock(PacketsMock, for: Aprsme.PacketsBehaviour) Mox.defmock(PacketsMock, for: Aprsme.PacketsBehaviour)
# Set up default stubs for commonly used functions
Mox.stub(Aprsme.PacketsMock, :get_recent_packets_optimized, fn _opts -> [] end)
Mox.stub(Aprsme.PacketsMock, :get_nearby_stations, fn _lat, _lon, _exclude, _opts -> [] end)
# Ensure no external APRS connections during tests # Ensure no external APRS connections during tests
Application.put_env(:aprsme, :disable_aprs_connection, true) Application.put_env(:aprsme, :disable_aprs_connection, true)
Application.put_env(:aprsme, :aprs_is_server, "mock.aprs.test") Application.put_env(:aprsme, :aprs_is_server, "mock.aprs.test")