Add PSK Reporter MQTT listener script
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
3f5b4cd60b
commit
ed741a8c0f
1 changed files with 355 additions and 0 deletions
355
scripts/pskr_mqtt_listen.py
Executable file
355
scripts/pskr_mqtt_listen.py
Executable file
|
|
@ -0,0 +1,355 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Connect to the PSK Reporter MQTT firehose over raw TCP and print each
|
||||||
|
received spot as a one-line JSON summary.
|
||||||
|
|
||||||
|
ZERO dependencies — uses only Python stdlib (socket + struct + json).
|
||||||
|
Implements just enough MQTT 3.1.1 to CONNECT, SUBSCRIBE, and read
|
||||||
|
PUBLISH packets at QoS 0.
|
||||||
|
|
||||||
|
Matches the subscription filter used by the prop app:
|
||||||
|
pskr/filter/v2/<band>/+/+/+/+/+/291/291
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/pskr_mqtt_listen.py # all default bands
|
||||||
|
python scripts/pskr_mqtt_listen.py --bands 2m # single band
|
||||||
|
python scripts/pskr_mqtt_listen.py --debug # hex dump of every packet
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import select
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
BROKER_HOST = "mqtt.pskreporter.info"
|
||||||
|
BROKER_PORT = 1883
|
||||||
|
KEEPALIVE_SEC = 60
|
||||||
|
US_DXCC = 291
|
||||||
|
|
||||||
|
BANDS = ["2m", "70cm", "23cm", "13cm", "9cm", "6cm", "3cm", "1.25cm"]
|
||||||
|
|
||||||
|
DEBUG = False
|
||||||
|
|
||||||
|
# ── minimal MQTT 3.1.1 primitives ────────────────────────────────────
|
||||||
|
|
||||||
|
def _vbint(n: int) -> bytes:
|
||||||
|
if n == 0:
|
||||||
|
return b"\x00"
|
||||||
|
parts = []
|
||||||
|
while n > 0:
|
||||||
|
parts.append(n & 0x7F)
|
||||||
|
n >>= 7
|
||||||
|
for i in range(len(parts) - 1):
|
||||||
|
parts[i] |= 0x80
|
||||||
|
return bytes(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _fixed_header(byte1: int, body: bytes) -> bytes:
|
||||||
|
return bytes([byte1]) + _vbint(len(body)) + body
|
||||||
|
|
||||||
|
|
||||||
|
def mqtt_connect(client_id: str) -> bytes:
|
||||||
|
"""
|
||||||
|
Build a CONNECT packet matching the Elixir Pskr.Mqtt.connect/2:
|
||||||
|
<<4::16, "MQTT", 0x04, 0x02, keepalive::16,
|
||||||
|
byte_size(client_id)::16, client_id::binary>>
|
||||||
|
"""
|
||||||
|
cid = client_id.encode()
|
||||||
|
body = (
|
||||||
|
b"\x00\x04MQTT" # protocol name + 2-byte length prefix
|
||||||
|
b"\x04" # protocol level (3.1.1)
|
||||||
|
b"\x02" # flags: clean session, no will, no auth
|
||||||
|
+ struct.pack(">H", KEEPALIVE_SEC)
|
||||||
|
+ struct.pack(">H", len(cid))
|
||||||
|
+ cid
|
||||||
|
)
|
||||||
|
return _fixed_header(0x10, body)
|
||||||
|
|
||||||
|
|
||||||
|
def mqtt_subscribe(packet_id: int, topics: list[tuple[str, int]]) -> bytes:
|
||||||
|
body = bytearray()
|
||||||
|
body.extend(struct.pack(">H", packet_id))
|
||||||
|
for topic, qos in topics:
|
||||||
|
t = topic.encode()
|
||||||
|
body.extend(struct.pack(">H", len(t)))
|
||||||
|
body.extend(t)
|
||||||
|
body.append(qos)
|
||||||
|
return _fixed_header(0x82, bytes(body))
|
||||||
|
|
||||||
|
|
||||||
|
def mqtt_pingreq() -> bytes:
|
||||||
|
return b"\xc0\x00"
|
||||||
|
|
||||||
|
|
||||||
|
def mqtt_disconnect() -> bytes:
|
||||||
|
return b"\xe0\x00"
|
||||||
|
|
||||||
|
|
||||||
|
# ── socket / packet helpers ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def _recv_exact(sock: socket.socket, n: int) -> bytes | None:
|
||||||
|
"""Read exactly n bytes. Returns None on EOF (not on timeout — callers
|
||||||
|
must use select to ensure data is ready before calling)."""
|
||||||
|
buf = bytearray()
|
||||||
|
while len(buf) < n:
|
||||||
|
try:
|
||||||
|
chunk = sock.recv(n - len(buf))
|
||||||
|
except (ConnectionError, OSError):
|
||||||
|
return None
|
||||||
|
if not chunk:
|
||||||
|
return None
|
||||||
|
buf.extend(chunk)
|
||||||
|
return bytes(buf)
|
||||||
|
|
||||||
|
|
||||||
|
class MqttProtocolError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def read_packet(sock: socket.socket) -> tuple[int, bytes]:
|
||||||
|
"""
|
||||||
|
Read one complete MQTT packet. Blocks until a full packet arrives.
|
||||||
|
Raises MqttProtocolError on parse failure. Returns None on clean EOF.
|
||||||
|
"""
|
||||||
|
b0 = _recv_exact(sock, 1)
|
||||||
|
if b0 is None:
|
||||||
|
return None
|
||||||
|
type_flags = b0[0]
|
||||||
|
|
||||||
|
# remaining length (variable-byte integer)
|
||||||
|
remaining = 0
|
||||||
|
shift = 0
|
||||||
|
while True:
|
||||||
|
b = _recv_exact(sock, 1)
|
||||||
|
if b is None:
|
||||||
|
return None
|
||||||
|
byte = b[0]
|
||||||
|
remaining |= (byte & 0x7F) << shift
|
||||||
|
if not (byte & 0x80):
|
||||||
|
break
|
||||||
|
shift += 7
|
||||||
|
if shift > 21:
|
||||||
|
raise MqttProtocolError("varint too long")
|
||||||
|
|
||||||
|
body = _recv_exact(sock, remaining) if remaining > 0 else b""
|
||||||
|
if body is None:
|
||||||
|
return None
|
||||||
|
return type_flags, body
|
||||||
|
|
||||||
|
|
||||||
|
# ── helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _s(v, default="?"):
|
||||||
|
if v:
|
||||||
|
return str(v)
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def fmt_ts(unix_val):
|
||||||
|
if unix_val is None:
|
||||||
|
return "?"
|
||||||
|
try:
|
||||||
|
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(int(unix_val)))
|
||||||
|
except (OverflowError, ValueError, OSError):
|
||||||
|
return str(unix_val)
|
||||||
|
|
||||||
|
|
||||||
|
# ── main ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def main():
|
||||||
|
global DEBUG
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="Listen to PSK Reporter MQTT spots (no-deps raw-TCP)")
|
||||||
|
parser.add_argument("--bands", nargs="+", default=BANDS,
|
||||||
|
help=f"Bands to subscribe (default: {' '.join(BANDS)})")
|
||||||
|
parser.add_argument("--debug", action="store_true", help="Hex dump every packet")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
DEBUG = args.debug
|
||||||
|
|
||||||
|
bands = args.bands
|
||||||
|
topics = [(f"pskr/filter/v2/{b}/+/+/+/+/+/{US_DXCC}/{US_DXCC}", 0) for b in bands]
|
||||||
|
|
||||||
|
suffix = os.urandom(4).hex()[:8]
|
||||||
|
hostname = socket.gethostname().split(".")[0].replace(".", "-")
|
||||||
|
client_id = f"microwaveprop-debug-{hostname}-{suffix}"
|
||||||
|
|
||||||
|
# ── resolve + connect ──
|
||||||
|
print(f"Resolving {BROKER_HOST} …", file=sys.stderr)
|
||||||
|
addrs = socket.getaddrinfo(BROKER_HOST, BROKER_PORT, socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
ip = addrs[0][4][0]
|
||||||
|
print(f"Connecting to {BROKER_HOST} ({ip}):{BROKER_PORT} …", file=sys.stderr)
|
||||||
|
sock = socket.create_connection((ip, BROKER_PORT), timeout=10)
|
||||||
|
sock.setblocking(True)
|
||||||
|
|
||||||
|
# ── CONNECT ──
|
||||||
|
connect_pkt = mqtt_connect(client_id)
|
||||||
|
if DEBUG:
|
||||||
|
print(f"\n>> CONNECT ({len(connect_pkt)} bytes): {connect_pkt.hex()}", file=sys.stderr)
|
||||||
|
print(f" client_id={client_id}", file=sys.stderr)
|
||||||
|
sock.sendall(connect_pkt)
|
||||||
|
|
||||||
|
result = read_packet(sock)
|
||||||
|
if result is None:
|
||||||
|
print("ERROR: no CONNACK — broker closed connection immediately", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
tf, body = result
|
||||||
|
if DEBUG:
|
||||||
|
print(f"<< type=0x{tf:02X} body={body.hex()}", file=sys.stderr)
|
||||||
|
|
||||||
|
if tf >> 4 != 2:
|
||||||
|
print(f"ERROR: expected CONNACK (0x20), got type 0x{tf:02X}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
return_code = body[1]
|
||||||
|
session_present = bool(body[0] & 1)
|
||||||
|
if return_code != 0:
|
||||||
|
codes = {1: "unacceptable protocol version", 2: "identifier rejected",
|
||||||
|
3: "server unavailable", 4: "bad user/password", 5: "not authorized"}
|
||||||
|
reason = codes.get(return_code, f"unknown ({return_code})")
|
||||||
|
print(f"ERROR: CONNACK refused: {reason}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
print(f"Connected! session_present={session_present}", file=sys.stderr)
|
||||||
|
|
||||||
|
# ── SUBSCRIBE ──
|
||||||
|
sub_pkt = mqtt_subscribe(1, topics)
|
||||||
|
if DEBUG:
|
||||||
|
print(f"\n>> SUBSCRIBE ({len(sub_pkt)} bytes): {sub_pkt[:80].hex()}...", file=sys.stderr)
|
||||||
|
sock.sendall(sub_pkt)
|
||||||
|
|
||||||
|
result = read_packet(sock)
|
||||||
|
if result is None:
|
||||||
|
print("ERROR: no SUBACK — broker closed connection", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
tf, body = result
|
||||||
|
if DEBUG:
|
||||||
|
print(f"<< type=0x{tf:02X} body={body.hex()}", file=sys.stderr)
|
||||||
|
|
||||||
|
if tf != 0x90:
|
||||||
|
print(f"ERROR: expected SUBACK (0x90), got type 0x{tf:02X}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
granted = body[2:]
|
||||||
|
failures = sum(1 for b in granted if b == 0x80)
|
||||||
|
if failures == len(granted):
|
||||||
|
print(f"ERROR: all {len(granted)} subscriptions rejected (0x80)", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
if failures:
|
||||||
|
print(f"WARNING: {failures}/{len(granted)} subscriptions rejected", file=sys.stderr)
|
||||||
|
|
||||||
|
print(f"Subscribed to {len(bands)} band(s): {', '.join(bands)}", file=sys.stderr)
|
||||||
|
print("Listening for spots…\n", file=sys.stderr)
|
||||||
|
|
||||||
|
# ── read loop ──
|
||||||
|
last_ping = time.monotonic()
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
# Wait for data or keepalive deadline, whichever comes first.
|
||||||
|
# select timeout = time until next PINGREQ, but never more than
|
||||||
|
# KEEPALIVE_SEC seconds so we don't hang forever if the broker
|
||||||
|
# goes silent.
|
||||||
|
now = time.monotonic()
|
||||||
|
ping_deadline = last_ping + KEEPALIVE_SEC - 5
|
||||||
|
wait = max(0.1, min(ping_deadline - now, KEEPALIVE_SEC))
|
||||||
|
|
||||||
|
readable, _, errored = select.select([sock], [], [sock], wait)
|
||||||
|
|
||||||
|
if errored:
|
||||||
|
print("Socket error (select).", file=sys.stderr)
|
||||||
|
break
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
|
||||||
|
# Time to send a PINGREQ?
|
||||||
|
if now - last_ping >= KEEPALIVE_SEC - 5:
|
||||||
|
try:
|
||||||
|
sock.sendall(mqtt_pingreq())
|
||||||
|
except (ConnectionError, OSError):
|
||||||
|
print("Connection lost (ping send failed).", file=sys.stderr)
|
||||||
|
break
|
||||||
|
last_ping = now
|
||||||
|
if DEBUG:
|
||||||
|
print(">> PINGREQ", file=sys.stderr)
|
||||||
|
|
||||||
|
if not readable:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Data available — read one full packet
|
||||||
|
try:
|
||||||
|
result = read_packet(sock)
|
||||||
|
except MqttProtocolError as e:
|
||||||
|
print(f"Protocol error: {e} — disconnecting", file=sys.stderr)
|
||||||
|
break
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
print("Connection closed by broker.", file=sys.stderr)
|
||||||
|
break
|
||||||
|
|
||||||
|
tf_pkt, body_pkt = result
|
||||||
|
|
||||||
|
if DEBUG:
|
||||||
|
ptype = tf_pkt >> 4
|
||||||
|
labels = {1: "CONNECT", 2: "CONNACK", 3: "PUBLISH", 4: "PUBACK",
|
||||||
|
8: "SUBSCRIBE", 9: "SUBACK", 12: "PINGREQ", 13: "PINGRESP", 14: "DISCONNECT"}
|
||||||
|
print(f"<< {labels.get(ptype, '?')} (0x{tf_pkt:02X}) len={len(body_pkt)}", file=sys.stderr)
|
||||||
|
|
||||||
|
# PUBLISH (QoS 0)
|
||||||
|
if (tf_pkt & 0xF0) == 0x30:
|
||||||
|
qos = (tf_pkt >> 1) & 0x03
|
||||||
|
if qos != 0:
|
||||||
|
if DEBUG:
|
||||||
|
print(f" skipping QoS {qos}", file=sys.stderr)
|
||||||
|
continue
|
||||||
|
|
||||||
|
pos = 0
|
||||||
|
topic_len = struct.unpack(">H", body_pkt[pos:pos+2])[0]
|
||||||
|
pos += 2
|
||||||
|
topic = body_pkt[pos:pos+topic_len].decode(errors="replace")
|
||||||
|
pos += topic_len
|
||||||
|
payload_bytes = body_pkt[pos:]
|
||||||
|
|
||||||
|
try:
|
||||||
|
spot = json.loads(payload_bytes)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
print(json.dumps({"error": "bad_json", "raw": payload_bytes.decode(errors="replace")[:200]}))
|
||||||
|
continue
|
||||||
|
|
||||||
|
ts = spot.get("t_tx") or spot.get("t")
|
||||||
|
out = {
|
||||||
|
"time": fmt_ts(ts),
|
||||||
|
"band": _s(spot.get("b")),
|
||||||
|
"freq_hz": spot.get("f"),
|
||||||
|
"mode": _s(spot.get("md")),
|
||||||
|
"snr_db": spot.get("rp"),
|
||||||
|
"tx_call": _s(spot.get("sc")),
|
||||||
|
"tx_grid": _s(spot.get("sl")),
|
||||||
|
"rx_call": _s(spot.get("rc")),
|
||||||
|
"rx_grid": _s(spot.get("rl")),
|
||||||
|
"topic": topic,
|
||||||
|
}
|
||||||
|
print(json.dumps(out), flush=True)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
# PINGRESP
|
||||||
|
elif tf_pkt == 0xD0:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# SUBACK (can arrive outside handshake)
|
||||||
|
elif tf_pkt == 0x90:
|
||||||
|
pass
|
||||||
|
|
||||||
|
else:
|
||||||
|
if DEBUG:
|
||||||
|
print(f" unhandled packet", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nDisconnecting…", file=sys.stderr)
|
||||||
Loading…
Add table
Reference in a new issue