get_recent_packets/1 and get_recent_packets_for_map/1 now accept a
:cursor option (%{received_at: DateTime, id: uuid}) instead of :offset
for deep paging. On the 100M-row partitioned packets table, offset
scanning was linear in page depth; keyset is constant.
Ordering tightened to (received_at DESC, id DESC) so the cursor is
stable across ties. idx_packets_received_desc still covers the lookup;
a dedicated (received_at DESC, id DESC) index isn't worth its size
given microsecond timestamp granularity.
historical_loader migrated from batch_offset*batch_size scheduling to
a sequential cursor chain. The old loader fanned out N Process.send_after
batches up-front which could arrive out-of-order; the cursor version
waits for batch N before scheduling N+1, and also short-circuits the
remaining schedule when an under-full batch signals exhaustion.
mobile_channel load_historical_packets dropped its meaningless
`offset: 0`; callsign history already lacked offset.
No LiveView or WebSocket payload shapes changed. offset: 0 (or omitted)
still behaves as first page; non-zero offsets are silently ignored.
611 lines
18 KiB
Elixir
611 lines
18 KiB
Elixir
defmodule AprsmeWeb.MobileChannel do
|
|
@moduledoc """
|
|
Channel for mobile clients to receive real-time APRS packets filtered by geographic bounds.
|
|
|
|
## Usage
|
|
|
|
1. Connect to socket: wss://aprs.me/mobile/websocket
|
|
2. Join channel: mobile:packets
|
|
3. Subscribe to bounds: push "subscribe_bounds" with bounds payload
|
|
4. Receive packets: listen for "packet" events
|
|
5. Update bounds: push "update_bounds" with new bounds
|
|
|
|
## Messages
|
|
|
|
### Client -> Server
|
|
|
|
- "subscribe_bounds" - Subscribe to packets within geographic bounds
|
|
Payload: %{north: float, south: float, east: float, west: float}
|
|
|
|
- "update_bounds" - Update subscription bounds (e.g., when user pans/zooms map)
|
|
Payload: %{north: float, south: float, east: float, west: float}
|
|
|
|
- "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 or matching tracked callsign
|
|
Payload: %{
|
|
id: string,
|
|
callsign: string,
|
|
lat: float,
|
|
lng: float,
|
|
timestamp: string (ISO 8601),
|
|
symbol_table_id: string,
|
|
symbol_code: string,
|
|
comment: string (optional),
|
|
altitude: float (optional),
|
|
speed: float (optional),
|
|
course: integer (optional),
|
|
path: string (optional)
|
|
}
|
|
|
|
- "subscription_confirmed" - Bounds subscription successful
|
|
Payload: %{bounds: map, message: string}
|
|
"""
|
|
use AprsmeWeb, :channel
|
|
|
|
alias AprsmeWeb.Live.Shared.CoordinateUtils
|
|
|
|
require Logger
|
|
|
|
# Per-socket rate limits on the expensive operations. Streaming packet
|
|
# delivery is unaffected — these only guard client-initiated queries.
|
|
@expensive_op_limit 30
|
|
@expensive_op_scale 60_000
|
|
|
|
@impl true
|
|
def join("mobile:packets", _payload, socket) do
|
|
Logger.info("Mobile client joined packets channel")
|
|
{:ok, %{message: "Connected to APRS mobile channel"}, socket}
|
|
end
|
|
|
|
@impl true
|
|
def handle_in(
|
|
"subscribe_bounds",
|
|
%{"north" => north, "south" => south, "east" => east, "west" => west} = payload,
|
|
socket
|
|
) do
|
|
Logger.info("Mobile websocket received subscribe_bounds: #{inspect(payload)}")
|
|
|
|
with :ok <- check_rate_limit(socket, "subscribe_bounds") do
|
|
bounds = %{
|
|
north: ensure_float(north),
|
|
south: ensure_float(south),
|
|
east: ensure_float(east),
|
|
west: ensure_float(west)
|
|
}
|
|
|
|
# Validate bounds
|
|
case validate_bounds(bounds) do
|
|
:ok ->
|
|
# Generate unique client ID
|
|
client_id = "mobile_#{:erlang.phash2(self())}"
|
|
|
|
# Subscribe to StreamingPacketsPubSub with bounds
|
|
Aprsme.StreamingPacketsPubSub.subscribe_to_bounds(self(), bounds)
|
|
|
|
# Store client info in socket
|
|
socket =
|
|
socket
|
|
|> assign(:client_id, client_id)
|
|
|> assign(:bounds, bounds)
|
|
|> assign(:subscribed, true)
|
|
|
|
Logger.info("Mobile client #{client_id} subscribed to bounds: #{inspect(bounds)}")
|
|
|
|
# Load and send historical packets
|
|
socket = load_historical_packets(socket, bounds, payload)
|
|
|
|
{:reply, {:ok, %{bounds: bounds, message: "Subscribed to packet stream"}}, socket}
|
|
|
|
{:error, reason} ->
|
|
{:reply, {:error, %{message: reason}}, socket}
|
|
end
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_in(
|
|
"update_bounds",
|
|
%{"north" => north, "south" => south, "east" => east, "west" => west} = payload,
|
|
socket
|
|
) do
|
|
Logger.info("Mobile websocket received update_bounds: #{inspect(payload)}")
|
|
|
|
bounds = %{
|
|
north: ensure_float(north),
|
|
south: ensure_float(south),
|
|
east: ensure_float(east),
|
|
west: ensure_float(west)
|
|
}
|
|
|
|
# Check if subscribed
|
|
if Map.get(socket.assigns, :subscribed, false) do
|
|
# Validate bounds
|
|
case validate_bounds(bounds) do
|
|
:ok ->
|
|
update_subscription_bounds(socket, bounds)
|
|
|
|
{:error, reason} ->
|
|
{:reply, {:error, %{message: reason}}, socket}
|
|
end
|
|
else
|
|
{:reply, {:error, %{message: "Not subscribed. Call subscribe_bounds first."}}, socket}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_in("unsubscribe", payload, socket) do
|
|
Logger.info("Mobile websocket received unsubscribe: #{inspect(payload)}")
|
|
|
|
if socket.assigns[:subscribed] && socket.assigns[:bounds] do
|
|
Aprsme.StreamingPacketsPubSub.unsubscribe(self())
|
|
|
|
socket =
|
|
socket
|
|
|> assign(:subscribed, false)
|
|
|> assign(:bounds, nil)
|
|
|
|
Logger.info("Mobile client #{socket.assigns[:client_id]} unsubscribed from packet stream")
|
|
|
|
{:reply, {:ok, %{message: "Unsubscribed from packet stream"}}, socket}
|
|
else
|
|
{:reply, {:ok, %{message: "Not subscribed"}}, socket}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_in("search_callsign", %{"query" => query} = payload, socket) do
|
|
Logger.info("Mobile websocket received search_callsign: #{inspect(payload)}")
|
|
|
|
with :ok <- check_rate_limit(socket, "search_callsign") do
|
|
# Trim whitespace and control characters from query
|
|
query = String.trim(query)
|
|
|
|
limit = payload |> Map.get("limit", 50) |> ensure_integer(50)
|
|
limit = min(limit, 500)
|
|
|
|
results = search_callsign(query, limit)
|
|
|
|
{:reply, {:ok, %{results: results, count: length(results)}}, socket}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_in("subscribe_callsign", %{"callsign" => callsign} = payload, socket) do
|
|
Logger.info("Mobile websocket received subscribe_callsign: #{inspect(payload)}")
|
|
|
|
with :ok <- check_rate_limit(socket, "subscribe_callsign") do
|
|
hours_back = payload |> Map.get("hours_back", 24) |> ensure_integer(24)
|
|
# Max 1 week
|
|
hours_back = min(hours_back, 168)
|
|
|
|
# Normalize callsign - trim whitespace and convert to uppercase
|
|
callsign = callsign |> String.trim() |> String.upcase()
|
|
|
|
# Load historical packets for this callsign
|
|
socket = load_callsign_history(socket, callsign, hours_back)
|
|
|
|
socket = ensure_callsign_subscription(socket)
|
|
|
|
# Store tracked callsign in socket
|
|
socket = assign(socket, :tracked_callsign, callsign)
|
|
|
|
{:reply, {:ok, %{callsign: callsign, message: "Subscribed to callsign updates"}}, socket}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_in("unsubscribe_callsign", payload, socket) do
|
|
Logger.info("Mobile websocket received unsubscribe_callsign: #{inspect(payload)}")
|
|
|
|
if socket.assigns[:tracked_callsign] do
|
|
callsign = socket.assigns.tracked_callsign
|
|
|
|
socket =
|
|
socket
|
|
|> remove_callsign_subscription()
|
|
|> assign(: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
|
|
maybe_push_tracked_packet(socket, packet)
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:postgres_packet, packet}, socket) do
|
|
maybe_push_tracked_packet(socket, packet)
|
|
end
|
|
|
|
def handle_info(_message, socket) do
|
|
{:noreply, socket}
|
|
end
|
|
|
|
defp maybe_push_tracked_packet(socket, packet) 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
|
|
|
|
# Per-socket limiter for the expensive client-initiated handlers
|
|
# (subscribe_bounds, subscribe_callsign, search_callsign). Returns :ok on
|
|
# allow or a {:reply, ..., socket} tuple that propagates straight out of the
|
|
# with block to the channel reply path on deny.
|
|
defp check_rate_limit(socket, op) do
|
|
key = "mobile_channel:#{op}:#{:erlang.phash2(self())}"
|
|
|
|
case Aprsme.RateLimiter.hit(key, @expensive_op_scale, @expensive_op_limit) do
|
|
{:allow, _count} ->
|
|
:ok
|
|
|
|
{:deny, retry_after} ->
|
|
Logger.warning("Mobile channel rate-limited op=#{op} client=#{inspect(self())}")
|
|
|
|
{:reply, {:error, %{message: "Rate limit exceeded; retry later", retry_after_ms: retry_after}}, socket}
|
|
end
|
|
end
|
|
|
|
# Push packets to the client in moderately sized batches under the new
|
|
# `packets` event. Keeps the per-packet `packet` event format intact for
|
|
# streaming deliveries; bulk historical loads move through this path so
|
|
# clients don't get hit with 5000 individual WebSocket frames.
|
|
@packets_batch_size 100
|
|
defp push_packets_batch(_socket, []), do: :ok
|
|
|
|
defp push_packets_batch(socket, packets) do
|
|
packets
|
|
|> Stream.map(&build_mobile_packet/1)
|
|
|> Stream.chunk_every(@packets_batch_size)
|
|
|> Enum.each(fn chunk ->
|
|
push(socket, "packets", %{packets: chunk, count: length(chunk)})
|
|
end)
|
|
end
|
|
|
|
# Clean up on terminate
|
|
@impl true
|
|
def terminate(_reason, socket) do
|
|
if socket.assigns[:subscribed] && socket.assigns[:bounds] do
|
|
Aprsme.StreamingPacketsPubSub.unsubscribe(self())
|
|
end
|
|
|
|
if callsign_subscription_active?(socket) do
|
|
Phoenix.PubSub.unsubscribe(Aprsme.PubSub, "postgres:aprsme_packets")
|
|
end
|
|
|
|
:ok
|
|
end
|
|
|
|
# Private functions
|
|
|
|
defp validate_bounds(%{north: north, south: south, east: east, west: west}) do
|
|
cond do
|
|
not all_numeric?(north, south, east, west) ->
|
|
{:error, "All bounds must be numeric"}
|
|
|
|
not valid_latitudes?(north, south) ->
|
|
{:error, "Latitude must be between -90 and 90"}
|
|
|
|
not valid_longitudes?(east, west) ->
|
|
{:error, "Longitude must be between -180 and 180"}
|
|
|
|
north <= south ->
|
|
{:error, "North must be greater than south"}
|
|
|
|
true ->
|
|
:ok
|
|
end
|
|
end
|
|
|
|
defp all_numeric?(north, south, east, west) do
|
|
is_number(north) and is_number(south) and is_number(east) and is_number(west)
|
|
end
|
|
|
|
defp valid_latitudes?(north, south) do
|
|
north >= -90 and north <= 90 and south >= -90 and south <= 90
|
|
end
|
|
|
|
defp valid_longitudes?(east, west) do
|
|
east >= -180 and east <= 180 and west >= -180 and west <= 180
|
|
end
|
|
|
|
defp ensure_float(value) when is_integer(value), do: value * 1.0
|
|
defp ensure_float(value) when is_float(value), do: value
|
|
|
|
defp ensure_float(value) when is_binary(value) do
|
|
case Float.parse(String.trim(value)) do
|
|
{float, ""} -> float
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
defp ensure_float(_value), do: nil
|
|
|
|
defp ensure_integer(value, _default) when is_integer(value), do: value
|
|
|
|
defp ensure_integer(value, default) when is_binary(value) do
|
|
case Integer.parse(String.trim(value)) do
|
|
{int, _} -> int
|
|
:error -> default
|
|
end
|
|
end
|
|
|
|
defp ensure_integer(_value, default), do: default
|
|
|
|
defp build_mobile_packet(packet) do
|
|
# Extract coordinates
|
|
{lat, lon, _data_extended} = CoordinateUtils.get_coordinates(packet)
|
|
|
|
# Build minimal packet data for mobile
|
|
%{
|
|
id: get_field(packet, :id),
|
|
callsign: get_field(packet, :sender) || get_field(packet, :base_callsign),
|
|
lat: to_float(lat),
|
|
lng: to_float(lon),
|
|
timestamp: packet |> get_field(:received_at) |> format_timestamp(),
|
|
symbol_table_id: get_field(packet, :symbol_table_id, "/"),
|
|
symbol_code: get_field(packet, :symbol_code, ">"),
|
|
comment: get_field(packet, :comment),
|
|
altitude: get_field(packet, :altitude),
|
|
speed: get_field(packet, :speed),
|
|
course: get_field(packet, :course),
|
|
path: get_field(packet, :path)
|
|
}
|
|
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|
|
|> Map.new()
|
|
end
|
|
|
|
defp get_field(packet, field, default \\ nil) when is_map(packet) do
|
|
Map.get(packet, field) || Map.get(packet, to_string(field), default)
|
|
end
|
|
|
|
defp to_float(nil), do: nil
|
|
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
|
|
{float, _} -> float
|
|
:error -> nil
|
|
end
|
|
end
|
|
|
|
defp to_float(%Decimal{} = value), do: Decimal.to_float(value)
|
|
defp to_float(_), do: nil
|
|
|
|
defp format_timestamp(nil), do: nil
|
|
|
|
defp format_timestamp(%DateTime{} = dt) do
|
|
DateTime.to_iso8601(dt)
|
|
end
|
|
|
|
defp format_timestamp(%NaiveDateTime{} = ndt) do
|
|
ndt
|
|
|> DateTime.from_naive!("Etc/UTC")
|
|
|> DateTime.to_iso8601()
|
|
end
|
|
|
|
defp format_timestamp(_), do: nil
|
|
|
|
defp load_historical_packets(socket, bounds, payload) do
|
|
# Get optional parameters from payload
|
|
limit = payload |> Map.get("limit", 1000) |> ensure_integer(1000)
|
|
hours_back = payload |> Map.get("hours_back", 1) |> ensure_integer(1)
|
|
|
|
# Ensure reasonable limits
|
|
limit = min(limit, 5000)
|
|
hours_back = min(hours_back, 24)
|
|
|
|
# Query historical packets within bounds
|
|
bounds_list = [
|
|
bounds.west,
|
|
bounds.south,
|
|
bounds.east,
|
|
bounds.north
|
|
]
|
|
|
|
params = %{
|
|
bounds: bounds_list,
|
|
limit: limit,
|
|
hours_back: hours_back
|
|
}
|
|
|
|
Logger.debug("Mobile client loading historical packets with params: #{inspect(params)}")
|
|
|
|
packets =
|
|
try do
|
|
Aprsme.Packets.get_recent_packets(params)
|
|
rescue
|
|
error ->
|
|
Logger.error("Error loading historical packets for mobile client: #{inspect(error)}")
|
|
[]
|
|
end
|
|
|
|
Logger.info("Loaded #{length(packets)} historical packets for mobile client")
|
|
|
|
# Send historical packets as batched "packets" events rather than one
|
|
# "packet" frame per row — previously a single subscription could queue
|
|
# thousands of WebSocket frames.
|
|
push_packets_batch(socket, packets)
|
|
|
|
socket
|
|
end
|
|
|
|
defp search_callsign(query, limit) do
|
|
import Ecto.Query
|
|
|
|
# Normalize query to uppercase
|
|
query = String.upcase(query)
|
|
|
|
Logger.info("Searching for callsign: #{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
|
|
|
|
Logger.info("Search pattern: #{pattern}")
|
|
|
|
# Query for matching callsigns - optimized version
|
|
# Use a subquery to get only the most recent packet per sender
|
|
results =
|
|
try do
|
|
subquery =
|
|
from p in Aprsme.Packet,
|
|
where: ilike(p.sender, ^pattern),
|
|
distinct: p.sender,
|
|
select: %{
|
|
callsign: p.sender,
|
|
base_callsign: p.base_callsign,
|
|
last_seen: max(p.received_at),
|
|
# Use first_value for lat/lon from most recent packet
|
|
lat: fragment("(array_agg(? ORDER BY ? DESC))[1]", p.lat, p.received_at),
|
|
lon: fragment("(array_agg(? ORDER BY ? DESC))[1]", p.lon, p.received_at)
|
|
},
|
|
group_by: [p.sender, p.base_callsign],
|
|
order_by: [desc: max(p.received_at)],
|
|
limit: ^limit
|
|
|
|
results = Aprsme.Repo.all(subquery)
|
|
Logger.info("Search returned #{length(results)} results: #{inspect(results)}")
|
|
results
|
|
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 batched "packets" events — see push_packets_batch/2.
|
|
push_packets_batch(socket, packets)
|
|
|
|
socket
|
|
end
|
|
|
|
defp callsign_matches?(packet_callsign, tracked_callsign) do
|
|
normalized_packet = String.upcase(packet_callsign)
|
|
normalized_tracked = String.upcase(tracked_callsign)
|
|
do_callsign_match(normalized_packet, normalized_tracked)
|
|
end
|
|
|
|
defp do_callsign_match(callsign, callsign), do: true
|
|
|
|
defp do_callsign_match(packet_callsign, tracked_callsign) do
|
|
if String.contains?(tracked_callsign, "*") do
|
|
pattern = String.replace(tracked_callsign, "*", "")
|
|
String.starts_with?(packet_callsign, pattern)
|
|
else
|
|
String.starts_with?(packet_callsign, tracked_callsign <> "-")
|
|
end
|
|
end
|
|
|
|
defp update_subscription_bounds(socket, bounds) do
|
|
# Unsubscribe from old bounds if they exist
|
|
if socket.assigns[:bounds] do
|
|
Aprsme.StreamingPacketsPubSub.unsubscribe(self())
|
|
end
|
|
|
|
# Subscribe to new bounds
|
|
Aprsme.StreamingPacketsPubSub.subscribe_to_bounds(self(), bounds)
|
|
|
|
socket = assign(socket, :bounds, bounds)
|
|
|
|
Logger.debug("Mobile client #{socket.assigns.client_id} updated bounds: #{inspect(bounds)}")
|
|
|
|
{:reply, {:ok, %{bounds: bounds, message: "Bounds updated"}}, socket}
|
|
end
|
|
|
|
defp ensure_callsign_subscription(socket) do
|
|
if callsign_subscription_active?(socket) do
|
|
socket
|
|
else
|
|
Phoenix.PubSub.subscribe(Aprsme.PubSub, "postgres:aprsme_packets")
|
|
assign(socket, :callsign_subscription_active, true)
|
|
end
|
|
end
|
|
|
|
defp remove_callsign_subscription(socket) do
|
|
if callsign_subscription_active?(socket) do
|
|
Phoenix.PubSub.unsubscribe(Aprsme.PubSub, "postgres:aprsme_packets")
|
|
assign(socket, :callsign_subscription_active, false)
|
|
else
|
|
socket
|
|
end
|
|
end
|
|
|
|
defp callsign_subscription_active?(socket) do
|
|
Map.get(socket.assigns, :callsign_subscription_active, false)
|
|
end
|
|
end
|