Extract some common code into telnet_spot_provier as a base class for DX cluster and RBN

This commit is contained in:
Ian Renton
2026-09-22 18:35:05 +01:00
parent 9067f545af
commit f8343dd036
11 changed files with 190 additions and 230 deletions
+29 -112
View File
@@ -1,21 +1,17 @@
import logging import logging
import re import re
import socket
from datetime import datetime from datetime import datetime
from threading import Event, Lock, Thread
import pytz import pytz
import telnetlib3
from core.config import SERVER_OWNER_CALLSIGN from core.config import SERVER_OWNER_CALLSIGN
from data.spot import Spot from data.spot import Spot
from providers.spot.spot_provider import SpotProvider from providers.spot.telnet_spot_provider import TelnetSpotProvider
from core.utils import decode_telnet_bytes
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class DXCluster(SpotProvider): class DXCluster(TelnetSpotProvider):
"""Spot provider for a DX Cluster. Hostname, port, login_prompt, login_callsign and allow_rbn_spots are provided in config. """Spot provider for a DX Cluster. Hostname, port, login_prompt, login_callsign and allow_rbn_spots are provided in config.
See config-example.yml for examples.""" See config-example.yml for examples."""
@@ -32,112 +28,33 @@ class DXCluster(SpotProvider):
"""Constructor requires hostname and port""" """Constructor requires hostname and port"""
name = provider_config.get("name", "Cluster") name = provider_config.get("name", "Cluster")
super().__init__(name, provider_config) allow_rbn_spots = provider_config.get("allow_rbn_spots", False)
self._hostname = provider_config["host"] self._spot_line_pattern = self._LINE_PATTERN_ALLOW_RBN if allow_rbn_spots else self._LINE_PATTERN_EXCLUDE_RBN
self._port = provider_config["port"] super().__init__(
self._login_prompt = provider_config.get("login_prompt", "login:") name,
self._login_callsign = provider_config.get("login_callsign", SERVER_OWNER_CALLSIGN) provider_config,
self._allow_rbn_spots = provider_config.get("allow_rbn_spots", False) host=provider_config["host"],
self._spot_line_pattern = ( port=provider_config["port"],
self._LINE_PATTERN_ALLOW_RBN if self._allow_rbn_spots else self._LINE_PATTERN_EXCLUDE_RBN login_prompt=provider_config.get("login_prompt", "login:"),
login_response=provider_config.get("login_callsign", SERVER_OWNER_CALLSIGN),
) )
self._telnet = None
self._telnet_lock = Lock()
self._thread = None
self._stop_event = Event()
def start(self): def _parse_line(self, line):
self._thread = Thread(target=self._handle, name=f"DXClusterSpotProvider-{self.name}", daemon=True) match = self._spot_line_pattern.match(line)
self._thread.start() if not match:
return None
def stop(self): spot_time = datetime.strptime(match.group(5), "%H%MZ").replace(tzinfo=pytz.UTC)
self._stop_event.set() spot_datetime = datetime.combine(
with self._telnet_lock: datetime.now(pytz.UTC).date(),
if self._telnet: spot_time.time(),
try: tzinfo=pytz.UTC,
self._telnet.sock.shutdown(socket.SHUT_RDWR) )
except (AttributeError, OSError): return Spot(
pass source=self.name,
self._telnet.close() dx_call=match.group(3),
if self._thread: de_call=match.group(1),
self._thread.join(timeout=5) freq=float(match.group(2)) * 1000,
if self._thread.is_alive(): comment=match.group(4).strip(),
logger.warning(f"DX Cluster {self._hostname} worker thread did not exit on time and will be killed.") time=spot_datetime.timestamp(),
)
def _handle(self):
while not self._stop_event.is_set():
connected = False
while not connected and not self._stop_event.is_set():
try:
self.status = "Connecting"
logger.info(f"DX Cluster {self._hostname} connecting...")
new_telnet = telnetlib3.Telnet(self._hostname, self._port)
with self._telnet_lock:
self._telnet = new_telnet
if self._stop_event.is_set():
# stop() was called while we were connecting, close the connection rather than trying to
# read when we know it won't work
new_telnet.close()
break
self._telnet.read_until(self._login_prompt.encode("latin-1"))
self._telnet.write(f"{self._login_callsign}\n".encode("latin-1"))
connected = True
logger.info(f"DX Cluster {self._hostname} connected.")
except ConnectionRefusedError:
self.status = "Error"
logger.warning(f"Connection refused to DX cluster {self._hostname}")
self._stop_event.wait(timeout=300)
except Exception:
self.status = "Error"
logger.exception(f"Exception while connecting to DX Cluster Provider ({self._hostname}).")
self._stop_event.wait(timeout=5)
self.status = "Waiting for Data"
while connected and not self._stop_event.is_set():
try:
# Check new telnet info against regular expression
telnet_output = self._telnet.read_until("\n".encode("latin-1"))
match = self._spot_line_pattern.match(decode_telnet_bytes(telnet_output))
if match:
spot_time = datetime.strptime(match.group(5), "%H%MZ").replace(tzinfo=pytz.UTC)
spot_datetime = datetime.combine(
datetime.now(pytz.UTC).date(),
spot_time.time(),
tzinfo=pytz.UTC,
)
spot = Spot(
source=self.name,
dx_call=match.group(3),
de_call=match.group(1),
freq=float(match.group(2)) * 1000,
comment=match.group(4).strip(),
time=spot_datetime.timestamp(),
)
# Add to our list
self._submit(spot)
self.status = "OK"
self.last_update_time = datetime.now(pytz.UTC)
logger.debug(f"Data received from DX Cluster {self._hostname}.")
except EOFError:
connected = False
if not self._stop_event.is_set():
self.status = "Restarting"
logger.warning(f"Disconnected from DX Cluster {self._hostname}. Reconnecting...")
self._stop_event.wait(timeout=5)
else:
logger.info(f"DX Cluster {self._hostname} shutting down...")
self.status = "Shutting down"
except Exception:
connected = False
if not self._stop_event.is_set():
self.status = "Error"
logger.exception(f"Exception in DX Cluster Provider ({self._hostname})")
self._stop_event.wait(timeout=5)
else:
logger.info(f"DX Cluster {self._hostname} shutting down...")
self.status = "Shutting down"
self.status = "Disconnected"
+29 -103
View File
@@ -1,23 +1,19 @@
import logging import logging
import re import re
import socket
from datetime import datetime from datetime import datetime
from threading import Event, Lock, Thread
import pytz import pytz
import telnetlib3
from core.config import SERVER_OWNER_CALLSIGN from core.config import SERVER_OWNER_CALLSIGN
from data.spot import Spot from data.spot import Spot
from providers.spot.spot_provider import SpotProvider from providers.spot.telnet_spot_provider import TelnetSpotProvider
from core.utils import decode_telnet_bytes
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class RBN(SpotProvider): class RBN(TelnetSpotProvider):
"""Spot provider for the Reverse Beacon Network. Connects to a single port, if you want both CW/RTTY (port 7000) and FT8 """Spot provider for the Reverse Beacon Network. Connects to a single port, if you want both CW/RTTY (port 7000) and FT8
(port 7001) you need to instantiate two copies of this. The port is provided as an argument to the constructor.""" (port 7001) you need to instantiate two copies of this. The port is provided in config."""
_LINE_PATTERN = re.compile( _LINE_PATTERN = re.compile(
r"^DX de ([a-z0-9/]+)-.*:\s+([0-9.]+)\s+([a-z0-9/]+)\s+(.*)\s+(\d{4}Z)", r"^DX de ([a-z0-9/]+)-.*:\s+([0-9.]+)\s+([a-z0-9/]+)\s+(.*)\s+(\d{4}Z)",
@@ -28,101 +24,31 @@ class RBN(SpotProvider):
"""Constructor requires port number.""" """Constructor requires port number."""
name = provider_config.get("name", "RBN") name = provider_config.get("name", "RBN")
super().__init__(name, provider_config) super().__init__(
self._port = provider_config["port"] name,
self._telnet = None provider_config,
self._telnet_lock = Lock() host="telnet.reversebeacon.net",
self._thread = None port=provider_config["port"],
self._stop_event = Event() login_prompt="Please enter your call: ",
login_response=SERVER_OWNER_CALLSIGN,
)
def start(self): def _parse_line(self, line):
self._thread = Thread(target=self._handle, name=f"RBNSpotProvider-{self.name}", daemon=True) match = self._LINE_PATTERN.match(line)
self._thread.start() if not match:
return None
def stop(self): spot_time = datetime.strptime(match.group(5), "%H%MZ").replace(tzinfo=pytz.UTC)
self._stop_event.set() spot_datetime = datetime.combine(
with self._telnet_lock: datetime.now(pytz.UTC).date(),
if self._telnet: spot_time.time(),
try: tzinfo=pytz.UTC,
self._telnet.sock.shutdown(socket.SHUT_RDWR) )
except (AttributeError, OSError): return Spot(
pass source=self.name,
self._telnet.close() dx_call=match.group(3),
if self._thread: de_call=match.group(1),
self._thread.join(timeout=5) freq=float(match.group(2)) * 1000,
if self._thread.is_alive(): comment=match.group(4).strip(),
logger.warning(f"RBN (port {self._port!s}) worker thread did not exit on time and will be killed.") time=spot_datetime.timestamp(),
)
def _handle(self):
while not self._stop_event.is_set():
connected = False
while not connected and not self._stop_event.is_set():
try:
self.status = "Connecting"
logger.info(f"RBN port {self._port!s} connecting...")
new_telnet = telnetlib3.Telnet("telnet.reversebeacon.net", self._port)
with self._telnet_lock:
self._telnet = new_telnet
if self._stop_event.is_set():
# stop() was called while we were connecting, close the connection rather than trying to
# read when we know it won't work
new_telnet.close()
break
self._telnet.read_until("Please enter your call: ".encode("latin-1"))
self._telnet.write(f"{SERVER_OWNER_CALLSIGN}\n".encode("latin-1"))
connected = True
logger.info(f"RBN port {self._port!s} connected.")
except Exception:
self.status = "Error"
logger.exception(f"Exception while connecting to RBN (port {self._port!s}).")
self._stop_event.wait(timeout=5)
self.status = "Waiting for Data"
while connected and not self._stop_event.is_set():
try:
# Check new telnet info against regular expression
telnet_output = self._telnet.read_until("\n".encode("latin-1"))
match = self._LINE_PATTERN.match(decode_telnet_bytes(telnet_output))
if match:
spot_time = datetime.strptime(match.group(5), "%H%MZ").replace(tzinfo=pytz.UTC)
spot_datetime = datetime.combine(
datetime.now(pytz.UTC).date(),
spot_time.time(),
tzinfo=pytz.UTC,
)
spot = Spot(
source=self.name,
dx_call=match.group(3),
de_call=match.group(1),
freq=float(match.group(2)) * 1000,
comment=match.group(4).strip(),
time=spot_datetime.timestamp(),
)
# Add to our list
self._submit(spot)
self.status = "OK"
self.last_update_time = datetime.now(pytz.UTC)
logger.debug(f"Data received from RBN on port {self._port!s}.")
except EOFError:
connected = False
if not self._stop_event.is_set():
self.status = "Restarting"
logger.warning(f"Disconnected from RBN provider (port {self._port!s}). Reconnecting...")
self._stop_event.wait(timeout=5)
else:
logger.info(f"RBN provider (port {self._port!s}) shutting down...")
self.status = "Shutting down"
except Exception:
connected = False
if not self._stop_event.is_set():
self.status = "Error"
logger.exception(f"Exception in RBN provider (port {self._port!s})")
self._stop_event.wait(timeout=5)
else:
logger.info(f"RBN provider (port {self._port!s}) shutting down...")
self.status = "Shutting down"
self.status = "Disconnected"
+117
View File
@@ -0,0 +1,117 @@
import logging
import socket
from datetime import datetime
from threading import Event, Lock, Thread
import pytz
import telnetlib3
from core.utils import decode_telnet_bytes
from providers.spot.spot_provider import SpotProvider
logger = logging.getLogger(__name__)
class TelnetSpotProvider(SpotProvider):
"""Base class for spot providers that connect to a telnet server to receive spots."""
def __init__(self, name, provider_config, host, port, login_prompt, login_response):
"""Constructor. host/port are the telnet server to connect to. login_prompt is the text to wait for before
logging in, and login_response is the callsign to send in response."""
super().__init__(name, provider_config)
self._host = host
self._port = port
self._login_prompt = login_prompt
self._login_response = login_response
self._telnet = None
self._telnet_lock = Lock()
self._thread = None
self._stop_event = Event()
def _parse_line(self, line):
"""Parse a line of telnet output and return a Spot, or None if the line did not contain a spot. Subclasses
must implement this method."""
raise NotImplementedError("Subclasses must implement this method")
def start(self):
self._thread = Thread(target=self._handle, name=f"{self.name}SpotProvider", daemon=True)
self._thread.start()
def stop(self):
self._stop_event.set()
with self._telnet_lock:
if self._telnet:
try:
self._telnet.sock.shutdown(socket.SHUT_RDWR)
except (AttributeError, OSError):
pass
self._telnet.close()
if self._thread:
self._thread.join(timeout=5)
if self._thread.is_alive():
logger.warning(f"{self.name} worker thread did not exit on time and will be killed.")
def _handle(self):
while not self._stop_event.is_set():
connected = False
while not connected and not self._stop_event.is_set():
try:
self.status = "Connecting"
logger.info(f"{self.name} connecting to {self._host}:{self._port!s}...")
new_telnet = telnetlib3.Telnet(self._host, self._port)
with self._telnet_lock:
self._telnet = new_telnet
if self._stop_event.is_set():
# stop() was called while we were connecting, close the connection rather than trying to
# read when we know it won't work
new_telnet.close()
break
self._telnet.read_until(self._login_prompt.encode("latin-1"))
self._telnet.write(f"{self._login_response}\n".encode("latin-1"))
connected = True
logger.info(f"{self.name} connected.")
except ConnectionRefusedError:
self.status = "Error"
logger.warning(f"Connection refused to {self.name} ({self._host}:{self._port!s}).")
self._stop_event.wait(timeout=300)
except Exception:
self.status = "Error"
logger.exception(f"Exception while connecting to {self.name} ({self._host}:{self._port!s}).")
self._stop_event.wait(timeout=5)
self.status = "Waiting for Data"
while connected and not self._stop_event.is_set():
try:
# Check new telnet info against regular expression
telnet_output = self._telnet.read_until("\n".encode("latin-1"))
spot = self._parse_line(decode_telnet_bytes(telnet_output))
if spot:
# Add to our list
self._submit(spot)
self.status = "OK"
self.last_update_time = datetime.now(pytz.UTC)
logger.debug(f"Data received from {self.name}.")
except EOFError:
connected = False
if not self._stop_event.is_set():
self.status = "Restarting"
logger.warning(f"Disconnected from {self.name}. Reconnecting...")
self._stop_event.wait(timeout=5)
else:
logger.info(f"{self.name} shutting down...")
self.status = "Shutting down"
except Exception:
connected = False
if not self._stop_event.is_set():
self.status = "Error"
logger.exception(f"Exception in {self.name}")
self._stop_event.wait(timeout=5)
else:
logger.info(f"{self.name} shutting down...")
self.status = "Shutting down"
self.status = "Disconnected"
+1 -1
View File
@@ -77,7 +77,7 @@
</div> </div>
<script src="/static/js/add-spot.js?v=1789933862"></script> <script src="/static/js/add-spot.js?v=1790098506"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-add-spot").addClass("active"); $("#nav-link-add-spot").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+1 -1
View File
@@ -85,7 +85,7 @@
</div> </div>
<script src="/static/js/alerts.js?v=1789933862"></script> <script src="/static/js/alerts.js?v=1790098506"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-alerts").addClass("active"); $("#nav-link-alerts").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+2 -2
View File
@@ -76,8 +76,8 @@
</div> </div>
<script src="/static/js/spotsbandsandmap.js?v=1789933862"></script> <script src="/static/js/spotsbandsandmap.js?v=1790098506"></script>
<script src="/static/js/bands.js?v=1789933862"></script> <script src="/static/js/bands.js?v=1790098506"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-bands").addClass("active"); $("#nav-link-bands").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+5 -5
View File
@@ -1,6 +1,6 @@
{% extends "skeleton.html" %} {% extends "skeleton.html" %}
{% block head_extra %} {% block head_extra %}
<link rel="stylesheet" href="/static/css/style.css?v=1789933862" type="text/css"> <link rel="stylesheet" href="/static/css/style.css?v=1790098506" type="text/css">
<link href="/static/vendor/css/bootstrap-5.3.8.min.css" rel="stylesheet"> <link href="/static/vendor/css/bootstrap-5.3.8.min.css" rel="stylesheet">
<link href="/static/vendor/css/fontawesome-6.7.2.min.css" rel="stylesheet"> <link href="/static/vendor/css/fontawesome-6.7.2.min.css" rel="stylesheet">
<link href="/static/vendor/css/solid-6.7.2.min.css" rel="stylesheet"> <link href="/static/vendor/css/solid-6.7.2.min.css" rel="stylesheet">
@@ -16,10 +16,10 @@
window.fetchEventSource = fetchEventSource; window.fetchEventSource = fetchEventSource;
</script> </script>
<script src="/static/js/utils.js?v=1789933862"></script> <script src="/static/js/utils.js?v=1790098506"></script>
<script src="/static/js/ui-ham.js?v=1789933862"></script> <script src="/static/js/ui-ham.js?v=1790098506"></script>
<script src="/static/js/geo.js?v=1789933862"></script> <script src="/static/js/geo.js?v=1790098506"></script>
<script src="/static/js/common.js?v=1789933862"></script> <script src="/static/js/common.js?v=1790098506"></script>
{% end %} {% end %}
{% block body %} {% block body %}
<div class="container"> <div class="container">
+1 -1
View File
@@ -284,7 +284,7 @@
</div> </div>
<script src="/static/vendor/js/chart-4.4.9.umd.min.js"></script> <script src="/static/vendor/js/chart-4.4.9.umd.min.js"></script>
<script src="/static/js/conditions.js?v=1789933862"></script> <script src="/static/js/conditions.js?v=1790098506"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-conditions").addClass("active"); $("#nav-link-conditions").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+2 -2
View File
@@ -113,8 +113,8 @@
const CARTODB_API_KEY = "{{ web_ui_options.get('cartodb_api_key', '') }}"; const CARTODB_API_KEY = "{{ web_ui_options.get('cartodb_api_key', '') }}";
</script> </script>
<script src="/static/js/spotsbandsandmap.js?v=1789933861"></script> <script src="/static/js/spotsbandsandmap.js?v=1790098505"></script>
<script src="/static/js/map.js?v=1789933861"></script> <script src="/static/js/map.js?v=1790098505"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-map").addClass("active"); $("#nav-link-map").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+2 -2
View File
@@ -125,8 +125,8 @@
</div> </div>
<script src="/static/js/spotsbandsandmap.js?v=1789933861"></script> <script src="/static/js/spotsbandsandmap.js?v=1790098505"></script>
<script src="/static/js/spots.js?v=1789933861"></script> <script src="/static/js/spots.js?v=1790098505"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-spots").addClass("active"); $("#nav-link-spots").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+1 -1
View File
@@ -96,7 +96,7 @@
</div> </div>
</div> </div>
<script src="/static/js/status.js?v=1789933862"></script> <script src="/static/js/status.js?v=1790098506"></script>
<script> <script>
$(document).ready(function () { $(document).ready(function () {
$("#nav-link-status").addClass("active"); $("#nav-link-status").addClass("active");