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 @flush_interval_ms 150 @impl true def mount(%{"callsign" => callsign}, _session, socket) do normalized = Callsign.normalize(callsign) if Callsign.valid?(normalized) do socket = subscribe_if_connected(socket, normalized) stored = get_stored_packets(normalized, 100) {:ok, assign_valid(socket, normalized, stored)} else {:ok, assign_invalid(socket, normalized)} end end defp subscribe_if_connected(socket, callsign) do _ = if connected?(socket) do Phoenix.PubSub.subscribe(Aprsme.PubSub, "packets:#{callsign}") end socket end defp assign_valid(socket, callsign, stored) do socket |> assign(:callsign, callsign) |> assign(:packets, stored) |> assign(:live_packets, []) |> assign(:all_packets, stored) |> assign(:error, nil) |> put_private(:packet_buffer, []) |> put_private(:flush_timer, nil) end defp assign_invalid(socket, callsign) do socket |> assign(:callsign, callsign) |> assign(:packets, []) |> assign(:live_packets, []) |> assign(:all_packets, []) |> assign(:error, "Invalid callsign format") end @impl true def handle_info({:postgres_packet, payload}, socket) do # Buffer in socket.private to avoid triggering a rerender on every packet. # A periodic flush timer drains the buffer and updates assigns in one batch, # which keeps client-side morphdom from being overwhelmed during high-traffic bursts. socket = update_in(socket.private.packet_buffer, &[payload | &1]) socket = ensure_flush_timer(socket) {:noreply, socket} end @impl true def handle_info(:flush_packets, socket) do packets = Enum.reverse(socket.private.packet_buffer) socket = put_private(socket, :packet_buffer, []) socket = put_private(socket, :flush_timer, nil) {updated_stored, updated_live} = packets # Sanitize encoding first, then batch enrich with device info # (enrich_packets_with_device_info batches device lookups internally) |> Enum.map(&EncodingUtils.sanitize_packet/1) |> SharedPacketHandler.enrich_packets_with_device_info() # Add canonical device identifier per-packet |> Enum.map(&enrich_packet_with_device_identifier/1) # Accumulate through the packet list update logic |> Enum.reduce({socket.assigns.packets, socket.assigns.live_packets}, fn enriched, {stored, live} -> update_packet_lists(stored, live, enriched) end) all_packets = get_all_packets_list(updated_stored, updated_live) {:noreply, assign(socket, packets: updated_stored, live_packets: updated_live, all_packets: all_packets )} end def handle_info(_message, socket), do: {:noreply, socket} defp ensure_flush_timer(socket) do if socket.private.flush_timer do socket else timer = Process.send_after(self(), :flush_packets, @flush_interval_ms) put_private(socket, :flush_timer, timer) end 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 = resolve_canonical_identifier(device_identifier) Map.put(sanitized_payload, :device_identifier, canonical_identifier) end defp resolve_canonical_identifier(identifier) when is_binary(identifier) do case Aprsme.DeviceIdentification.lookup_device_by_identifier(identifier) do %{identifier: canonical} -> canonical nil -> identifier end end defp resolve_canonical_identifier(identifier), do: identifier # 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(%DateTime{} = dt), do: DateTime.to_unix(dt, :microsecond) defp get_timestamp_microseconds(dt) when is_binary(dt) do case DateTime.from_iso8601(dt) do {:ok, parsed_dt, _} -> DateTime.to_unix(parsed_dt, :microsecond) _ -> 0 end end defp get_timestamp_microseconds(_), do: 0 # Helper to update stored and live packet lists, keeping total <= 100. # Dispatches on capacity: at-cap with no stored, at-cap with stored, under-cap. defp update_packet_lists(current_stored, current_live, sanitized_payload) do total = length(current_live) + length(current_stored) do_update(total, current_stored, current_live, sanitized_payload) end defp do_update(total, [], current_live, payload) when total >= 100, do: {[], [payload | Enum.drop(current_live, -1)]} defp do_update(total, current_stored, current_live, payload) when total >= 100, do: {Enum.drop(current_stored, -1), [payload | current_live]} defp do_update(_total, current_stored, current_live, payload), do: {current_stored, [payload | current_live]} end