Turned on :error_handling, :underspecs, and :unmatched_returns in mix.exs dialyzer config. The 97 warnings this surfaced were fixed in place rather than suppressed: - unmatched_return (79): explicit discard with `_ = ...` for fire-and-forget side effects (Process.cancel_timer, :ets.new, send/2), and pattern-matched `:ok = ...` for control-plane Phoenix.PubSub subscribe/unsubscribe/broadcast calls so a future return-shape change fails loud. - contract_supertype (18): tightened @spec arg and return types on data_builder, historical_loader, url_params, packet_utils, encoding_utils, aprs_symbol, weather_controller, packet_replay to match each function's actual success typing. No behavioural change. mix compile clean, 1008 tests pass, dialyzer count is now 0.
189 lines
6.2 KiB
Elixir
189 lines
6.2 KiB
Elixir
defmodule AprsmeWeb.PacketsLive.CallsignView do
|
|
@moduledoc false
|
|
use AprsmeWeb, :live_view
|
|
|
|
import Ecto.Query
|
|
|
|
alias Aprsme.Callsign
|
|
alias Aprsme.DeviceParser
|
|
alias Aprsme.EncodingUtils
|
|
alias Aprsme.Packet
|
|
alias Aprsme.Repo
|
|
alias AprsmeWeb.Live.SharedPacketHandler
|
|
|
|
@impl true
|
|
def mount(%{"callsign" => callsign}, _session, socket) do
|
|
# Validate and normalize callsign
|
|
normalized_callsign = Callsign.normalize(callsign)
|
|
|
|
if Callsign.valid?(normalized_callsign) do
|
|
# Subscribe to live packet updates for this callsign only
|
|
_ =
|
|
if connected?(socket) do
|
|
Phoenix.PubSub.subscribe(Aprsme.PubSub, "packets:#{normalized_callsign}")
|
|
end
|
|
|
|
# Get stored packets for this callsign (up to 100)
|
|
stored_packets = get_stored_packets(normalized_callsign, 100)
|
|
all_packets = stored_packets
|
|
latest_packet = List.first(all_packets)
|
|
{symbol_table_id, symbol_code} = extract_symbol_info(latest_packet)
|
|
|
|
socket =
|
|
socket
|
|
|> assign(:callsign, normalized_callsign)
|
|
|> assign(:packets, stored_packets)
|
|
|> assign(:live_packets, [])
|
|
|> assign(:all_packets, all_packets)
|
|
|> assign(:latest_symbol_table_id, symbol_table_id)
|
|
|> assign(:latest_symbol_code, symbol_code)
|
|
|> assign(:error, nil)
|
|
|
|
{:ok, socket}
|
|
else
|
|
socket =
|
|
socket
|
|
|> assign(:callsign, normalized_callsign)
|
|
|> assign(:packets, [])
|
|
|> assign(:live_packets, [])
|
|
|> assign(:all_packets, [])
|
|
|> assign(:latest_symbol_table_id, "/")
|
|
|> assign(:latest_symbol_code, ">")
|
|
|> assign(:error, "Invalid callsign format")
|
|
|
|
{:ok, socket}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:postgres_packet, payload}, socket) do
|
|
SharedPacketHandler.handle_packet_update(payload, socket,
|
|
filter_fn: fn _packet, _socket -> true end,
|
|
process_fn: &process_matching_packet/2
|
|
)
|
|
end
|
|
|
|
def handle_info(_message, socket), do: {:noreply, socket}
|
|
|
|
defp process_matching_packet(enriched_payload, socket) do
|
|
# Device identifier enrichment specific to this module
|
|
enriched_payload = enrich_packet_with_device_identifier(enriched_payload)
|
|
|
|
current_live = socket.assigns.live_packets
|
|
current_stored = socket.assigns.packets
|
|
|
|
{updated_stored, updated_live} = update_packet_lists(current_stored, current_live, enriched_payload)
|
|
all_packets = get_all_packets_list(updated_stored, updated_live)
|
|
latest_packet = List.first(all_packets)
|
|
{symbol_table_id, symbol_code} = extract_symbol_info(latest_packet)
|
|
|
|
socket =
|
|
socket
|
|
|> assign(:packets, updated_stored)
|
|
|> assign(:live_packets, updated_live)
|
|
|> assign(:all_packets, all_packets)
|
|
|> assign(:latest_symbol_table_id, symbol_table_id)
|
|
|> assign(:latest_symbol_code, symbol_code)
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
defp enrich_packet_with_device_identifier(sanitized_payload) do
|
|
device_identifier =
|
|
Map.get(sanitized_payload, :device_identifier) ||
|
|
Map.get(sanitized_payload, "device_identifier") ||
|
|
DeviceParser.extract_device_identifier(sanitized_payload)
|
|
|
|
canonical_identifier =
|
|
if is_binary(device_identifier) do
|
|
matched_device = Aprsme.DeviceIdentification.lookup_device_by_identifier(device_identifier)
|
|
if matched_device, do: matched_device.identifier, else: device_identifier
|
|
else
|
|
device_identifier
|
|
end
|
|
|
|
Map.put(sanitized_payload, :device_identifier, canonical_identifier)
|
|
end
|
|
|
|
# Private helper functions
|
|
|
|
# Get recent packets for this callsign from the database (all packets, not just position)
|
|
# Returns the latest packets regardless of age, filtered by callsign
|
|
defp get_stored_packets(callsign, limit) do
|
|
# Create query for all packets (not just position packets) for this callsign
|
|
query =
|
|
from p in Packet,
|
|
order_by: [desc: p.received_at],
|
|
limit: ^limit
|
|
|
|
# Apply callsign filter using sender field for exact matching
|
|
# Use functional index on upper(sender) for performance
|
|
normalized_callsign = String.upcase(String.trim(callsign))
|
|
filtered_query = from p in query, where: fragment("upper(?)", p.sender) == ^normalized_callsign
|
|
|
|
filtered_query
|
|
|> Repo.all()
|
|
|> Enum.map(&EncodingUtils.sanitize_packet/1)
|
|
|> SharedPacketHandler.enrich_packets_with_device_info()
|
|
rescue
|
|
error ->
|
|
require Logger
|
|
|
|
Logger.error("Failed to fetch stored packets for callsign #{callsign}: #{inspect(error)}")
|
|
[]
|
|
end
|
|
|
|
# Helper to get all packets (stored + live) in chronological order
|
|
# Combines stored and live packets, sorts by timestamp (newest first), and limits to 100
|
|
defp get_all_packets_list(stored, live) do
|
|
# Combine and sort by received_at timestamp (newest first)
|
|
(live ++ stored)
|
|
|> Enum.sort_by(&get_timestamp_microseconds/1, :desc)
|
|
# Ensure we never exceed 100 total
|
|
|> Enum.take(100)
|
|
end
|
|
|
|
defp get_timestamp_microseconds(packet) do
|
|
case packet.received_at do
|
|
%DateTime{} = dt ->
|
|
DateTime.to_unix(dt, :microsecond)
|
|
|
|
dt when is_binary(dt) ->
|
|
case DateTime.from_iso8601(dt) do
|
|
{:ok, parsed_dt, _} -> DateTime.to_unix(parsed_dt, :microsecond)
|
|
_ -> 0
|
|
end
|
|
|
|
_ ->
|
|
0
|
|
end
|
|
end
|
|
|
|
# Helper to update stored and live packet lists, keeping total <= 100
|
|
defp update_packet_lists(current_stored, current_live, sanitized_payload) do
|
|
total_count = length(current_live) + length(current_stored)
|
|
|
|
cond do
|
|
total_count >= 100 and current_stored == [] ->
|
|
live_without_oldest = Enum.drop(current_live, -1)
|
|
{current_stored, [sanitized_payload | live_without_oldest]}
|
|
|
|
total_count >= 100 ->
|
|
stored_without_oldest = Enum.drop(current_stored, -1)
|
|
{stored_without_oldest, [sanitized_payload | current_live]}
|
|
|
|
true ->
|
|
{current_stored, [sanitized_payload | current_live]}
|
|
end
|
|
end
|
|
|
|
# Helper to extract symbol table and code from a packet
|
|
defp extract_symbol_info(nil), do: {"/", ">"}
|
|
|
|
defp extract_symbol_info(packet) do
|
|
data = Map.get(packet, :data_extended) || %{}
|
|
table = Map.get(data, :symbol_table_id) || Map.get(data, "symbol_table_id") || "/"
|
|
code = Map.get(data, :symbol_code) || Map.get(data, "symbol_code") || ">"
|
|
{table, code}
|
|
end
|
|
end
|