Some FT8/Q65 multi-decoder spots omit sender/receiver callsigns from the JSON payload, but the MQTT topic routing path always carries them in positions 5 (sc) and 6 (rc): pskr/filter/v2/<band>/<mode>/<sc>/<rc>/<sl>/<rl>/<sa>/<ra> Client now extracts callsigns from the topic and threads them through Aggregator.ingest → Pskr.parse_spot, where they act as fallback when the JSON sc/rc keys are nil or empty. JSON payload values still take precedence when present — the topic is only used as a safety net. Co-Authored-By: Claude <noreply@anthropic.com>
321 lines
11 KiB
Elixir
321 lines
11 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(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}"
|
|
|
|
# Extract sender/receiver callsigns from a received topic.
|
|
# Topic format: pskr/filter/v2/<band>/<mode>/<sc>/<rc>/<sl>/<rl>/<sa>/<ra>
|
|
# Some FT8/Q65 multi-decoder spots omit `sc`/`rc` from the JSON
|
|
# payload; the topic always carries them in the routing path.
|
|
defp topic_callsigns(topic) do
|
|
case String.split(topic, "/") do
|
|
["pskr", "filter", "v2", _band, _mode, sc, rc | _rest] ->
|
|
opts = []
|
|
opts = if sc != "+" and sc != "", do: Keyword.put(opts, :sender_callsign, sc), else: opts
|
|
opts = if rc != "+" and rc != "", do: Keyword.put(opts, :receiver_callsign, rc), else: opts
|
|
opts
|
|
|
|
_ ->
|
|
[]
|
|
end
|
|
end
|
|
|
|
# ---------- Inbound dispatch ----------
|
|
|
|
defp process_buffer(state) do
|
|
case Mqtt.parse(state.buffer) do
|
|
{:ok, {:publish, topic, payload}, rest} ->
|
|
opts = topic_callsigns(topic)
|
|
Aggregator.ingest(state.aggregator, payload, opts)
|
|
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
|