From b277f98f2626e14c3fef64e7466db90446d779af Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Mon, 16 Jun 2025 09:10:12 -0500 Subject: [PATCH] add callsign lookup --- lib/aprs/encoding_utils.ex | 19 + lib/aprs/packets.ex | 16 +- lib/aprs_web/live/map_live/callsign_view.ex | 876 ++++++++++++++++++ lib/aprs_web/router.ex | 1 + .../live/map_live/callsign_view_test.exs | 70 ++ 5 files changed, 980 insertions(+), 2 deletions(-) create mode 100644 lib/aprs_web/live/map_live/callsign_view.ex create mode 100644 test/aprs_web/live/map_live/callsign_view_test.exs diff --git a/lib/aprs/encoding_utils.ex b/lib/aprs/encoding_utils.ex index e238068..79685a2 100644 --- a/lib/aprs/encoding_utils.ex +++ b/lib/aprs/encoding_utils.ex @@ -45,6 +45,12 @@ defmodule Aprs.EncodingUtils do } end + def sanitize_packet(packet) when is_map(packet) do + packet + |> Map.update(:information_field, nil, &sanitize_string/1) + |> Map.update(:data_extended, nil, &sanitize_data_extended/1) + end + @doc """ Sanitizes string fields in the data_extended structure. """ @@ -58,6 +64,19 @@ defmodule Aprs.EncodingUtils do %{mic_e | message: sanitize_string(message)} end + def sanitize_data_extended(data_extended) when is_map(data_extended) do + # Handle generic maps by sanitizing all string values + Enum.reduce(data_extended, %{}, fn {key, value}, acc -> + sanitized_value = + case value do + val when is_binary(val) -> sanitize_string(val) + val -> val + end + + Map.put(acc, key, sanitized_value) + end) + end + def sanitize_data_extended(data_extended), do: data_extended # Private helper functions diff --git a/lib/aprs/packets.ex b/lib/aprs/packets.ex index c5fc823..4835e3a 100644 --- a/lib/aprs/packets.ex +++ b/lib/aprs/packets.ex @@ -232,8 +232,20 @@ defmodule Aprs.Packets do defp filter_by_region(query, _), do: query defp filter_by_callsign(query, %{callsign: callsign}) do - pattern = "%#{callsign}%" - from p in query, where: ilike(p.sender, ^pattern) or ilike(p.base_callsign, ^pattern) + # Support both exact match and partial match + # For exact match, check if callsign contains a hyphen (has SSID) + 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 end defp filter_by_callsign(query, _), do: query diff --git a/lib/aprs_web/live/map_live/callsign_view.ex b/lib/aprs_web/live/map_live/callsign_view.ex new file mode 100644 index 0000000..5fc6ea0 --- /dev/null +++ b/lib/aprs_web/live/map_live/callsign_view.ex @@ -0,0 +1,876 @@ +defmodule AprsWeb.MapLive.CallsignView do + use AprsWeb, :live_view + + alias Aprs.EncodingUtils + alias Aprs.Packets + alias AprsWeb.Endpoint + alias Parser.Types.MicE + + @default_center %{lat: 39.0, lng: -98.0} + @default_zoom 4 + @default_replay_speed 1.0 + + def mount(%{"callsign" => callsign}, _session, socket) do + # Normalize callsign to uppercase + normalized_callsign = String.upcase(callsign) + + # Calculate one hour ago for packet age filtering + one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second) + + socket = + assign(socket, + callsign: normalized_callsign, + packets: [], + page_title: "APRS Map - #{normalized_callsign}", + # Track visible packets by callsign + visible_packets: %{}, + # Default bounds - will be updated based on packet locations + map_bounds: %{ + north: 49.0, + south: 24.0, + east: -66.0, + west: -125.0 + }, + map_center: @default_center, + map_zoom: @default_zoom, + # Replay controls + replay_active: false, + replay_speed: @default_replay_speed, + replay_paused: false, + replay_packets: [], + replay_index: 0, + replay_timer_ref: nil, + replay_start_time: nil, + replay_end_time: nil, + # Map of packet IDs to packet data for historical packets + historical_packets: %{}, + # Timestamp for filtering out old packets + packet_age_threshold: one_hour_ago, + # Flag to indicate if map is ready for replay + map_ready: false, + # Flag to prevent multiple replay starts + replay_started: false, + # Pending geolocation to zoom to after map is ready + pending_geolocation: nil, + # Last known position for auto-zoom + last_known_position: nil + ) + + if connected?(socket) do + Endpoint.subscribe("aprs_messages") + + # Load recent packets for this callsign + socket = load_callsign_packets(socket, normalized_callsign) + + # Schedule regular cleanup of old packets from the map + Process.send_after(self(), :cleanup_old_packets, 60_000) + # Schedule initialization of replay after a short delay + Process.send_after(self(), :initialize_replay, 2000) + + {:ok, socket} + else + {:ok, socket} + end + end + + def handle_event("bounds_changed", %{"bounds" => bounds}, socket) do + handle_bounds_update(bounds, socket) + end + + def handle_event("update_bounds", %{"bounds" => bounds}, socket) do + handle_bounds_update(bounds, socket) + end + + def handle_event("locate_me", _params, socket) do + socket = push_event(socket, "request_geolocation", %{}) + {:noreply, socket} + end + + def handle_event("set_location", %{"lat" => lat, "lng" => lng}, socket) do + lat_float = + cond do + is_binary(lat) -> String.to_float(lat) + is_integer(lat) -> lat / 1.0 + true -> lat + end + + lng_float = + cond do + is_binary(lng) -> String.to_float(lng) + is_integer(lng) -> lng / 1.0 + true -> lng + end + + socket = + socket + |> assign(map_center: %{lat: lat_float, lng: lng_float}, map_zoom: 12) + |> push_event("zoom_to_location", %{lat: lat_float, lng: lng_float, zoom: 12}) + + {:noreply, socket} + end + + def handle_event("toggle_replay", _params, socket) do + if socket.assigns.replay_active do + # Stop replay + if socket.assigns.replay_timer_ref do + Process.cancel_timer(socket.assigns.replay_timer_ref) + end + + # Clear historical packets from map + socket = push_event(socket, "clear_historical_packets", %{}) + + socket = + assign(socket, + replay_active: false, + replay_paused: false, + replay_timer_ref: nil, + replay_index: 0, + historical_packets: %{} + ) + + {:noreply, socket} + else + # Start replay + socket = start_historical_replay(socket) + {:noreply, assign(socket, replay_active: true, replay_paused: false)} + end + end + + def handle_event("pause_replay", _params, socket) do + if socket.assigns.replay_timer_ref do + Process.cancel_timer(socket.assigns.replay_timer_ref) + end + + socket = + assign(socket, + replay_paused: not socket.assigns.replay_paused, + replay_timer_ref: nil + ) + + # Resume replay if unpausing + socket = + if socket.assigns.replay_paused do + socket + else + Process.send_after(self(), :replay_next_packet, 100) + socket + end + + {:noreply, socket} + end + + def handle_event("adjust_replay_speed", %{"speed" => speed}, socket) do + {:noreply, assign(socket, replay_speed: String.to_float(speed))} + end + + def handle_event("map_ready", _params, socket) do + socket = assign(socket, map_ready: true) + + # If we have a pending geolocation, zoom to it now + socket = + if socket.assigns.pending_geolocation do + location = socket.assigns.pending_geolocation + push_event(socket, "zoom_to_location", %{lat: location.lat, lng: location.lng, zoom: 12}) + else + # If we have a last known position, zoom to it + if socket.assigns.last_known_position do + pos = socket.assigns.last_known_position + push_event(socket, "zoom_to_location", %{lat: pos.lat, lng: pos.lng, zoom: 12}) + else + socket + end + end + + {:noreply, socket} + end + + defp handle_bounds_update(bounds, socket) do + # Convert string keys to atom keys and parse values + normalized_bounds = %{ + north: String.to_float(bounds["north"]), + south: String.to_float(bounds["south"]), + east: String.to_float(bounds["east"]), + west: String.to_float(bounds["west"]) + } + + socket = assign(socket, map_bounds: normalized_bounds) + {:noreply, socket} + end + + def handle_info(msg, socket) do + case msg do + {:delayed_zoom, %{lat: lat, lng: lng}} -> + socket = push_event(socket, "zoom_to_location", %{lat: lat, lng: lng, zoom: 12}) + {:noreply, socket} + + :initialize_replay -> + # Only start replay if it hasn't been started yet + if not socket.assigns.replay_started and socket.assigns.map_ready do + socket = start_historical_replay(socket) + {:noreply, assign(socket, replay_started: true)} + else + {:noreply, socket} + end + + :replay_next_packet -> + handle_replay_next_packet(socket) + + :cleanup_old_packets -> + # Clean up packets older than 1 hour from the map display + handle_cleanup_old_packets(socket) + + %{event: "packet", payload: payload} -> + # Sanitize the packet to prevent encoding errors + sanitized_packet = EncodingUtils.sanitize_packet(payload) + + # Add received timestamp if not present + sanitized_packet = Map.put_new(sanitized_packet, :received_at, DateTime.utc_now()) + + # Check if this packet is from our target callsign + if packet_matches_callsign?(sanitized_packet, socket.assigns.callsign) and + has_position_data?(sanitized_packet) and + packet_within_time_threshold?(sanitized_packet, socket.assigns.packet_age_threshold) do + # Convert to a simple map structure for JSON encoding + packet_data = build_packet_data(sanitized_packet) + + # Only push if we have valid packet data + if packet_data do + # Generate a unique key for this packet + callsign_key = + "#{sanitized_packet.base_callsign}#{if sanitized_packet.ssid, do: "-#{sanitized_packet.ssid}", else: ""}" + + # Update visible packets tracking + visible_packets = Map.put(socket.assigns.visible_packets, callsign_key, sanitized_packet) + + # Update last known position + {lat, lng} = get_coordinates(sanitized_packet) + last_known_position = if lat && lng, do: %{lat: lat, lng: lng}, else: socket.assigns.last_known_position + + # Push the packet to the client-side JavaScript + socket = + socket + |> push_event("new_packet", packet_data) + |> assign(visible_packets: visible_packets, last_known_position: last_known_position) + + # If this is the first packet and map is ready, zoom to it + # Or if this is a new position that's significantly different, update zoom + socket = + cond do + map_empty?(socket) and socket.assigns.map_ready and last_known_position -> + push_event(socket, "zoom_to_location", %{ + lat: last_known_position.lat, + lng: last_known_position.lng, + zoom: 12 + }) + + should_update_zoom?(socket.assigns.last_known_position, last_known_position) and socket.assigns.map_ready -> + push_event(socket, "zoom_to_location", %{ + lat: last_known_position.lat, + lng: last_known_position.lng, + zoom: 12 + }) + + true -> + socket + end + + {:noreply, socket} + else + # Invalid packet data, skip it + {:noreply, socket} + end + else + # Ignore packets that don't match our callsign or don't have position data + {:noreply, socket} + end + end + end + + defp handle_replay_next_packet(socket) do + if socket.assigns.replay_active and not socket.assigns.replay_paused and + socket.assigns.replay_index < length(socket.assigns.replay_packets) do + packet = Enum.at(socket.assigns.replay_packets, socket.assigns.replay_index) + + if packet do + packet_data = build_packet_data(packet) + + if packet_data do + # Add to historical packets map + historical_packets = Map.put(socket.assigns.historical_packets, packet_data.id, packet) + + # Push as historical packet + socket = push_event(socket, "historical_packet", Map.put(packet_data, :historical, true)) + + # Schedule next packet + delay = trunc(1000 / socket.assigns.replay_speed) + timer_ref = Process.send_after(self(), :replay_next_packet, delay) + + socket = + assign(socket, + replay_index: socket.assigns.replay_index + 1, + replay_timer_ref: timer_ref, + historical_packets: historical_packets + ) + + {:noreply, socket} + else + # Skip invalid packet + timer_ref = Process.send_after(self(), :replay_next_packet, 10) + + socket = + assign(socket, + replay_index: socket.assigns.replay_index + 1, + replay_timer_ref: timer_ref + ) + + {:noreply, socket} + end + else + # End of replay + socket = + assign(socket, + replay_active: false, + replay_paused: false, + replay_timer_ref: nil, + replay_index: 0 + ) + + {:noreply, socket} + end + else + {:noreply, socket} + end + end + + def render(assigns) do + ~H""" + + + + + + + + +
+
📡 {@callsign}
+ +
+ +
+ + + <%= if @replay_active do %> + + +
+ + x +
+ <% end %> +
+ + + + <%= if map_size(@visible_packets) == 0 and not @replay_active do %> +
+

