aprs.me/lib/aprsme_web/live/shared/packet_utils.ex
Graham McIntire 68f64f3699
Display APRS object/item names instead of sender callsign
APRS object packets (e.g. DAPNET transmitters) were showing the
sender's callsign on the map instead of the object name. For example,
DB0SDA (Germany) sending an object for P-K5SGD (Texas) would display
as "DB0SDA" at the Texas coordinates.

- Add display_name/1 to PacketUtils that returns object_name for
  objects, item_name for items, falling back to sender
- Update DataBuilder to use display_name in all callsign display paths
- Fix object/item detection order in packet_consumer to prevent
  objects from being incorrectly flagged as items
- Add DataBuilder tests for object/item display name behavior
- Fix flaky PacketPipelineSupervisor test (ensure module loaded)
2026-02-19 09:13:03 -06:00

251 lines
7.2 KiB
Elixir

defmodule AprsmeWeb.Live.Shared.PacketUtils do
@moduledoc """
Shared packet processing and memory management utilities.
Used across multiple LiveView modules for consistent packet handling.
"""
alias AprsmeWeb.Live.Shared.BoundsUtils
@doc """
Get unique callsign key from packet.
"""
@spec get_callsign_key(map()) :: binary()
def get_callsign_key(%{"id" => id}), do: to_string(id)
def get_callsign_key(_packet), do: [:positive] |> System.unique_integer() |> to_string()
@doc """
Prune oldest packets from a map to enforce memory limits.
"""
@spec prune_oldest_packets(map(), integer()) :: map()
def prune_oldest_packets(packets_map, max_count) when map_size(packets_map) <= max_count do
packets_map
end
def prune_oldest_packets(packets_map, max_count) do
# Convert to list, sort by timestamp, take newest max_count packets
packets_map
|> Enum.to_list()
|> Enum.sort_by(
fn {_key, packet} ->
case packet do
%{received_at: %DateTime{} = dt} -> DateTime.to_unix(dt)
%{"received_at" => timestamp} when is_integer(timestamp) -> timestamp
_ -> 0
end
end,
:desc
)
|> Enum.take(max_count)
|> Map.new()
end
@doc """
Parse trail duration with validation and bounds checking.
"""
@spec parse_trail_duration(binary() | any()) :: integer()
def parse_trail_duration(duration) when is_binary(duration) do
case Integer.parse(duration) do
{hours, ""} when hours in [1, 6, 12, 24, 48, 168] ->
hours
_ ->
# Default to 1 hour if invalid
1
end
end
def parse_trail_duration(_), do: 1
@doc """
Parse historical hours with validation.
"""
@spec parse_historical_hours(binary() | any()) :: integer()
def parse_historical_hours(hours) when is_binary(hours) do
case Integer.parse(hours) do
{h, ""} when h in [1, 3, 6, 12, 24] ->
h
_ ->
# Default to 1 hour if invalid
1
end
end
def parse_historical_hours(_), do: 1
@doc """
Filter packets by both time threshold and bounds.
"""
@spec filter_packets_by_time_and_bounds(map(), map(), DateTime.t()) :: map()
def filter_packets_by_time_and_bounds(packets, bounds, time_threshold) do
packets
|> Enum.filter(fn {_callsign, packet} ->
BoundsUtils.within_bounds?(packet, bounds) &&
packet_within_time_threshold?(packet, time_threshold)
end)
|> Map.new()
end
@doc """
Check if packet contains weather data.
"""
@spec has_weather_data?(map()) :: boolean()
def has_weather_data?(packet) do
weather_fields = [
:temperature,
:humidity,
:pressure,
:wind_speed,
:wind_direction,
:rain_1h,
:rain_24h,
:rain_since_midnight,
:snow,
:luminosity
]
Enum.any?(weather_fields, fn field ->
value = get_packet_field(packet, field, nil)
not is_nil(value)
end)
end
@doc """
Check if packet is a weather packet based on its data.
"""
@spec weather_packet?(map()) :: boolean()
def weather_packet?(packet) do
has_weather_data?(packet)
end
@doc """
Get weather field value from packet with fallback.
"""
@spec get_weather_field(map(), atom()) :: any()
def get_weather_field(packet, field) do
data_extended = Map.get(packet, :data_extended, Map.get(packet, "data_extended", %{})) || %{}
Map.get(packet, field) ||
Map.get(packet, to_string(field)) ||
Map.get(data_extended, field) ||
Map.get(data_extended, to_string(field))
end
@doc """
Get packet field value with fallback and default.
Checks both atom and string keys, and data_extended field.
"""
@spec get_packet_field(map(), atom() | String.t(), any()) :: any()
def get_packet_field(packet, field, default \\ nil) do
data_extended = Map.get(packet, :data_extended, Map.get(packet, "data_extended", %{})) || %{}
Map.get(packet, field) ||
Map.get(packet, to_string(field)) ||
Map.get(data_extended, field) ||
Map.get(data_extended, to_string(field)) ||
default
end
# Private helper functions
@doc """
Check if a packet is within the time threshold (not too old).
"""
@spec packet_within_time_threshold?(struct(), any()) :: boolean()
def packet_within_time_threshold?(%{received_at: received_at}, threshold) when not is_nil(received_at) do
threshold_dt = convert_threshold_to_datetime(threshold)
DateTime.compare(received_at, threshold_dt) in [:gt, :eq]
end
def packet_within_time_threshold?(_packet, _threshold), do: true
defp convert_threshold_to_datetime(threshold) when is_integer(threshold) do
# Assume seconds since epoch
DateTime.from_unix!(threshold)
end
defp convert_threshold_to_datetime(threshold) when is_binary(threshold) do
case DateTime.from_iso8601(threshold) do
{:ok, dt, _} -> dt
_ -> DateTime.utc_now()
end
end
defp convert_threshold_to_datetime(%DateTime{} = threshold), do: threshold
defp convert_threshold_to_datetime(_), do: DateTime.utc_now()
@doc """
Extracts symbol information from a packet with fallbacks.
"""
@spec get_symbol_info(map()) :: {String.t(), String.t()}
def get_symbol_info(packet) do
AprsmeWeb.AprsSymbol.extract_from_packet(packet)
end
@doc """
Extracts timestamp from a packet in ISO8601 format.
"""
@spec get_timestamp(map()) :: String.t()
def get_timestamp(packet) do
received_at = Map.get(packet, :received_at) || Map.get(packet, "received_at")
case received_at do
nil ->
""
%DateTime{} = dt ->
DateTime.to_iso8601(dt)
%NaiveDateTime{} = ndt ->
ndt
|> DateTime.from_naive!("Etc/UTC")
|> DateTime.to_iso8601()
timestamp when is_binary(timestamp) ->
# If it's already a string, try to parse and reformat it
case DateTime.from_iso8601(timestamp) do
{:ok, dt, _} -> DateTime.to_iso8601(dt)
_ -> timestamp
end
_ ->
""
end
end
@doc """
Generates a callsign string with SSID if present.
"""
@spec generate_callsign(map()) :: String.t()
def generate_callsign(packet) do
base_callsign = get_packet_field(packet, :base_callsign, "")
ssid = get_packet_field(packet, :ssid, nil)
format_callsign_with_ssid(base_callsign, ssid)
end
@doc """
Returns the display name for a packet.
For APRS objects, returns the object_name.
For APRS items, returns the item_name.
Otherwise, falls back to the sender callsign.
"""
@spec display_name(map()) :: String.t()
def display_name(packet) do
object_name = get_packet_field(packet, :object_name, nil)
item_name = get_packet_field(packet, :item_name, nil)
cond do
non_empty_string?(object_name) -> String.trim(object_name)
non_empty_string?(item_name) -> String.trim(item_name)
true -> get_packet_field(packet, :sender, "")
end
end
defp non_empty_string?(value) when is_binary(value), do: String.trim(value) != ""
defp non_empty_string?(_), do: false
defp format_callsign_with_ssid(base_callsign, nil), do: base_callsign
defp format_callsign_with_ssid(base_callsign, ""), do: base_callsign
defp format_callsign_with_ssid(base_callsign, ssid), do: "#{base_callsign}-#{ssid}"
end