defmodule AprsWeb.PacketsLive.CallsignView do @moduledoc """ LiveView for displaying packets specific to a single callsign. Shows up to 100 packets total (stored + live) for the specified callsign. Includes both stored packets from the database (last hour) and live incoming packets. """ use AprsWeb, :live_view import Ecto.Query alias Aprs.EncodingUtils alias Aprs.Packet alias Aprs.Repo alias AprsWeb.Endpoint @impl true def mount(%{"callsign" => callsign}, _session, socket) do # Validate and normalize callsign normalized_callsign = String.upcase(String.trim(callsign)) if valid_callsign?(normalized_callsign) do # Subscribe to live packet updates if connected if connected?(socket) do Endpoint.subscribe("aprs_messages") end # Get stored packets for this callsign (up to 100) stored_packets = get_stored_packets(normalized_callsign, 100) socket = socket |> assign(:callsign, normalized_callsign) |> assign(:packets, stored_packets) |> assign(:live_packets, []) |> assign(:all_packets, stored_packets) |> assign(:error, nil) {:ok, socket} else socket = socket |> assign(:callsign, normalized_callsign) |> assign(:packets, []) |> assign(:live_packets, []) |> assign(:all_packets, []) |> assign(:error, "Invalid callsign format") {:ok, socket} end end @impl true def handle_info(%{event: "packet", payload: payload}, socket) do # Handle incoming live packets - only process if they match our callsign # Only process packets for this specific callsign if packet_matches_callsign?(payload, socket.assigns.callsign) do # Sanitize the packet to prevent JSON encoding errors sanitized_payload = EncodingUtils.sanitize_packet(payload) # Add to live packets and maintain combined limit of 100 current_live = socket.assigns.live_packets current_stored = socket.assigns.packets # Calculate how many total packets we have total_count = length(current_live) + length(current_stored) # If we're at or over the limit, remove the oldest stored packet {updated_stored, updated_live} = if total_count >= 100 do # Remove the oldest stored packet if we have any, otherwise remove oldest live case current_stored do [] -> # No stored packets, remove oldest live packet live_without_oldest = Enum.drop(current_live, -1) {current_stored, [sanitized_payload | live_without_oldest]} _ -> # Remove oldest stored packet stored_without_oldest = Enum.drop(current_stored, -1) {stored_without_oldest, [sanitized_payload | current_live]} end else # Under limit, just add the new packet {current_stored, [sanitized_payload | current_live]} end # Calculate combined packets for display all_packets = get_all_packets_list(updated_stored, updated_live) socket = socket |> assign(:packets, updated_stored) |> assign(:live_packets, updated_live) |> assign(:all_packets, all_packets) {:noreply, socket} else {:noreply, socket} end end @impl true def handle_info(_message, socket) do {:noreply, socket} end # Private helper functions # Get recent packets for this callsign from the database (all packets, not just position) # Returns packets from the last hour, filtered by callsign defp get_stored_packets(callsign, limit) do one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second) # Create query for all packets (not just position packets) for this callsign query = from p in Packet, where: p.received_at >= ^one_hour_ago, order_by: [desc: p.received_at], limit: ^limit # Apply callsign filter filtered_query = if String.contains?(callsign, "-") do # Exact match for callsign with SSID [base_call, ssid] = String.split(callsign, "-", parts: 2) from p in query, where: ilike(p.base_callsign, ^base_call) and ((is_nil(p.ssid) and ^ssid == "0") or p.ssid == ^ssid) else # Match base callsign exactly, regardless of SSID from p in query, where: ilike(p.base_callsign, ^callsign) end filtered_query |> Repo.all() |> Enum.map(&EncodingUtils.sanitize_packet/1) rescue error -> require Logger Logger.error("Failed to fetch stored packets for callsign #{callsign}: #{inspect(error)}") [] end defp packet_matches_callsign?(packet, target_callsign) do # Check if packet sender or base_callsign matches the target callsign # Supports both exact matches and SSID variants (e.g., "N0CALL" matches "N0CALL-1") sender = packet.sender || "" base_callsign = packet.base_callsign || "" # Convert to uppercase for case-insensitive comparison sender_upper = String.upcase(sender) base_upper = String.upcase(base_callsign) target_upper = String.upcase(target_callsign) # Check exact match for sender or base_callsign # Also check if the sender starts with the target callsign (for SSID variants) sender_upper == target_upper || base_upper == target_upper || String.starts_with?(sender_upper, target_upper <> "-") 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( fn packet -> 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, :desc ) # Ensure we never exceed 100 total |> Enum.take(100) 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 end