aprs.me/lib/aprsme/aprs_is_connection.ex

161 lines
4.8 KiB
Elixir

defmodule Aprsme.AprsIsConnection do
@moduledoc """
Maintains a supervised TCP connection to APRS-IS, with reconnection logic and
exponential backoff. Broadcasts each received line via Phoenix.PubSub and emits
telemetry events for connection, disconnection, errors, and packet receipt.
"""
use GenServer
alias Aprsme.LogSanitizer
require Logger
@type state :: %{
socket: port() | nil,
backoff: non_neg_integer()
}
@reconnect_initial 2_000
@reconnect_max 60_000
@pubsub_topic "aprs_is:raw"
# Public API
@spec start_link(Keyword.t()) :: GenServer.on_start()
def start_link(_opts) do
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
end
@doc """
Send a raw string to APRS-IS.
"""
@spec send_packet(String.t()) :: :ok | {:error, term()}
def send_packet(packet) do
GenServer.call(__MODULE__, {:send, packet})
end
# GenServer callbacks
@impl true
def init(state) do
# Check if APRS connection is disabled (e.g., in test mode)
disable_connection = Application.get_env(:aprsme, :disable_aprs_connection, false)
if disable_connection do
Logger.info("APRS-IS connection disabled (test mode)")
{:ok, Map.merge(%{socket: nil, backoff: @reconnect_initial}, state)}
else
schedule_connect(0)
{:ok, Map.merge(%{socket: nil, backoff: @reconnect_initial}, state)}
end
end
@impl true
def handle_info(:connect, state) do
case connect_aprs_is() do
{:ok, socket} ->
Logger.info("Connected to APRS-IS")
:telemetry.execute([:aprsme, :is, :connected], %{}, %{})
{:noreply, %{state | socket: socket, backoff: @reconnect_initial}}
{:error, reason} ->
Logger.error("APRS-IS connection failed, retrying with backoff",
connection_error:
LogSanitizer.log_data(
reason: reason,
backoff_ms: state.backoff,
next_attempt: DateTime.add(DateTime.utc_now(), state.backoff, :millisecond)
)
)
:telemetry.execute([:aprsme, :is, :connect_error], %{}, %{reason: reason})
schedule_connect(state.backoff)
{:noreply, %{state | socket: nil, backoff: min(state.backoff * 2, @reconnect_max)}}
end
end
@impl true
def handle_info({:tcp, _socket, data}, state) do
# Each line received from APRS-IS
Phoenix.PubSub.broadcast(Aprsme.PubSub, @pubsub_topic, {:aprsme_is_line, data})
:telemetry.execute([:aprsme, :is, :packet], %{size: byte_size(data)}, %{data: data})
{:noreply, state}
end
@impl true
def handle_info({:tcp_closed, _socket}, state) do
Logger.warning("APRS-IS connection closed, initiating reconnection",
connection_status:
LogSanitizer.log_data(
event: "connection_closed",
reconnect_delay_ms: @reconnect_initial,
backoff_reset: true
)
)
:telemetry.execute([:aprsme, :is, :disconnected], %{}, %{})
schedule_connect(@reconnect_initial)
{:noreply, %{state | socket: nil, backoff: @reconnect_initial}}
end
@impl true
def handle_info({:tcp_error, _socket, reason}, state) do
Logger.error("APRS-IS TCP error detected, reconnecting",
tcp_error:
LogSanitizer.log_data(
reason: reason,
reconnect_delay_ms: @reconnect_initial,
socket_reset: true
)
)
:telemetry.execute([:aprsme, :is, :tcp_error], %{}, %{reason: reason})
schedule_connect(@reconnect_initial)
{:noreply, %{state | socket: nil, backoff: @reconnect_initial}}
end
@impl true
def handle_call({:send, packet}, _from, %{socket: socket} = state) when is_port(socket) do
:ok = :gen_tcp.send(socket, packet <> "\r\n")
{:reply, :ok, state}
end
def handle_call({:send, _packet}, _from, state) do
{:reply, {:error, :not_connected}, state}
end
@impl true
def terminate(_reason, %{socket: socket}) when is_port(socket) do
:gen_tcp.close(socket)
:ok
end
def terminate(_reason, _state), do: :ok
defp schedule_connect(delay) do
Process.send_after(self(), :connect, delay)
end
defp connect_aprs_is do
host = Application.get_env(:aprsme, :aprsme_is_host, ~c"rotate.aprs2.net")
port = Application.get_env(:aprsme, :aprsme_is_port, 14_580)
callsign = Application.get_env(:aprsme, :aprsme_is_callsign, "N0CALL")
passcode = Application.get_env(:aprsme, :aprsme_is_passcode, "00000")
filter = Application.get_env(:aprsme, :aprsme_is_filter, "")
opts = [:binary, active: true, packet: :line, keepalive: true]
case :gen_tcp.connect(host, port, opts) do
{:ok, socket} ->
login = "user #{callsign} pass #{passcode} vers aprs.me 0.1 #{filter}\r\n"
:ok = :gen_tcp.send(socket, login)
{:ok, socket}
error ->
error
end
end
@impl true
def code_change(_old_vsn, state, _extra) do
{:ok, state}
end
end