Add callsign search and tracking to mobile API
Implements comprehensive callsign search and live tracking: - search_callsign: Search for callsigns with wildcard support - "W5ISP" finds W5ISP and all SSIDs (W5ISP-1, W5ISP-9, etc.) - "W5ISP-9" finds only that specific SSID - "W5ISP*" finds all callsigns starting with W5ISP - subscribe_callsign: Subscribe to live updates for a callsign - Loads historical packets for the callsign - Receives real-time streaming updates - Supports wildcard patterns - Works with or without geographic bounds - unsubscribe_callsign: Stop tracking callsign When both bounds and callsign are subscribed, packets are filtered by both criteria (callsign AND within bounds). Generated with Claude Code https://claude.com/claude-code
This commit is contained in:
parent
9509c49429
commit
2da935b1af
2 changed files with 295 additions and 6 deletions
|
|
@ -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
|
## Error Responses
|
||||||
|
|
||||||
**Invalid Bounds:**
|
**Invalid Bounds:**
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,17 @@ defmodule AprsmeWeb.MobileChannel do
|
||||||
|
|
||||||
- "unsubscribe" - Stop receiving packets
|
- "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
|
### Server -> Client
|
||||||
|
|
||||||
- "packet" - New APRS packet within subscribed bounds
|
- "packet" - New APRS packet within subscribed bounds or matching tracked callsign
|
||||||
Payload: %{
|
Payload: %{
|
||||||
id: string,
|
id: string,
|
||||||
callsign: string,
|
callsign: string,
|
||||||
|
|
@ -148,12 +156,70 @@ defmodule AprsmeWeb.MobileChannel do
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info({:streaming_packet, packet}, socket) do
|
def handle_in("search_callsign", %{"query" => query} = payload, socket) do
|
||||||
# Convert packet to mobile-friendly format
|
limit = Map.get(payload, "limit", 50)
|
||||||
packet_data = build_mobile_packet(packet)
|
limit = min(limit, 500)
|
||||||
|
|
||||||
# Push packet to mobile client
|
Logger.debug("Mobile client searching for callsign: #{query}")
|
||||||
push(socket, "packet", packet_data)
|
|
||||||
|
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}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
@ -297,4 +363,115 @@ defmodule AprsmeWeb.MobileChannel do
|
||||||
|
|
||||||
socket
|
socket
|
||||||
end
|
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
|
end
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue