221 lines
5.6 KiB
Elixir
221 lines
5.6 KiB
Elixir
defmodule Aprsme.StreamingPacketsPubSub do
|
|
@moduledoc """
|
|
Global PubSub system for streaming APRS packets to subscribers based on geographic bounds.
|
|
|
|
This module provides efficient real-time packet distribution with geographic filtering,
|
|
allowing multiple GenServers to subscribe to packet streams filtered by lat/long bounds.
|
|
"""
|
|
|
|
use GenServer
|
|
|
|
@table_name :streaming_packets_subscribers
|
|
|
|
# Client API
|
|
|
|
@doc """
|
|
Starts the StreamingPacketsPubSub GenServer.
|
|
"""
|
|
def start_link(opts \\ []) do
|
|
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
|
end
|
|
|
|
@doc """
|
|
Subscribe to packets within the specified geographic bounds.
|
|
|
|
## Parameters
|
|
- pid: The process to receive packet notifications
|
|
- bounds: Map with :north, :south, :east, :west keys defining the geographic area
|
|
|
|
## Returns
|
|
- :ok
|
|
"""
|
|
def subscribe_to_bounds(pid, bounds) do
|
|
with_server({:error, :not_running}, fn ->
|
|
GenServer.call(__MODULE__, {:subscribe, pid, bounds})
|
|
end)
|
|
end
|
|
|
|
@doc """
|
|
Unsubscribe from packet notifications.
|
|
"""
|
|
def unsubscribe(pid) do
|
|
with_server(:ok, fn ->
|
|
GenServer.call(__MODULE__, {:unsubscribe, pid})
|
|
end)
|
|
end
|
|
|
|
@doc """
|
|
Broadcast a packet to all subscribers whose bounds contain the packet's location.
|
|
"""
|
|
def broadcast_packet(packet) do
|
|
with_server(:ok, fn ->
|
|
GenServer.cast(__MODULE__, {:broadcast, packet})
|
|
end)
|
|
end
|
|
|
|
@doc """
|
|
List all active subscribers and their bounds.
|
|
"""
|
|
def list_subscribers do
|
|
with_server([], fn ->
|
|
GenServer.call(__MODULE__, :list_subscribers)
|
|
end)
|
|
end
|
|
|
|
# Server Callbacks
|
|
|
|
@impl true
|
|
def init(_opts) do
|
|
# Create ETS table for fast lookups
|
|
_ = :ets.new(@table_name, [:set, :protected, :named_table, read_concurrency: true])
|
|
|
|
# Monitor subscribers for cleanup
|
|
_ = Process.flag(:trap_exit, true)
|
|
|
|
# pid => monitor_ref — track monitors to demonitor on unsubscribe/update
|
|
{:ok, %{monitors: %{}}}
|
|
end
|
|
|
|
@impl true
|
|
def handle_call({:subscribe, pid, bounds}, _from, state) do
|
|
if valid_bounds?(bounds) do
|
|
state = demonitor_if_exists(state, pid)
|
|
ref = Process.monitor(pid)
|
|
:ets.insert(@table_name, {pid, bounds})
|
|
{:reply, :ok, put_in(state.monitors[pid], ref)}
|
|
else
|
|
{:reply, {:error, :invalid_bounds}, state}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_call({:unsubscribe, pid}, _from, state) do
|
|
state = demonitor_if_exists(state, pid)
|
|
:ets.delete(@table_name, pid)
|
|
{:reply, :ok, state}
|
|
end
|
|
|
|
@impl true
|
|
def handle_call(:list_subscribers, _from, state) do
|
|
subscribers = :ets.tab2list(@table_name)
|
|
{:reply, subscribers, state}
|
|
end
|
|
|
|
@impl true
|
|
def handle_cast({:broadcast, packet}, state) do
|
|
_ = maybe_broadcast_packet(packet)
|
|
|
|
{:noreply, state}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:DOWN, _ref, :process, pid, _reason}, state) do
|
|
# Clean up subscriber when process dies
|
|
:ets.delete(@table_name, pid)
|
|
{:noreply, %{state | monitors: Map.delete(state.monitors, pid)}}
|
|
end
|
|
|
|
# Private functions
|
|
|
|
defp demonitor_if_exists(state, pid) do
|
|
case Map.pop(state.monitors, pid) do
|
|
{nil, _monitors} ->
|
|
state
|
|
|
|
{ref, monitors} ->
|
|
Process.demonitor(ref, [:flush])
|
|
%{state | monitors: monitors}
|
|
end
|
|
end
|
|
|
|
defp valid_bounds?(%{north: n, south: s, east: e, west: w}) do
|
|
all_bounds_numeric?(n, s, e, w) and
|
|
n >= s and
|
|
valid_latitude_range?(n) and
|
|
valid_latitude_range?(s) and
|
|
valid_longitude_range?(e) and
|
|
valid_longitude_range?(w)
|
|
end
|
|
|
|
defp valid_bounds?(_), do: false
|
|
|
|
defp all_bounds_numeric?(n, s, e, w) do
|
|
is_number(n) and is_number(s) and is_number(e) and is_number(w)
|
|
end
|
|
|
|
defp valid_latitude_range?(lat) do
|
|
lat >= -90 and lat <= 90
|
|
end
|
|
|
|
defp valid_longitude_range?(lon) do
|
|
lon >= -180 and lon <= 180
|
|
end
|
|
|
|
defp packet_in_bounds?(lat, lon, %{north: n, south: s, east: e, west: w}) do
|
|
lat_in_bounds = lat >= s and lat <= n
|
|
|
|
# Handle longitude wrap-around at international date line
|
|
lon_in_bounds =
|
|
if w > e do
|
|
# Bounds cross the date line
|
|
lon >= w or lon <= e
|
|
else
|
|
# Normal bounds
|
|
lon >= w and lon <= e
|
|
end
|
|
|
|
lat_in_bounds and lon_in_bounds
|
|
end
|
|
|
|
defp send_to_matching_subscribers(subscribers, lat, lon, packet, _server_pid) do
|
|
# send/2 to a dead PID is a no-op in Erlang (does not crash).
|
|
# Process.monitor :DOWN messages handle cleanup, so no alive? check needed.
|
|
subscribers
|
|
|> Stream.filter(fn {_pid, bounds} -> packet_in_bounds?(lat, lon, bounds) end)
|
|
|> Enum.each(fn {pid, _bounds} ->
|
|
send(pid, {:streaming_packet, packet})
|
|
end)
|
|
end
|
|
|
|
defp test_env? do
|
|
Application.get_env(:aprsme, :env) == :test
|
|
end
|
|
|
|
defp with_server(default, fun) do
|
|
if Process.whereis(__MODULE__) do
|
|
fun.()
|
|
else
|
|
default
|
|
end
|
|
end
|
|
|
|
defp maybe_broadcast_packet(packet) do
|
|
case packet_coordinates(packet) do
|
|
{lat, lon} ->
|
|
subscribers = :ets.tab2list(@table_name)
|
|
dispatch_packet_to_subscribers(subscribers, lat, lon, packet)
|
|
|
|
nil ->
|
|
:ok
|
|
end
|
|
end
|
|
|
|
defp packet_coordinates(packet) do
|
|
lat = packet[:latitude] || packet[:lat]
|
|
lon = packet[:longitude] || packet[:lon] || packet[:lng]
|
|
|
|
if lat && lon, do: {lat, lon}
|
|
end
|
|
|
|
defp dispatch_packet_to_subscribers(subscribers, lat, lon, packet) do
|
|
if test_env?() do
|
|
send_to_matching_subscribers(subscribers, lat, lon, packet, self())
|
|
else
|
|
server_pid = self()
|
|
|
|
Aprsme.BroadcastTaskSupervisor.async_execute(fn ->
|
|
send_to_matching_subscribers(subscribers, lat, lon, packet, server_pid)
|
|
end)
|
|
end
|
|
end
|
|
end
|