No Recent Packets

+

+ No packets found for {@callsign} in the last hour. Try starting a historical replay to see older data. +

+
+ <% end %> + +
+
+ """ + end + + defp handle_cleanup_old_packets(socket) do + # Schedule next cleanup + Process.send_after(self(), :cleanup_old_packets, 60_000) + + # Update packet age threshold + one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second) + socket = assign(socket, packet_age_threshold: one_hour_ago) + + # Remove expired packets from visible_packets + updated_visible_packets = + socket.assigns.visible_packets + |> Enum.filter(fn {_key, packet} -> + packet_within_time_threshold?(packet, one_hour_ago) + end) + |> Map.new() + + socket = assign(socket, visible_packets: updated_visible_packets) + + {:noreply, socket} + end + + defp packet_within_time_threshold?(packet, threshold) do + received_at = Map.get(packet, :received_at, DateTime.utc_now()) + + case received_at do + %DateTime{} -> DateTime.compare(received_at, threshold) != :lt + # If no timestamp, assume it's current + _ -> true + end + end + + defp start_historical_replay(socket) do + # Fetch historical packets for the specific callsign + one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second) + six_hours_ago = DateTime.add(DateTime.utc_now(), -21_600, :second) + + packets = + fetch_historical_packets_for_callsign( + socket.assigns.callsign, + six_hours_ago, + one_hour_ago + ) + + socket = + assign(socket, + replay_packets: packets, + replay_index: 0, + replay_start_time: six_hours_ago, + replay_end_time: one_hour_ago, + historical_packets: %{} + ) + + # Start replay immediately if we have packets + if length(packets) > 0 do + Process.send_after(self(), :replay_next_packet, 100) + end + + socket + end + + defp fetch_historical_packets_for_callsign(callsign, start_time, end_time) do + Packets.get_packets_for_replay(%{ + callsign: callsign, + start_time: start_time, + end_time: end_time, + limit: 1000 + }) + end + + defp has_position_data?(packet) do + case packet.data_extended do + %MicE{} -> true + %{latitude: lat, longitude: lon} when not is_nil(lat) and not is_nil(lon) -> true + _ -> false + end + end + + defp get_coordinates(packet) do + case packet.data_extended do + %MicE{} = mic_e -> + # Convert MicE components to decimal degrees + lat = mic_e.lat_degrees + mic_e.lat_minutes / 60.0 + mic_e.lat_fractional / 6000.0 + lat = if mic_e.lat_direction == :south, do: -lat, else: lat + + lng = mic_e.lon_degrees + mic_e.lon_minutes / 60.0 + mic_e.lon_fractional / 6000.0 + lng = if mic_e.lon_direction == :west, do: -lng, else: lng + + # Validate coordinates are within valid ranges + if lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180 do + {lat, lng} + else + {nil, nil} + end + + %{latitude: lat, longitude: lon} -> + {lat, lon} + + _ -> + {nil, nil} + end + end + + defp build_packet_data(packet) do + {lat, lng} = get_coordinates(packet) + + if lat && lng do + callsign = "#{packet.base_callsign}#{if packet.ssid, do: "-#{packet.ssid}", else: ""}" + + symbol_table_id = + case packet.data_extended do + %{symbol_table_id: id} -> id + _ -> "/" + end + + symbol_code = + case packet.data_extended do + %{symbol_code: code} -> code + _ -> ">" + end + + comment = + case packet.data_extended do + %{comment: comment} when is_binary(comment) -> comment + _ -> "" + end + + %{ + id: "#{callsign}_#{:os.system_time(:millisecond)}", + callsign: callsign, + lat: lat, + lng: lng, + symbol_table_id: symbol_table_id, + symbol_code: symbol_code, + comment: comment, + received_at: packet.received_at || DateTime.utc_now(), + data_extended: build_data_extended(packet.data_extended) + } + end + end + + defp build_data_extended(nil), do: nil + + defp build_data_extended(data_extended) do + case data_extended do + %MicE{} = mic_e -> + %{ + type: "MicE", + latitude: mic_e.lat_degrees + mic_e.lat_minutes / 60.0 + mic_e.lat_fractional / 6000.0, + longitude: mic_e.lon_degrees + mic_e.lon_minutes / 60.0 + mic_e.lon_fractional / 6000.0, + symbol_table_id: Map.get(mic_e, :symbol_table_id, "/"), + symbol_code: Map.get(mic_e, :symbol_code, ">"), + comment: Map.get(mic_e, :comment, "") + } + + %{} = data -> + # Convert struct to map if needed, filtering out private fields + data + |> Map.from_struct() + |> Map.delete(:__meta__) + + _ -> + nil + end + end + + defp packet_matches_callsign?(packet, target_callsign) do + # Handle both struct and map formats + base_callsign = packet[:base_callsign] || packet.base_callsign || "" + ssid = packet[:ssid] || packet.ssid + + # Build the full callsign + packet_callsign = + case ssid do + nil -> base_callsign + "" -> base_callsign + "0" -> base_callsign + _ -> "#{base_callsign}-#{ssid}" + end + + # Normalize both callsigns for comparison + normalized_packet = String.upcase(String.trim(packet_callsign)) + normalized_target = String.upcase(String.trim(target_callsign)) + + # Try exact match first + if normalized_packet == normalized_target do + true + else + # If target has no SSID, match against base callsign only + if String.contains?(normalized_target, "-") do + false + else + String.upcase(String.trim(base_callsign)) == normalized_target + end + end + end + + defp load_callsign_packets(socket, callsign) do + # Load recent packets (last hour) for this specific callsign + recent_packets = Packets.get_recent_packets(%{callsign: callsign}) + + # Find the most recent packet with position data for auto-zoom + last_known_position = + recent_packets + |> Enum.filter(&has_position_data?/1) + |> Enum.sort_by(& &1.received_at, {:desc, DateTime}) + |> List.first() + |> case do + nil -> + nil + + packet -> + {lat, lng} = get_coordinates(packet) + if lat && lng, do: %{lat: lat, lng: lng} + end + + # Convert packets to client-friendly format and send to map + packet_data_list = + recent_packets + |> Stream.filter(&has_position_data?/1) + |> Stream.map(&build_packet_data/1) + |> Stream.filter(&(&1 != nil)) + |> Enum.to_list() + + # Send packets to map if any exist + socket = + if length(packet_data_list) > 0 do + push_event(socket, "add_markers", %{markers: packet_data_list}) + else + socket + end + + assign(socket, last_known_position: last_known_position) + end + + defp map_empty?(socket) do + map_size(socket.assigns.visible_packets) == 0 + end + + # Check if we should update zoom based on position change + defp should_update_zoom?(nil, _new_pos), do: false + defp should_update_zoom?(_old_pos, nil), do: false + + defp should_update_zoom?(old_pos, new_pos) do + # Calculate distance between positions (rough approximation) + lat_diff = abs(old_pos.lat - new_pos.lat) + lng_diff = abs(old_pos.lng - new_pos.lng) + + # Update zoom if position changed by more than ~5km (approximately 0.05 degrees) + lat_diff > 0.05 or lng_diff > 0.05 + end +end diff --git a/lib/aprs_web/router.ex b/lib/aprs_web/router.ex index 0318dd6..12e9015 100644 --- a/lib/aprs_web/router.ex +++ b/lib/aprs_web/router.ex @@ -33,6 +33,7 @@ defmodule AprsWeb.Router do live "/status", StatusLive.Index, :index live "/packets", PacketsLive.Index, :index + live "/:callsign", MapLive.CallsignView, :index end # Other scopes may use custom stacks. diff --git a/test/aprs_web/live/map_live/callsign_view_test.exs b/test/aprs_web/live/map_live/callsign_view_test.exs new file mode 100644 index 0000000..4bebce3 --- /dev/null +++ b/test/aprs_web/live/map_live/callsign_view_test.exs @@ -0,0 +1,70 @@ +defmodule AprsWeb.MapLive.CallsignViewTest do + use AprsWeb.ConnCase + + import Phoenix.LiveViewTest + + describe "CallsignView" do + test "renders callsign view with valid callsign", %{conn: conn} do + {:ok, view, html} = live(conn, "/W5ISP-9") + + assert html =~ "📡 W5ISP-9" + assert html =~ "Back to Map" + assert html =~ "Packets" + assert has_element?(view, "#aprs-map") + end + + test "normalizes callsign to uppercase", %{conn: conn} do + {:ok, _view, html} = live(conn, "/w5isp-9") + + assert html =~ "📡 W5ISP-9" + end + + test "shows empty state when no packets found", %{conn: conn} do + {:ok, _view, html} = live(conn, "/TESTCALL-1") + + assert html =~ "No Recent Packets" + assert html =~ "No packets found for TESTCALL-1" + end + + test "handles callsign without SSID", %{conn: conn} do + {:ok, _view, html} = live(conn, "/W5ISP") + + assert html =~ "📡 W5ISP" + end + + test "has replay controls", %{conn: conn} do + {:ok, view, _html} = live(conn, "/W5ISP-9") + + assert has_element?(view, "button", "Start Replay") + end + + test "can start and stop replay", %{conn: conn} do + {:ok, view, _html} = live(conn, "/W5ISP-9") + + # Start replay + view |> element("button", "Start Replay") |> render_click() + assert has_element?(view, "button", "Stop Replay") + assert has_element?(view, "button", "Pause") + + # Stop replay + view |> element("button", "Stop Replay") |> render_click() + assert has_element?(view, "button", "Start Replay") + end + + test "handles locate me button", %{conn: conn} do + {:ok, view, _html} = live(conn, "/W5ISP-9") + + assert has_element?(view, "button[title='Find my location']") + + # Click locate button - should trigger geolocation request + view |> element("button[title='Find my location']") |> render_click() + # Should not crash - actual geolocation testing would require JavaScript + end + + test "sets correct page title", %{conn: conn} do + {:ok, view, _html} = live(conn, "/W5ISP-9") + + assert page_title(view) =~ "APRS Map - W5ISP-9" + end + end +end