prop/lib/microwaveprop/pskr/client.ex
Graham McIntire a1f561dfb8
fix(pskr): require both ends in CONUS for MQTT subscriptions
Pin both the sender and receiver DXCC slots to 291 so the broker
only forwards CONUS↔CONUS spots. The previous two-topic-per-band
filter (sender_us OR receiver_us) was leaking US↔CA paths through
the receiver-side wildcard, and the project doesn't have NWP
coverage for the non-US end yet.

When HRDPS lights up we'll add a Canada-anchored topic alongside.
2026-05-04 13:07:45 -05:00

303 lines
10 KiB
Elixir

defmodule Microwaveprop.Pskr.Client do
@moduledoc """
MQTT 3.1.1 client for the PSK Reporter live spot firehose.
Connects to `mqtt.pskreporter.info:1883` over plain TCP (the
broker doesn't expose TLS), subscribes to a curated set of
topics, and forwards every PUBLISH payload to
`Pskr.Aggregator.ingest/2`. Each topic anchors **both** ends on
USA (DXCC 291) so we only ingest CONUS-to-CONUS paths — that's
the only path geometry where both endpoints sit inside HRRR
coverage. When HRDPS lights up we'll add a Canada-anchored
topic alongside.
## No external MQTT library
We talk to the broker with `:gen_tcp` and the codec in
`Microwaveprop.Pskr.Mqtt`. emqtt's transitive `quicer` dep was a
CMake/native-build mess in our slim Debian builder image; the
alternative pure-Elixir client (tortoise311) bundles a
gen_state_machine + retry/inflight stack we don't use because
we're subscribe-only at QoS 0. Implementing the wire protocol
inline is ~150 lines of binary parsing — less code than the
dependency-management overhead of either choice.
## Cluster singleton
All replicas start this GenServer; **only one across the cluster
actually connects to MQTT**. We elect a leader via
`:global.register_name/2` (atomic across the BEAM cluster, which
libcluster gives us). The losing nodes stay in `:standby` mode:
no socket, no DB writes, just an idle process watching for the
leader's `nodedown` to re-run election.
Handover loses at most one flush cycle (~60 s) of in-flight
aggregates from the old leader's Aggregator — acceptable, since
the upsert-on-conflict logic in `Pskr.Aggregator` merges any
overlapping path-hours additively.
## Bands
Subscribes to **VHF and up** by default (6m, 2m, 70cm, 23cm). HF
spots are dominated by ionospheric propagation, which isn't what
this project models. Microwave bands are listed too but spots
there are vanishingly rare (FT8 activity is mostly 144 MHz /
432 MHz / 1296 MHz).
## Off in dev/test
`:microwaveprop, :pskr_mqtt_enabled` defaults to `false` in
dev/test so `mix test` doesn't open an outbound connection. Prod
flips it on via runtime.exs.
"""
use GenServer
alias Microwaveprop.Pskr.Aggregator
alias Microwaveprop.Pskr.Mqtt
require Logger
@broker_host ~c"mqtt.pskreporter.info"
@broker_port 1883
@keepalive_seconds 60
@keepalive_ms @keepalive_seconds * 1000
# USA = 291 (continental). PSK Reporter splits Alaska (6) and
# Hawaii (110) as separate DXCCs but neither is currently in the
# HRRR domain; HRDPS will eventually add Canada (1).
@us_dxcc 291
@default_bands ~w(6m 2m 70cm 23cm 13cm 9cm 6cm 3cm 1.25cm)
@spec start_link(keyword()) :: GenServer.on_start()
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: Keyword.get(opts, :name, __MODULE__))
end
@impl true
def init(opts) do
Process.flag(:trap_exit, true)
# `:net_kernel.monitor_nodes/1` lands `{:nodeup, node}` /
# `{:nodedown, node}` in our mailbox — that's what tells a
# standby client the leader vanished and election should
# re-run.
:ok = :net_kernel.monitor_nodes(true)
state = %{
role: :standby,
socket: nil,
buffer: <<>>,
bands: Keyword.get(opts, :bands, @default_bands),
aggregator: Keyword.get(opts, :aggregator, Aggregator),
client_id: Keyword.get(opts, :client_id, default_client_id()),
received: 0,
ping_timer: nil
}
{:ok, state, {:continue, :elect}}
end
@impl true
def handle_continue(:elect, state) do
case :global.register_name(__MODULE__, self()) do
:yes ->
Logger.info("Pskr.Client elected leader on #{node()} — connecting to MQTT")
{:noreply, %{state | role: :active}, {:continue, :connect}}
:no ->
Logger.info("Pskr.Client standby on #{node()} (leader: #{inspect(:global.whereis_name(__MODULE__))})")
{:noreply, state}
end
end
def handle_continue(:connect, state) do
case do_connect(state) do
{:ok, socket, ping_timer} ->
{:noreply, %{state | socket: socket, ping_timer: ping_timer}}
{:error, reason} ->
Logger.error("Pskr.Client connect failed: #{inspect(reason)} — retrying in 30s")
Process.send_after(self(), :reconnect, 30_000)
{:noreply, state}
end
end
@impl true
def handle_info(:reconnect, state), do: {:noreply, state, {:continue, :connect}}
def handle_info(:ping, %{socket: socket} = state) when is_port(socket) do
case :gen_tcp.send(socket, Mqtt.pingreq()) do
:ok ->
{:noreply, %{state | ping_timer: schedule_ping()}}
{:error, reason} ->
Logger.warning("Pskr.Client PINGREQ send failed: #{inspect(reason)}")
{:noreply, drop_socket(state), {:continue, :connect}}
end
end
def handle_info(:ping, state), do: {:noreply, state}
# Standby clients re-run election on any cluster topology change.
# Active leaders ignore — their registration stays valid until
# *their* node disappears.
def handle_info({event, _node}, %{role: :standby} = state) when event in [:nodeup, :nodedown] do
{:noreply, state, {:continue, :elect}}
end
def handle_info({event, _node}, state) when event in [:nodeup, :nodedown], do: {:noreply, state}
def handle_info({:tcp, socket, data}, %{socket: socket} = state) do
:inet.setopts(socket, active: :once)
{:noreply, process_buffer(%{state | buffer: state.buffer <> data})}
end
def handle_info({:tcp_closed, socket}, %{socket: socket} = state) do
Logger.warning("Pskr.Client TCP closed (received #{state.received} so far) — reconnecting in 5s")
Process.send_after(self(), :reconnect, 5_000)
{:noreply, drop_socket(state)}
end
def handle_info({:tcp_error, socket, reason}, %{socket: socket} = state) do
Logger.warning("Pskr.Client TCP error #{inspect(reason)} — reconnecting in 5s")
Process.send_after(self(), :reconnect, 5_000)
{:noreply, drop_socket(state)}
end
def handle_info(_other, state), do: {:noreply, state}
@impl true
def terminate(_reason, %{socket: socket}) when is_port(socket) do
_ = :gen_tcp.send(socket, Mqtt.disconnect())
_ = :gen_tcp.close(socket)
:ok
end
def terminate(_reason, _state), do: :ok
# ---------- Connection setup ----------
defp do_connect(state) do
# Open in passive mode for the synchronous CONNECT/CONNACK
# handshake — `:gen_tcp.recv/3` only works on passive sockets
# and returns `:einval` if the socket is in any active mode.
# After CONNACK we flip to `active: :once` so PUBLISHes arrive
# as `{:tcp, socket, data}` messages.
#
# `:inet` forces IPv4 resolution + an IPv4 socket. Without
# this, BEAM can pick a broker AAAA record while our K8s pod
# has IPv4-only egress.
tcp_opts = [:binary, :inet, active: false, packet: :raw, keepalive: true]
case :gen_tcp.connect(@broker_host, @broker_port, tcp_opts, 10_000) do
{:ok, socket} -> handshake(socket, state)
{:error, _} = err -> err
end
end
defp handshake(socket, state) do
with :ok <- :gen_tcp.send(socket, Mqtt.connect(state.client_id, @keepalive_seconds)),
{:ok, :connected, rest} <- await_connack(socket, <<>>),
:ok <- :gen_tcp.send(socket, subscribe_packet(state.bands)),
:ok <- :inet.setopts(socket, active: :once) do
Logger.info("Pskr.Client connected as #{state.client_id}, subscribed to #{length(state.bands)} bands")
# `rest` holds anything the broker sent piggybacked after
# CONNACK — feed it back into the buffer so we don't lose
# the SUBACK or an early PUBLISH.
send(self(), {:tcp, socket, rest})
{:ok, socket, schedule_ping()}
else
{:error, _} = err ->
# Close the connected-but-not-handshaked socket so we
# don't leak ports across retries.
_ = :gen_tcp.close(socket)
err
end
end
# CONNACK arrives synchronously right after CONNECT, so a small
# blocking read here keeps the state machine simple. If we don't
# get it within 10s, treat the connection as dead.
defp await_connack(socket, buffer) do
case Mqtt.parse(buffer) do
{:ok, {:connack, 0, _}, rest} ->
{:ok, :connected, rest}
{:ok, {:connack, code, _}, _} ->
{:error, {:connack_refused, code}}
{:incomplete, buf} ->
case :gen_tcp.recv(socket, 0, 10_000) do
{:ok, chunk} -> await_connack(socket, buf <> chunk)
{:error, _} = err -> err
end
{:error, _} = err ->
err
end
end
defp subscribe_packet(bands) do
topics = Enum.map(bands, fn band -> {topic_for(band), 0} end)
Mqtt.subscribe(1, topics)
end
# Topic order (from the broker's landing page):
# pskr/filter/v2/<band>/<mode>/<sc>/<rc>/<sl>/<rl>/<sa>/<ra>
# Both DXCC slots pinned to USA so the broker drops US↔CA, US↔EU,
# etc. before the spots ever reach us.
defp topic_for(band), do: "pskr/filter/v2/#{band}/+/+/+/+/+/#{@us_dxcc}/#{@us_dxcc}"
# ---------- Inbound dispatch ----------
defp process_buffer(state) do
case Mqtt.parse(state.buffer) do
{:ok, {:publish, _topic, payload}, rest} ->
Aggregator.ingest(state.aggregator, payload)
process_buffer(%{state | buffer: rest, received: state.received + 1})
{:ok, {:suback, _id, results}, rest} ->
if Enum.any?(results, &(&1 == 0x80)) do
Logger.warning("Pskr.Client SUBACK contains failures: #{inspect(results)}")
end
process_buffer(%{state | buffer: rest})
{:ok, :pingresp, rest} ->
process_buffer(%{state | buffer: rest})
{:ok, _other, rest} ->
process_buffer(%{state | buffer: rest})
{:incomplete, buffer} ->
%{state | buffer: buffer}
{:error, reason} ->
Logger.error("Pskr.Client parse error: #{inspect(reason)} — dropping connection")
Process.send_after(self(), :reconnect, 1_000)
drop_socket(state)
end
end
defp drop_socket(state) do
if state.ping_timer, do: Process.cancel_timer(state.ping_timer)
if is_port(state.socket), do: :gen_tcp.close(state.socket)
%{state | socket: nil, buffer: <<>>, ping_timer: nil}
end
defp schedule_ping, do: Process.send_after(self(), :ping, @keepalive_ms)
# ---------- Client ID ----------
defp default_client_id do
suffix = 4 |> :crypto.strong_rand_bytes() |> Base.encode16(case: :lower)
"microwaveprop-#{node_short()}-#{suffix}"
end
defp node_short do
case System.get_env("POD_IP") do
nil -> node() |> to_string() |> String.replace(["@", "."], "-")
ip -> String.replace(ip, ".", "-")
end
end
end