defmodule AprsmeWeb.InfoLive.Show do @moduledoc false use AprsmeWeb, :live_view use Gettext, backend: AprsmeWeb.Gettext alias Aprsme.Packets alias AprsmeWeb.AprsSymbol alias AprsmeWeb.MapLive.PacketUtils @neighbor_radius_km 10 @neighbor_limit 10 @impl true def mount(%{"callsign" => callsign}, _session, socket) do normalized_callsign = String.upcase(String.trim(callsign)) # Subscribe to Postgres notifications for live updates if connected?(socket) do Phoenix.PubSub.subscribe(Aprsme.PubSub, "postgres:aprsme_packets") end packet = get_latest_packet(normalized_callsign) packet = enrich_packet_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 = get_other_ssids(normalized_callsign) socket = socket |> assign(:callsign, normalized_callsign) |> assign(:packet, packet) |> assign(:neighbors, neighbors) |> assign(:page_title, "APRS station #{normalized_callsign}") |> assign(:has_weather_packets, has_weather_packets) |> assign(:other_ssids, other_ssids) {:ok, socket} end @impl true def handle_info({:postgres_packet, packet}, socket) do # Only update if the packet is for our callsign if packet_matches_callsign?(packet, socket.assigns.callsign) do # Refresh data when new packet arrives packet = get_latest_packet(socket.assigns.callsign) packet = enrich_packet_with_device_info(packet) # Get locale from socket assigns locale = Map.get(socket.assigns, :locale, "en") neighbors = get_neighbors(packet, socket.assigns.callsign, locale) has_weather_packets = PacketUtils.has_weather_packets?(socket.assigns.callsign) other_ssids = get_other_ssids(socket.assigns.callsign) socket = socket |> assign(:packet, packet) |> assign(:neighbors, neighbors) |> assign(:has_weather_packets, has_weather_packets) |> assign(:other_ssids, other_ssids) {:noreply, socket} else {:noreply, socket} end end def handle_info(_message, socket), do: {:noreply, socket} defp packet_matches_callsign?(packet, callsign) do packet_sender = Map.get(packet, "sender") || Map.get(packet, :sender, "") String.upcase(packet_sender) == String.upcase(callsign) 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 Packets.get_latest_packet_for_callsign(callsign) end defp enrich_packet_with_device_info(nil), do: nil defp enrich_packet_with_device_info(packet) do device_identifier = Map.get(packet, :device_identifier) || Map.get(packet, "device_identifier") device = if is_binary(device_identifier), do: Aprsme.DeviceIdentification.lookup_device_by_identifier(device_identifier) model = if device, do: device.model vendor = if device, do: device.vendor contact = if device, do: device.contact class = if device, do: device.class packet |> Map.put(:device_model, model) |> Map.put(:device_vendor, vendor) |> Map.put(:device_contact, contact) |> Map.put(:device_class, class) 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 # Simple bounding box for ~10km radius delta = @neighbor_radius_km / 111.0 min_lat = lat - delta max_lat = lat + delta min_lon = lon - delta max_lon = lon + delta opts = %{bounds: [min_lon, min_lat, max_lon, max_lat], limit: 50} opts |> Packets.get_recent_packets() |> Enum.filter(fn p -> (p.sender != callsign and p.lat) && p.lon end) |> uniq_by(& &1.sender) |> Enum.map(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), course: course, last_heard: format_timestamp_for_display(p), packet: p } end) |> Enum.sort_by(& &1.distance) |> Enum.take(@neighbor_limit) end end defp uniq_by(list, fun) do list |> Enum.reduce({MapSet.new(), []}, fn item, {set, acc} -> key = fun.(item) if MapSet.member?(set, key) do {set, acc} else {MapSet.put(set, key), [item | acc]} end end) |> elem(1) |> Enum.reverse() end def haversine(lat1, lon1, lat2, lon2) do # Returns distance in km # Convert Decimal to float if needed lat1 = to_float(lat1) lon1 = to_float(lon1) lat2 = to_float(lat2) lon2 = to_float(lon2) r = 6371 dlat = :math.pi() / 180 * (lat2 - lat1) dlon = :math.pi() / 180 * (lon2 - lon1) a = :math.sin(dlat / 2) * :math.sin(dlat / 2) + :math.cos(:math.pi() / 180 * lat1) * :math.cos(:math.pi() / 180 * lat2) * :math.sin(dlon / 2) * :math.sin(dlon / 2) c = 2 * :math.atan2(:math.sqrt(a), :math.sqrt(1 - a)) r * c end defp to_float(%Decimal{} = decimal), do: Decimal.to_float(decimal) defp to_float(value) when is_float(value), do: value defp to_float(value) when is_integer(value), do: value * 1.0 defp to_float(value) when is_binary(value) do case Float.parse(value) do {f, _} -> f :error -> 0.0 end end defp to_float(_), do: 0.0 defp format_timestamp_for_display(packet) do received_at = get_received_at(packet) if received_at do timestamp_dt = AprsmeWeb.TimeHelpers.to_datetime(received_at) if timestamp_dt do %{ time_ago: AprsmeWeb.TimeHelpers.time_ago_in_words(timestamp_dt), formatted: Calendar.strftime(timestamp_dt, "%Y-%m-%d %H:%M:%S UTC") } else %{ time_ago: gettext("Unknown"), formatted: "" } end else %{ time_ago: gettext("Unknown"), formatted: "" } end end defp get_received_at(packet) do cond do Map.has_key?(packet, :received_at) -> packet.received_at Map.has_key?(packet, "received_at") -> packet["received_at"] true -> nil end end def format_distance(km, locale \\ "en") do case locale do "en" -> # Use imperial units for English locale miles = Aprsme.Convert.kph_to_mph(km) if miles < 1.0 do feet = miles * 5280 "#{Float.round(feet, 0)} ft" else "#{Float.round(miles, 2)} mi" end _ -> # Use metric units for other locales if km < 1.0 do "#{Float.round(km * 1000, 0)} m" else "#{Float.round(km, 2)} km" end end end def calculate_course(lat1, lon1, lat2, lon2) do # Calculate bearing from point 1 to point 2 # 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) bearing = :math.atan2(y, x) * 180 / :math.pi() # Convert to 0-360 range if bearing < 0, do: bearing + 360, else: bearing end defp get_other_ssids(callsign) do import Ecto.Query alias Aprsme.Packet alias Aprsme.Repo # Extract base callsign from the full callsign (remove SSID if present) base_callsign = extract_base_callsign(callsign) # Query directly for packets with the same base_callsign # Get recent packets for the base callsign to find other SSIDs one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second) query = from p in Packet, where: p.base_callsign == ^base_callsign, where: p.received_at >= ^one_hour_ago, order_by: [desc: p.received_at], limit: 100 query |> Repo.all() |> Enum.map(fn p -> %{ callsign: p.sender, ssid: p.ssid, last_heard: format_timestamp_for_display(p), packet: p } end) |> uniq_by(& &1.callsign) |> Enum.filter(fn ssid_info -> ssid_info.callsign != callsign end) |> Enum.sort_by(& &1.last_heard, :desc) |> Enum.take(10) end defp extract_base_callsign(callsign) do case String.split(callsign, "-") do [base, _ssid] -> base [base] -> base end end @doc """ Renders an APRS symbol style for use in templates. """ def render_symbol_style(packet, size \\ 32) do if packet do {symbol_table_id, symbol_code} = AprsSymbol.extract_from_packet(packet) AprsSymbol.render_style(symbol_table_id, symbol_code, size) else # Return empty style if no packet "" 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) # Check if this is an overlay symbol if symbol_table_id && String.match?(symbol_table_id, ~r/^[A-Z0-9]$/) do # Use layered sprite backgrounds for overlay symbols sprite_info = AprsSymbol.get_sprite_info(symbol_table_id, symbol_code) overlay_sprite_info = AprsSymbol.get_overlay_character_sprite_info(symbol_table_id) raw("""