aprs.me/lib/aprsme/aprs_is_connection.ex
Graham McIntire f5ab36b7e5
Fix APRS-IS connection by converting host to charlist
The host from Application.get_env returns a binary string, but :gen_tcp.connect
expects a charlist. This was causing ArgumentError on connection attempts.

Convert host to charlist if it's a binary before passing to :gen_tcp.connect.
2025-10-25 17:23:13 -05:00

189 lines
5.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
# Use circuit breaker for connection attempts
case Aprsme.CircuitBreaker.call(:aprs_is, &connect_aprs_is/0, 15_000) do
{:ok, {:ok, socket}} ->
Logger.info("Connected to APRS-IS")
:telemetry.execute([:aprsme, :is, :connected], %{}, %{})
{:noreply, %{state | socket: socket, backoff: @reconnect_initial}}
{:ok, {:error, reason}} ->
handle_connection_error(reason, state)
{:error, :circuit_open} ->
Logger.warning("APRS-IS circuit breaker is open, delaying reconnection")
# Use longer backoff when circuit is open
schedule_connect(@reconnect_max)
{:noreply, %{state | socket: nil, backoff: @reconnect_max}}
{:error, reason} ->
handle_connection_error(reason, state)
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
# Safely send to socket with error handling
case :gen_tcp.send(socket, packet <> "\r\n") do
:ok ->
{:reply, :ok, state}
{:error, reason} = error ->
Logger.error("Failed to send packet: #{inspect(reason)}")
# Socket is likely closed, reset state
{:reply, error, %{state | socket: nil}}
end
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 handle_connection_error(reason, state) do
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
defp connect_aprs_is do
host = Application.get_env(:aprsme, :aprs_is_server, ~c"rotate.aprs2.net")
port = Application.get_env(:aprsme, :aprs_is_port, 14_580)
callsign = Application.get_env(:aprsme, :aprs_is_login_id, "N0CALL")
passcode = Application.get_env(:aprsme, :aprs_is_password, "00000")
filter = Application.get_env(:aprsme, :aprs_is_default_filter, "")
# Convert host to charlist if it's a binary string
host = if is_binary(host), do: String.to_charlist(host), else: host
# Add timeout to prevent indefinite hanging
opts = [:binary, active: true, packet: :line, keepalive: true, send_timeout: 5000]
# 10 seconds
connect_timeout = 10_000
case :gen_tcp.connect(host, port, opts, connect_timeout) 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