161 lines
4.8 KiB
Elixir
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
|