Turned on :error_handling, :underspecs, and :unmatched_returns in mix.exs dialyzer config. The 97 warnings this surfaced were fixed in place rather than suppressed: - unmatched_return (79): explicit discard with `_ = ...` for fire-and-forget side effects (Process.cancel_timer, :ets.new, send/2), and pattern-matched `:ok = ...` for control-plane Phoenix.PubSub subscribe/unsubscribe/broadcast calls so a future return-shape change fails loud. - contract_supertype (18): tightened @spec arg and return types on data_builder, historical_loader, url_params, packet_utils, encoding_utils, aprs_symbol, weather_controller, packet_replay to match each function's actual success typing. No behavioural change. mix compile clean, 1008 tests pass, dialyzer count is now 0.
223 lines
5.6 KiB
Elixir
223 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
|
|
|
|
require Logger
|
|
|
|
@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
|