defmodule AprsmeWeb.InfoLive.Show do @moduledoc false use AprsmeWeb, :live_view use Gettext, backend: AprsmeWeb.Gettext import AprsmeWeb.Components.InfoMapComponent import Ecto.Query import Phoenix.HTML, only: [raw: 1] alias Aprsme.Callsign alias Aprsme.EncodingUtils alias Aprsme.GeoUtils alias Aprsme.Packets alias Aprsme.Repo alias AprsmeWeb.AprsSymbol alias AprsmeWeb.Live.SharedPacketHandler alias AprsmeWeb.MapLive.PacketUtils require Logger @neighbor_limit 10 # APRS Q-construct descriptions (APRS-IS codes) @q_constructs %{ "qAC" => "qAC (APRS-IS connection)", "qAO" => "qAO (APRS-IS origin)", "qAR" => "qAR (APRS-IS relay)", "qAS" => "qAS (APRS-IS server)", "qAX" => "qAX (APRS-IS client)", "qAY" => "qAY (APRS-IS gateway)", "qAZ" => "qAZ (APRS-IS zone)", "qBU" => "qBU (APRS-IS user)", "qBV" => "qBV (APRS-IS vendor)", "qBW" => "qBW (APRS-IS web)", "qBX" => "qBX (APRS-IS experimental)", "qBY" => "qBY (APRS-IS Y2K)", "qBZ" => "qBZ (APRS-IS Zulu)", "qCA" => "qCA (APRS-IS client application)", "qCB" => "qCB (APRS-IS client browser)", "qCC" => "qCC (APRS-IS client console)", "qCD" => "qCD (APRS-IS client daemon)", "qCE" => "qCE (APRS-IS client editor)", "qCF" => "qCF (APRS-IS client filter)", "qCG" => "qCG (APRS-IS client gateway)", "qCH" => "qCH (APRS-IS client host)", "qCI" => "qCI (APRS-IS client interface)", "qCJ" => "qCJ (APRS-IS client java)", "qCK" => "qCK (APRS-IS client kernel)", "qCL" => "qCL (APRS-IS client library)", "qCM" => "qCM (APRS-IS client module)", "qCN" => "qCN (APRS-IS client network)", "qCO" => "qCO (APRS-IS client object)", "qCP" => "qCP (APRS-IS client protocol)", "qCQ" => "qCQ (APRS-IS client query)", "qCR" => "qCR (APRS-IS client router)", "qCS" => "qCS (APRS-IS client server)", "qCT" => "qCT (APRS-IS client terminal)", "qCU" => "qCU (APRS-IS client user)", "qCV" => "qCV (APRS-IS client vendor)", "qCW" => "qCW (APRS-IS client web)", "qCX" => "qCX (APRS-IS client experimental)", "qCY" => "qCY (APRS-IS client Y2K)", "qCZ" => "qCZ (APRS-IS client Zulu)" } @impl true def mount(%{"callsign" => callsign}, _session, socket) do normalized_callsign = Callsign.normalize(callsign) # Subscribe to callsign-specific topic for live updates _ = if connected?(socket) do Phoenix.PubSub.subscribe(Aprsme.PubSub, "packets:#{normalized_callsign}") end packet = get_latest_packet(normalized_callsign) packet = if packet, do: SharedPacketHandler.enrich_with_device_info(packet) # Get locale from socket assigns (set by LocaleHook) locale = Map.get(socket.assigns, :locale, "en") neighbors = get_neighbors(packet, normalized_callsign, locale) has_weather_packets = PacketUtils.has_weather_packets?(normalized_callsign) other_ssids = Packets.get_other_ssids(normalized_callsign) socket = socket |> assign(:callsign, normalized_callsign) |> assign(:packet, packet) |> assign(:neighbors, neighbors) |> assign(:heard_by_stations, []) |> assign(:stations_heard_by, []) |> assign(:page_title, "APRS station #{normalized_callsign}") |> assign(:has_weather_packets, has_weather_packets) |> assign(:other_ssids, other_ssids) send(self(), :load_aggregations) {:ok, socket} end @impl true def handle_info({:postgres_packet, packet}, socket) do # Since we're subscribed to callsign-specific topic, no need to filter # The packet from PostgreSQL notify already contains all fields process_packet_update(packet, socket) end @impl true def handle_info(:load_aggregations, socket) do locale = Map.get(socket.assigns, :locale, "en") heard_by_stations = get_heard_by_stations(socket.assigns.callsign, locale) stations_heard_by = get_stations_heard_by(socket.assigns.callsign, locale) {:noreply, assign(socket, heard_by_stations: heard_by_stations, stations_heard_by: stations_heard_by)} end def handle_info(_message, socket), do: {:noreply, socket} defp process_packet_update(incoming_packet, socket) do Logger.debug( "InfoLive received packet update for #{socket.assigns.callsign}: #{inspect(Map.get(incoming_packet, "raw_packet"))}" ) # Get the new packet data new_packet = get_latest_packet(socket.assigns.callsign) new_packet = if new_packet, do: SharedPacketHandler.enrich_with_device_info(new_packet) current_packet = socket.assigns.packet # Check if this is a position update by comparing location and other key fields position_changed = position_changed?(current_packet, new_packet) if position_changed do # Only update position-related data if position changed # Get locale from socket assigns locale = Map.get(socket.assigns, :locale, "en") neighbors = get_neighbors(new_packet, socket.assigns.callsign, locale) socket = socket |> assign(:packet, new_packet) |> assign(:neighbors, neighbors) {:noreply, socket} else # Just update the packet data (for timestamp, comment, etc.) without affecting neighbors socket = assign(socket, :packet, new_packet) {:noreply, socket} end end @spec position_changed?(map() | nil, map() | nil) :: boolean() defp position_changed?(nil, _new_packet), do: true defp position_changed?(_current_packet, nil), do: false defp position_changed?(current_packet, new_packet) do coords_changed?({ EncodingUtils.to_float(current_packet.lat), EncodingUtils.to_float(current_packet.lon), EncodingUtils.to_float(new_packet.lat), EncodingUtils.to_float(new_packet.lon) }) end @spec coords_changed?({float() | nil, float() | nil, float() | nil, float() | nil}) :: boolean() defp coords_changed?({curr_lat, curr_lon, new_lat, new_lon}) when is_number(curr_lat) and is_number(curr_lon) and is_number(new_lat) and is_number(new_lon) do threshold = Application.get_env(:aprsme, :position_tracking, [])[:change_threshold] || 0.001 abs(curr_lat - new_lat) > threshold or abs(curr_lon - new_lon) > threshold end defp coords_changed?(_), do: true # Expose for testing if Mix.env() == :test do def position_changed_for_test(current, new), do: position_changed?(current, new) end defp get_latest_packet(callsign) do # Get the most recent packet for this callsign, regardless of type # This ensures we show the most recent activity, not just position packets packet = Packets.get_latest_packet_for_callsign(callsign) # If this packet doesn't have position data, try to get the latest position packet if packet && (is_nil(packet.lat) || is_nil(packet.lon)) do position_packet = get_latest_position_packet(callsign) if position_packet do # Merge position data from position packet into the latest packet %{packet | lat: position_packet.lat, lon: position_packet.lon, location: position_packet.location} else packet end else packet end end defp get_latest_position_packet(callsign) do # Get the most recent packet with valid position data Repo.one( from(p in Aprsme.Packet, where: fragment("upper(?)", p.sender) == ^String.upcase(String.trim(callsign)), where: not is_nil(p.lat) and not is_nil(p.lon), order_by: [desc: p.received_at], limit: 1, select: %{p | lat: fragment("ST_Y(?)", p.location), lon: fragment("ST_X(?)", p.location)} ) ) end defp get_neighbors(nil, _callsign, _locale), do: [] defp get_neighbors(packet, callsign, locale) do lat = packet.lat lon = packet.lon if is_nil(lat) or is_nil(lon) do [] else # Convert Decimal to float if needed lat_float = to_float(lat) lon_float = to_float(lon) # Use the spatial query to get the closest stations # The query already returns them sorted by distance nearby_packets = Packets.get_nearby_stations( lat_float, lon_float, callsign, %{limit: @neighbor_limit} ) # Calculate distance and course for each neighbor # The packets are already sorted by distance from the database Enum.map(nearby_packets, fn p -> dist = haversine(lat, lon, p.lat, p.lon) course = calculate_course(lat, lon, p.lat, p.lon) %{ callsign: p.sender, distance: format_distance(dist, locale), distance_km: dist, course: course, last_heard: format_timestamp_for_display(p), packet: p } end) end end @typep timestamp_display :: %{ time_ago: String.t(), formatted: String.t(), timestamp: String.t() | nil } @spec haversine(any(), any(), any(), any()) :: float() def haversine(lat1, lon1, lat2, lon2) do # Returns distance in km. Decimals are converted to float; nil/invalid inputs become 0.0. GeoUtils.haversine_distance(to_float(lat1), to_float(lon1), to_float(lat2), to_float(lon2)) / 1000 end @spec to_float(any()) :: float() defp to_float(value), do: EncodingUtils.to_float(value) || 0.0 @spec format_timestamp_for_display(map()) :: timestamp_display() defp format_timestamp_for_display(packet) do packet |> get_received_at() |> AprsmeWeb.TimeHelpers.to_datetime() |> build_timestamp_display() end @spec build_timestamp_display(DateTime.t() | nil) :: timestamp_display() defp build_timestamp_display(nil), do: %{time_ago: gettext("Unknown"), formatted: "", timestamp: nil} defp build_timestamp_display(%DateTime{} = dt) do %{ time_ago: AprsmeWeb.TimeHelpers.time_ago_in_words(dt), formatted: Calendar.strftime(dt, "%Y-%m-%d %H:%M:%S UTC"), timestamp: DateTime.to_iso8601(dt) } end @spec get_received_at(map()) :: DateTime.t() | NaiveDateTime.t() | String.t() | integer() | nil defp get_received_at(%{received_at: value}), do: value defp get_received_at(%{"received_at" => value}), do: value defp get_received_at(_), do: nil # English locale uses imperial (ft/mi), everything else uses metric (m/km). def format_distance(km, locale \\ "en") def format_distance(km, "en") do miles = Aprsme.Convert.kph_to_mph(km) format_imperial_distance(miles) end def format_distance(km, _locale), do: format_metric_distance(km) defp format_imperial_distance(miles) when miles < 1.0, do: "#{Float.round(miles * 5280, 0)} ft" defp format_imperial_distance(miles), do: "#{Float.round(miles, 2)} mi" defp format_metric_distance(km) when km < 1.0, do: "#{Float.round(km * 1000, 0)} m" defp format_metric_distance(km), do: "#{Float.round(km, 2)} km" def calculate_course(lat1, lon1, lat2, lon2) do # Convert Decimal to float if needed. lat1 = to_float(lat1) lon1 = to_float(lon1) lat2 = to_float(lat2) lon2 = to_float(lon2) dlon = :math.pi() / 180 * (lon2 - lon1) lat1_rad = :math.pi() / 180 * lat1 lat2_rad = :math.pi() / 180 * lat2 y = :math.sin(dlon) * :math.cos(lat2_rad) x = :math.cos(lat1_rad) * :math.sin(lat2_rad) - :math.sin(lat1_rad) * :math.cos(lat2_rad) * :math.cos(dlon) normalize_bearing(:math.atan2(y, x) * 180 / :math.pi()) end # Convert atan2 output from ±180 to 0-360. defp normalize_bearing(b) when b < 0, do: b + 360 defp normalize_bearing(b), do: b defp get_heard_by_stations(callsign, locale) do # Get packets from the last month where this callsign was heard on RF one_month_ago = DateTime.add(DateTime.utc_now(), -30, :day) # Query to find stations that heard this callsign directly # In APRS, the first station with an asterisk (*) in the path is the one that heard the packet directly query = """ WITH digipeater_location AS ( SELECT DISTINCT ON (sender) sender as digipeater, location::geography as dig_loc FROM packets WHERE sender IN ( SELECT DISTINCT substring(path from '([A-Z0-9]+-?[0-9]*)\\*') FROM packets WHERE sender = $1 AND received_at >= $2 AND path IS NOT NULL AND path != '' AND path !~ '^TCPIP' AND path !~ ',TCPIP' AND path ~ '[A-Z0-9]+-?[0-9]*\\*' ) AND location IS NOT NULL ORDER BY sender, received_at DESC ), parsed_paths AS ( SELECT id, sender, path, received_at, lat, lon, location, -- Extract the first digipeater from the path CASE WHEN path ~ '[A-Z0-9]+-?[0-9]*\\*' THEN substring(path from '([A-Z0-9]+-?[0-9]*)\\*') ELSE NULL END as first_digipeater FROM packets WHERE sender = $1 AND received_at >= $2 AND path IS NOT NULL AND path != '' AND path !~ '^TCPIP' AND path !~ ',TCPIP' ), longest_paths AS ( SELECT DISTINCT ON (pp.first_digipeater) pp.first_digipeater, pp.id as longest_path_packet_id FROM parsed_paths pp WHERE pp.first_digipeater IS NOT NULL AND pp.lat IS NOT NULL AND pp.lon IS NOT NULL ORDER BY pp.first_digipeater, ST_Distance( pp.location::geography, COALESCE( (SELECT dl.dig_loc FROM digipeater_location dl WHERE dl.digipeater = pp.first_digipeater), pp.location::geography ) ) DESC NULLS LAST ), digipeater_stats AS ( SELECT pp.first_digipeater as digipeater, MIN(pp.received_at) as first_heard, MAX(pp.received_at) as last_heard, COUNT(*) as packet_count, lp.longest_path_packet_id FROM parsed_paths pp LEFT JOIN longest_paths lp ON lp.first_digipeater = pp.first_digipeater WHERE pp.first_digipeater IS NOT NULL GROUP BY pp.first_digipeater, lp.longest_path_packet_id ) SELECT ds.digipeater, ds.first_heard, ds.last_heard, ds.packet_count, p.received_at as longest_path_time, CASE WHEN p.location IS NOT NULL AND dl.dig_loc IS NOT NULL THEN ST_Distance(p.location::geography, dl.dig_loc) / 1000.0 ELSE NULL END as longest_distance_km FROM digipeater_stats ds LEFT JOIN packets p ON p.id = ds.longest_path_packet_id LEFT JOIN digipeater_location dl ON dl.digipeater = ds.digipeater ORDER BY ds.last_heard DESC LIMIT 50 """ case Repo.query(query, [callsign, one_month_ago]) do {:ok, result} -> result.rows |> Enum.map(fn row -> map_digipeater_row(row, locale) end) |> Enum.reject(&is_nil/1) {:error, error} -> Logger.error("Error in get_heard_by_stations: #{inspect(error)}") [] end end defp get_stations_heard_by(callsign, locale) do # Get packets from the last month where this callsign heard other stations on RF one_month_ago = DateTime.add(DateTime.utc_now(), -30, :day) # Optimized query to find stations that were heard directly by this callsign # Eliminates correlated subqueries and reduces redundant operations query = """ WITH digipeater_location AS ( -- Get the most recent location for the digipeater (cached once) SELECT location::geography as dig_loc FROM packets WHERE sender = $1 AND location IS NOT NULL ORDER BY received_at DESC LIMIT 1 ), parsed_paths AS ( SELECT id, sender, path, received_at, lat, lon, location, -- Extract the first digipeater from the path more efficiently CASE WHEN path ~ ($1 || '\\*') THEN substring(path from ($1 || '\\*')) ELSE NULL END as first_digipeater FROM packets WHERE path ~ ($1 || '\\*') AND received_at >= $2 AND path IS NOT NULL AND path != '' AND path !~ '^TCPIP' AND path !~ ',TCPIP' AND lat IS NOT NULL AND lon IS NOT NULL ), station_stats AS ( SELECT sender as station, MIN(received_at) as first_heard, MAX(received_at) as last_heard, COUNT(*) as packet_count FROM parsed_paths WHERE first_digipeater = $1 GROUP BY sender ), longest_paths AS ( -- Find the longest path packet for each station in a single pass SELECT DISTINCT ON (pp.sender) pp.sender, pp.id as longest_path_packet_id, pp.received_at as longest_path_time, CASE WHEN pp.location IS NOT NULL AND dl.dig_loc IS NOT NULL THEN ST_Distance(pp.location::geography, dl.dig_loc) / 1000.0 ELSE NULL END as longest_distance_km FROM parsed_paths pp CROSS JOIN digipeater_location dl WHERE pp.first_digipeater = $1 ORDER BY pp.sender, ST_Distance(pp.location::geography, dl.dig_loc) DESC NULLS LAST, pp.received_at DESC ) SELECT ss.station, ss.first_heard, ss.last_heard, ss.packet_count, lp.longest_path_time, lp.longest_distance_km FROM station_stats ss LEFT JOIN longest_paths lp ON lp.sender = ss.station ORDER BY ss.last_heard DESC LIMIT 50 """ case Repo.query(query, [callsign, one_month_ago]) do {:ok, result} -> result.rows |> Enum.map(fn row -> map_station_heard_row(row, locale) end) |> Enum.reject(&is_nil/1) {:error, error} -> Logger.error("Error in get_stations_heard_by: #{inspect(error)}") [] end end @doc """ Renders an APRS symbol as HTML for overlay symbols that need proper overlay character display. """ def render_symbol_html(packet, size \\ 32) do if packet do {symbol_table_id, symbol_code} = AprsSymbol.extract_from_packet(packet) if symbol_table_id && String.match?(symbol_table_id, ~r/^[A-Z0-9]$/) do sprite_info = AprsSymbol.get_sprite_info(symbol_table_id, symbol_code) overlay_sprite_info = AprsSymbol.get_overlay_character_sprite_info(symbol_table_id) style = "position: relative; width: #{size}px; height: #{size}px; background-image: url(#{overlay_sprite_info.sprite_file}), url(#{sprite_info.sprite_file}); background-position: #{overlay_sprite_info.background_position}, #{sprite_info.background_position}; background-size: #{overlay_sprite_info.background_size}, #{sprite_info.background_size}; background-repeat: no-repeat, no-repeat; image-rendering: pixelated; display: inline-block; vertical-align: middle; margin-bottom: -6px;" escaped_style = style |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string() raw("
") else style = AprsSymbol.render_style(symbol_table_id, symbol_code, size) escaped_style = style |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string() raw("") end else raw("") end end defp decode_aprs_path(path) when is_binary(path) and path != "" do path_elements = String.split(path, ",") decoded_elements = Enum.map(path_elements, &decode_path_element/1) # Filter out nil results and join with explanations decoded_elements |> Enum.reject(&is_nil/1) |> Enum.join(" → ") end defp decode_aprs_path(_), do: nil @doc """ Parses the APRS path and creates linked callsigns where appropriate. Returns a list of tuples: {:text, string} or {:link, callsign, display_text} """ def parse_path_with_links(path) when is_binary(path) and path != "" do path |> String.split(",") |> Enum.map(&parse_path_element_with_link/1) end def parse_path_with_links(_), do: [] defp parse_path_element_with_link(element) do element = String.trim(element) classify_path_element(linkable_callsign?(element), element) end # Only real callsigns (not digipeater aliases or q-constructs) become links. defp linkable_callsign?(element) do Regex.match?(~r/^[A-Z0-9]{1,6}(-\d{1,2})?(\*)?$/, element) and not path_alias?(element) end defp path_alias?(element) do String.starts_with?(element, "WIDE") or String.starts_with?(element, "TRACE") or String.starts_with?(element, "RELAY") or String.starts_with?(element, "TCPIP") or String.starts_with?(element, "q") end # Strip the trailing `*` from the canonical callsign while preserving it # in the display string so the UI still shows "was-heard-via" markers. defp classify_path_element(true, element) do callsign = String.replace(element, "*", "") {:link, callsign, display_for_element(String.ends_with?(element, "*"), callsign)} end defp classify_path_element(false, element), do: {:text, element, element} defp display_for_element(true, callsign), do: "#{callsign}*" defp display_for_element(false, callsign), do: callsign defp decode_path_element(element) do element = String.trim(element) cond do # Check Q-constructs map first (most common) Map.has_key?(@q_constructs, element) -> @q_constructs[element] # WIDE digipeaters String.starts_with?(element, "WIDE") -> decode_wide_element(element) # TRACE digipeaters String.starts_with?(element, "TRACE") -> decode_trace_element(element) # RELAY digipeaters String.starts_with?(element, "RELAY") -> decode_relay_element(element) # TCPIP digipeaters String.starts_with?(element, "TCPIP") -> decode_tcpip_element(element) # Generic callsign with SSID (likely a digipeater) Regex.match?(~r/^[A-Z0-9]+-\d+$/, element) -> gettext("%{element} (Digipeater)", element: element) # Generic callsign without SSID Regex.match?(~r/^[A-Z0-9]+$/, element) -> gettext("%{element} (Station)", element: element) # Default case true -> gettext("%{element} (Unknown)", element: element) end end defp decode_wide_element("WIDE1-1"), do: gettext("WIDE1-1 (Wide area digipeater, 1 hop)") defp decode_wide_element("WIDE2-1"), do: gettext("WIDE2-1 (Wide area digipeater, 2 hops)") defp decode_wide_element("WIDE3-1"), do: gettext("WIDE3-1 (Wide area digipeater, 3 hops)") defp decode_wide_element("WIDE4-1"), do: gettext("WIDE4-1 (Wide area digipeater, 4 hops)") defp decode_wide_element("WIDE5-1"), do: gettext("WIDE5-1 (Wide area digipeater, 5 hops)") defp decode_wide_element("WIDE6-1"), do: gettext("WIDE6-1 (Wide area digipeater, 6 hops)") defp decode_wide_element("WIDE7-1"), do: gettext("WIDE7-1 (WIDE area digipeater, 7 hops)") defp decode_wide_element("WIDE1-2"), do: gettext("WIDE1-2 (Wide area digipeater, 1 hop, 2nd attempt)") defp decode_wide_element("WIDE2-2"), do: gettext("WIDE2-2 (Wide area digipeater, 2 hops, 2nd attempt)") defp decode_wide_element(element), do: gettext("WIDE digipeater (%{element})", element: element) defp decode_trace_element("TRACE1-1"), do: gettext("TRACE1-1 (Trace digipeater, 1 hop)") defp decode_trace_element("TRACE2-1"), do: gettext("TRACE2-1 (Trace digipeater, 2 hops)") defp decode_trace_element("TRACE3-1"), do: gettext("TRACE3-1 (Trace digipeater, 3 hops)") defp decode_trace_element("TRACE4-1"), do: gettext("TRACE4-1 (Trace digipeater, 4 hops)") defp decode_trace_element("TRACE5-1"), do: gettext("TRACE5-1 (Trace digipeater, 5 hops)") defp decode_trace_element("TRACE6-1"), do: gettext("TRACE6-1 (Trace digipeater, 6 hops)") defp decode_trace_element("TRACE7-1"), do: gettext("TRACE7-1 (Trace digipeater, 7 hops)") defp decode_trace_element(element), do: gettext("TRACE digipeater (%{element})", element: element) defp decode_relay_element("RELAY"), do: gettext("RELAY (Relay digipeater)") defp decode_relay_element("RELAY-1"), do: gettext("RELAY-1 (Relay digipeater, 1 hop)") defp decode_relay_element("RELAY-2"), do: gettext("RELAY-2 (Relay digipeater, 2 hops)") defp decode_relay_element(element), do: gettext("RELAY digipeater (%{element})", element: element) defp decode_tcpip_element("TCPIP"), do: gettext("TCPIP (Internet gateway)") defp decode_tcpip_element("TCPIP*"), do: gettext("TCPIP* (Internet gateway, no forward)") defp decode_tcpip_element(element), do: gettext("TCPIP gateway (%{element})", element: element) defp map_digipeater_row(row, locale) do case row do [digipeater, first_heard, last_heard, packet_count, longest_path_time, longest_distance_km] -> %{ digipeater: digipeater || "", first_heard: first_heard, last_heard: last_heard, packet_count: packet_count || 0, longest_distance_km: longest_distance_km, longest_distance: if(longest_distance_km, do: format_distance(longest_distance_km, locale)), longest_path_time: longest_path_time } _ -> nil end end defp map_station_heard_row(row, locale) do case row do [station, first_heard, last_heard, packet_count, longest_path_time, longest_distance_km] -> %{ station: station || "", first_heard: first_heard, last_heard: last_heard, packet_count: packet_count || 0, longest_distance_km: longest_distance_km, longest_distance: if(longest_distance_km, do: format_distance(longest_distance_km, locale)), longest_path_time: longest_path_time } _ -> nil end end end