diff --git a/docs/mobile-api.md b/docs/mobile-api.md index 08bcf6b..012423d 100644 --- a/docs/mobile-api.md +++ b/docs/mobile-api.md @@ -210,6 +210,118 @@ Stop receiving packets. } ``` +### Searching for Callsigns + +Search for callsigns matching a pattern. + +**Event:** `search_callsign` + +**Payload:** +```json +{ + "query": "W5ISP", + "limit": 50 +} +``` + +**Payload Fields:** + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `query` | string | Yes | - | Callsign to search (supports * wildcard) | +| `limit` | integer | No | 50 | Maximum results to return (max: 500) | + +**Response:** +```json +{ + "topic": "mobile:packets", + "event": "phx_reply", + "payload": { + "status": "ok", + "response": { + "results": [ + { + "callsign": "W5ISP-9", + "base_callsign": "W5ISP", + "last_seen": "2025-10-25T17:30:00Z", + "lat": 33.1225, + "lng": -96.124 + } + ], + "count": 1 + } + }, + "ref": "5" +} +``` + +**Examples:** +- `"W5ISP"` - Finds W5ISP and all SSIDs (W5ISP-1, W5ISP-9, etc.) +- `"W5ISP-9"` - Finds only W5ISP-9 +- `"W5ISP*"` - Finds all callsigns starting with W5ISP + +### Subscribing to Callsign Updates + +Subscribe to live updates for a specific callsign or pattern. When subscribed, you'll receive historical packets for that callsign, followed by real-time updates. + +**Event:** `subscribe_callsign` + +**Payload:** +```json +{ + "callsign": "W5ISP-9", + "hours_back": 24 +} +``` + +**Payload Fields:** + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `callsign` | string | Yes | - | Callsign to track (supports * wildcard) | +| `hours_back` | integer | No | 24 | Hours of historical data to load (max: 168) | + +**Response:** +```json +{ + "topic": "mobile:packets", + "event": "phx_reply", + "payload": { + "status": "ok", + "response": { + "callsign": "W5ISP-9", + "message": "Subscribed to callsign updates" + } + }, + "ref": "6" +} +``` + +**Note:** When tracking a callsign, you'll receive packets matching that callsign even if they're outside your geographic bounds. To receive only callsign packets within bounds, use both `subscribe_bounds` and `subscribe_callsign` together. + +### Unsubscribing from Callsign Updates + +Stop receiving updates for the tracked callsign. + +**Event:** `unsubscribe_callsign` + +**Payload:** `{}` + +**Response:** +```json +{ + "topic": "mobile:packets", + "event": "phx_reply", + "payload": { + "status": "ok", + "response": { + "message": "Unsubscribed from callsign updates" + } + }, + "ref": "7" +} +``` + ## Error Responses **Invalid Bounds:** diff --git a/lib/aprsme_web/channels/mobile_channel.ex b/lib/aprsme_web/channels/mobile_channel.ex index dc3f07d..9901a81 100644 --- a/lib/aprsme_web/channels/mobile_channel.ex +++ b/lib/aprsme_web/channels/mobile_channel.ex @@ -22,9 +22,17 @@ defmodule AprsmeWeb.MobileChannel do - "unsubscribe" - Stop receiving packets + - "search_callsign" - Search for callsigns matching a pattern + Payload: %{query: string, limit: integer (optional)} + + - "subscribe_callsign" - Subscribe to live updates for a callsign pattern + Payload: %{callsign: string, hours_back: integer (optional)} + + - "unsubscribe_callsign" - Stop receiving updates for tracked callsign + ### Server -> Client - - "packet" - New APRS packet within subscribed bounds + - "packet" - New APRS packet within subscribed bounds or matching tracked callsign Payload: %{ id: string, callsign: string, @@ -148,12 +156,70 @@ defmodule AprsmeWeb.MobileChannel do end @impl true - def handle_info({:streaming_packet, packet}, socket) do - # Convert packet to mobile-friendly format - packet_data = build_mobile_packet(packet) + def handle_in("search_callsign", %{"query" => query} = payload, socket) do + limit = Map.get(payload, "limit", 50) + limit = min(limit, 500) - # Push packet to mobile client - push(socket, "packet", packet_data) + Logger.debug("Mobile client searching for callsign: #{query}") + + results = search_callsign(query, limit) + + {:reply, {:ok, %{results: results, count: length(results)}}, socket} + end + + @impl true + def handle_in("subscribe_callsign", %{"callsign" => callsign} = payload, socket) do + hours_back = Map.get(payload, "hours_back", 24) + # Max 1 week + hours_back = min(hours_back, 168) + + # Normalize callsign to uppercase + callsign = String.upcase(callsign) + + Logger.info("Mobile client #{socket.assigns[:client_id]} subscribing to callsign: #{callsign}") + + # Load historical packets for this callsign + socket = load_callsign_history(socket, callsign, hours_back) + + # Store tracked callsign in socket + socket = assign(socket, :tracked_callsign, callsign) + + {:reply, {:ok, %{callsign: callsign, message: "Subscribed to callsign updates"}}, socket} + end + + @impl true + def handle_in("unsubscribe_callsign", _payload, socket) do + if socket.assigns[:tracked_callsign] do + callsign = socket.assigns.tracked_callsign + socket = assign(socket, :tracked_callsign, nil) + + Logger.info("Mobile client #{socket.assigns[:client_id]} unsubscribed from callsign: #{callsign}") + + {:reply, {:ok, %{message: "Unsubscribed from callsign updates"}}, socket} + else + {:reply, {:ok, %{message: "Not tracking any callsign"}}, socket} + end + end + + @impl true + def handle_info({:streaming_packet, packet}, socket) do + # Check if packet matches tracked callsign (if any) + should_send = + if tracked_callsign = socket.assigns[:tracked_callsign] do + packet_callsign = get_field(packet, :sender) || get_field(packet, :base_callsign) || "" + callsign_matches?(packet_callsign, tracked_callsign) + else + # If not tracking a callsign, send all packets (geographic filtering already applied) + true + end + + if should_send do + # Convert packet to mobile-friendly format + packet_data = build_mobile_packet(packet) + + # Push packet to mobile client + push(socket, "packet", packet_data) + end {:noreply, socket} end @@ -297,4 +363,115 @@ defmodule AprsmeWeb.MobileChannel do socket end + + defp search_callsign(query, limit) do + import Ecto.Query + + # Normalize query to uppercase + query = String.upcase(query) + + # Build search pattern - support wildcard with * + pattern = + if String.contains?(query, "*") do + # Convert * to SQL % wildcard + String.replace(query, "*", "%") + else + # If no wildcard, search for exact match or with SSID + "#{query}%" + end + + # Query for matching callsigns + results = + try do + Aprsme.Repo.all( + from p in Aprsme.Packet, + where: ilike(p.sender, ^pattern) or ilike(p.base_callsign, ^pattern), + distinct: true, + select: %{ + callsign: p.sender, + base_callsign: p.base_callsign, + last_seen: p.received_at, + lat: p.lat, + lng: p.lng + }, + order_by: [desc: p.received_at], + limit: ^limit + ) + rescue + error -> + Logger.error("Error searching callsigns: #{inspect(error)}") + [] + end + + results + end + + defp load_callsign_history(socket, callsign, hours_back) do + import Ecto.Query + + # Build pattern for callsign matching (supports wildcards) + pattern = + if String.contains?(callsign, "*") do + String.replace(callsign, "*", "%") + else + # Exact callsign match + callsign + end + + # Calculate time window + since = DateTime.add(DateTime.utc_now(), -hours_back * 3600, :second) + + # Query historical packets + packets = + try do + Aprsme.Repo.all( + from p in Aprsme.Packet, + where: p.received_at >= ^since, + where: ilike(p.sender, ^pattern) or ilike(p.base_callsign, ^pattern), + order_by: [asc: p.received_at], + limit: 1000 + ) + rescue + error -> + Logger.error("Error loading callsign history: #{inspect(error)}") + [] + end + + Logger.info("Loaded #{length(packets)} historical packets for callsign #{callsign}") + + # Send historical packets to client + if length(packets) > 0 do + Enum.each(packets, fn packet -> + packet_data = build_mobile_packet(packet) + push(socket, "packet", packet_data) + end) + end + + socket + end + + defp callsign_matches?(packet_callsign, tracked_callsign) do + # Normalize both to uppercase + packet_callsign = String.upcase(packet_callsign) + tracked_callsign = String.upcase(tracked_callsign) + + cond do + # Exact match + packet_callsign == tracked_callsign -> + true + + # Wildcard match (e.g., "W5ISP*" matches "W5ISP-9") + String.contains?(tracked_callsign, "*") -> + pattern = String.replace(tracked_callsign, "*", "") + String.starts_with?(packet_callsign, pattern) + + # Base callsign match (e.g., "W5ISP" matches "W5ISP-9") + String.starts_with?(packet_callsign, tracked_callsign <> "-") -> + true + + # No match + true -> + false + end + end end