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""" + + + + + + + + +
+ No packets found for {@callsign} in the last hour. Try starting a historical replay to see older data. +
+