- Delete dead config keys and test file (typo'd aprsme_is_*, vacuous disable test) - Remove duplicate function clauses and identical function pairs - Rename misleading one_hour_ago variable to one_day_ago - Strip stale Oban comment - Remove TestHelpers time function duplicates - Inline Aprsme.Schema module (only 1 caller) - Deduplicate prod esbuild config - Thin SymbolRenderer delegations, inline TimeUtils wrappers - Remove redundant DeploymentNotifier GenServer polling - Replace random fallback in get_callsign_key with sentinel
282 lines
8.2 KiB
Elixir
282 lines
8.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.
|
|
Returns the display name, or "__unknown__" if none is available.
|
|
"""
|
|
@spec get_callsign_key(map()) :: binary()
|
|
def get_callsign_key(packet) when is_map(packet) do
|
|
case display_name(packet) do
|
|
"" -> "__unknown__"
|
|
callsign -> callsign
|
|
end
|
|
end
|
|
|
|
def get_callsign_key(_packet), do: "__unknown__"
|
|
|
|
@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(any()) :: 1 | 6 | 12 | 24 | 48 | 168
|
|
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(any()) :: 1 | 3 | 6 | 12 | 24
|
|
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.
|
|
Uses the pre-computed has_weather boolean when available (DB structs),
|
|
falls back to field-by-field scan for live PubSub payloads.
|
|
"""
|
|
@spec has_weather_data?(map()) :: boolean()
|
|
def has_weather_data?(packet) do
|
|
case Map.get(packet, :has_weather) || Map.get(packet, "has_weather") do
|
|
nil ->
|
|
Enum.any?(Aprsme.EncodingUtils.weather_fields(), fn field ->
|
|
value = get_packet_field(packet, field, nil)
|
|
not is_nil(value)
|
|
end)
|
|
|
|
bool when is_boolean(bool) ->
|
|
bool
|
|
end
|
|
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.
|
|
"""
|
|
@spec get_packet_field(map(), atom() | String.t(), any()) :: any()
|
|
def get_packet_field(packet, field, default \\ nil) do
|
|
case Map.get(packet, field) do
|
|
nil ->
|
|
case Map.get(packet, to_string(field)) do
|
|
nil ->
|
|
de =
|
|
Map.get(packet, :data_extended) ||
|
|
Map.get(packet, "data_extended", %{}) || %{}
|
|
|
|
Map.get(de, field) || Map.get(de, to_string(field)) || default
|
|
|
|
val ->
|
|
val
|
|
end
|
|
|
|
val ->
|
|
val
|
|
end
|
|
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
|
|
|
|
@doc """
|
|
Get the map label for a packet.
|
|
|
|
For objects and items, show the object/item name to distinguish them from
|
|
the station that created them. For example, a repeater object should show
|
|
the repeater name, not the station that's reporting it.
|
|
|
|
For position packets, show the sender callsign.
|
|
"""
|
|
@spec map_label(map()) :: String.t()
|
|
def map_label(packet) do
|
|
map_label_for(get_packet_field(packet, :data_type, ""), packet)
|
|
end
|
|
|
|
defp map_label_for("object", packet) do
|
|
prefer_name(get_packet_field(packet, :object_name, ""), packet)
|
|
end
|
|
|
|
defp map_label_for("item", packet) do
|
|
prefer_name(get_packet_field(packet, :item_name, ""), packet)
|
|
end
|
|
|
|
defp map_label_for(_data_type, packet), do: get_packet_field(packet, :sender, "")
|
|
|
|
# Use `name` when non-empty, otherwise fall back to the packet sender.
|
|
defp prefer_name("", packet), do: get_packet_field(packet, :sender, "")
|
|
defp prefer_name(name, _packet), do: name
|
|
|
|
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
|