Compare commits

...
7 Commits
41 changed files with 367 additions and 179 deletions
+4
View File
@@ -34,6 +34,10 @@ class CleanupTimer:
"""Stop any threads and prepare for application shutdown"""
self._stop_event.set()
if self._thread:
self._thread.join(timeout=15)
if self._thread.is_alive():
logger.warning("Cleanup worker thread did not exit on time and will be killed.")
def _run(self):
while not self._stop_event.wait(timeout=self._cleanup_interval):
+11 -1
View File
@@ -4,7 +4,7 @@ from data.band import Band
from data.sig import SIG
# General software
SOFTWARE_VERSION = "2.1-pre"
SOFTWARE_VERSION = "2.1"
# HTTP headers used for spot providers that use HTTP
HTTP_HEADERS = {"User-Agent": f"Spothole v{SOFTWARE_VERSION} (operated by {SERVER_OWNER_CALLSIGN})"}
@@ -263,6 +263,16 @@ SIGS = [
region_flag="🇮🇪",
refs_globally_unique=False,
),
SIG(
name="DMF",
comment_names=["DMF"],
description="Diplôme des Moulins de France",
sig_type=SIGType.REGIONAL,
ref_type=SIGRefType.MILL,
icon="fa-fan",
region_flag="🇫🇷",
refs_globally_unique=False,
),
SIG(
name="DME",
comment_names=["DME"],
+52 -33
View File
@@ -1,5 +1,6 @@
import logging
import threading
import time
from core.config import config, create_provider_from_config
@@ -16,6 +17,7 @@ class DataProviders:
self.static_data_providers = []
self.sig_ref_data_providers = []
self.callsign_data_providers = []
self._startup_timers = []
def setup(self):
for entry in config["spot_providers"]:
@@ -43,41 +45,58 @@ class DataProviders:
def start(self):
# Start data providers before spot/alert providers so the lookup data is there already for incoming spots.
# Each category is fired off after a small delay to give the rest of Spothole chance to start up.
threading.Timer(5.0, lambda: self.start_providers(self.static_data_providers, "static data")).start()
threading.Timer(
10.0,
lambda: self.start_providers(self.callsign_data_providers, "callsign data"),
).start()
threading.Timer(15.0, lambda: self.start_providers(self.spot_providers, "spot")).start()
threading.Timer(20.0, lambda: self.start_providers(self.alert_providers, "alert")).start()
threading.Timer(
25.0,
lambda: self.start_providers(self.solar_condition_providers, "solar condition"),
).start()
threading.Timer(
30.0,
lambda: self.start_providers(self.sig_ref_data_providers, "SIG ref data"),
).start()
self._startup_timers = [
threading.Timer(5.0, lambda: self.start_providers(self.static_data_providers, "static data")),
threading.Timer(10.0, lambda: self.start_providers(self.callsign_data_providers, "callsign data")),
threading.Timer(15.0, lambda: self.start_providers(self.spot_providers, "spot")),
threading.Timer(20.0, lambda: self.start_providers(self.alert_providers, "alert")),
threading.Timer(
25.0,
lambda: self.start_providers(self.solar_condition_providers, "solar condition"),
),
threading.Timer(30.0, lambda: self.start_providers(self.sig_ref_data_providers, "SIG ref data")),
]
for t in self._startup_timers:
t.daemon = True
t.start()
def stop(self):
for sp in self.spot_providers:
if sp.enabled:
sp.stop()
for ap in self.alert_providers:
if ap.enabled:
ap.stop()
for scp in self.solar_condition_providers:
if scp.enabled:
scp.stop()
for srdp in self.sig_ref_data_providers:
if srdp.enabled:
srdp.stop()
for sdp in self.static_data_providers:
if sdp.enabled:
sdp.stop()
for cdp in self.callsign_data_providers:
if cdp.enabled:
cdp.stop()
# Cancel any startup timers that haven't fired yet
for t in self._startup_timers:
t.cancel()
# Stop all providers
all_providers = [
p
for p in (
self.spot_providers
+ self.alert_providers
+ self.solar_condition_providers
+ self.sig_ref_data_providers
+ self.static_data_providers
+ self.callsign_data_providers
)
if p.enabled
]
if not all_providers:
return
def stop_provider(p):
try:
p.stop()
except Exception:
logger.exception("Exception stopping provider")
threads = [threading.Thread(target=stop_provider, args=(p,), daemon=True) for p in all_providers]
for t in threads:
t.start()
deadline = time.monotonic() + 40
for t in threads:
t.join(timeout=max(0.0, deadline - time.monotonic()))
still_running = [t for t in threads if t.is_alive()]
if still_running:
logger.warning("Some threads did not stop in time!")
# Global object
+1
View File
@@ -106,6 +106,7 @@ class DataStore:
and testing the callsign every time is expensive. So instead we build a separate in-memory lookup of compiled
regex against DXCC entity code, as a list of tuples we can iterate through."""
self.dxcc_lookup_by_call_regex = []
for entry in [DATA_STORE.dxcc_data[key] for key in DATA_STORE.dxcc_data]:
self.dxcc_lookup_by_call_regex.append((re.compile(entry["prefixRegex"]), entry["entityCode"]))
+12 -4
View File
@@ -22,6 +22,8 @@ class LiveDataCache:
self._listeners_lock = threading.Lock()
self._snapshot_dir = snapshot_dir
self._disk_cache = diskcache.Cache(str(snapshot_dir))
self._stop_event = threading.Event()
self._snapshot_thread = None
self._load_snapshot()
self._start_periodic_snapshot(snapshot_interval_sec)
@@ -89,13 +91,19 @@ class LiveDataCache:
def _start_periodic_snapshot(self, interval):
def loop():
while True:
time.sleep(interval)
while not self._stop_event.wait(timeout=interval):
self.save_snapshot()
t = threading.Thread(target=loop, name=f"LiveDataCache-Snapshot-{self._snapshot_dir}")
t.start()
self._snapshot_thread = threading.Thread(
target=loop, name=f"LiveDataCache-Snapshot-{self._snapshot_dir}", daemon=True
)
self._snapshot_thread.start()
def close(self):
self._stop_event.set()
if self._snapshot_thread:
self._snapshot_thread.join(timeout=15)
if self._snapshot_thread.is_alive():
logger.warning(f"LiveDataCache snapshot thread for {self._snapshot_dir} did not exit on time.")
self.save_snapshot()
self._disk_cache.close()
+17 -10
View File
@@ -24,7 +24,7 @@ def get_sig_ref_info(sig_name, ref_id):
# Sometimes we allow spaces instead of dashes in references due to common usage that way, but official reference
# lists never do, so convert them here.
ref_id.replace(" ", "-")
ref_id = ref_id.replace(" ", "-")
# Prepare the object to be returned
sig_ref = SIGRef(sig=sig_name, id=ref_id)
@@ -114,16 +114,23 @@ def get_sig_ref_info(sig_name, ref_id):
# OK, this is something we have to look up. Now check to see if our data store contains reference data and if
# so, copy the data into the sig_ref object
key = f"{sig_name}:{ref_id}"
lookup_data = DATA_STORE.sigrefs.get(key) if key in DATA_STORE.sigrefs else None
if lookup_data:
for key, value in lookup_data.__dict__.items():
if value is not None and sig_ref.__dict__.get(key) is None:
sig_ref.__dict__[key] = value
try:
lookup_data = DATA_STORE.sigrefs.get(key) if key in DATA_STORE.sigrefs else None
if lookup_data:
for attr, value in lookup_data.__dict__.items():
if value is not None and sig_ref.__dict__.get(attr) is None:
sig_ref.__dict__[attr] = value
else:
# Maybe a super new reference we don't know about yet, but more likely a typo or a test reference,
# just silently ignore it.
logger.debug(f"{sig_name} database did not contain data for ref {ref_id}")
else:
# Maybe a super new reference we don't know about yet, but more likely a typo or a test reference,
# just silently ignore it.
logger.debug(f"{sig_name} database did not contain data for ref {ref_id}")
except (ValueError, KeyError):
# Catch exceptions due to e.g. old versions of objects in the cache that are no longer compatible,
# and remove them from the cache.
del DATA_STORE.sigrefs[key]
return None
except Exception:
logger.exception(f"Exception when looking up sig_ref info for {sig_name} ref {ref_id}")
+8 -1
View File
@@ -1,3 +1,4 @@
import logging
import os
from datetime import datetime
from threading import Event, Thread
@@ -14,6 +15,8 @@ from core.prometheus_metrics_handler import alerts_gauge, memory_use_gauge, spot
from telnetserver.telnetserver import TELNET_SERVER
from webserver.webserver import WEB_SERVER
logger = logging.getLogger(__name__)
class StatusReporter:
"""Provides a timed update of the application's status data."""
@@ -33,13 +36,17 @@ class StatusReporter:
def start(self):
"""Start the reporter thread"""
self._thread = Thread(target=self._run, name="StatusReporter")
self._thread = Thread(target=self._run, name="StatusReporter", daemon=True)
self._thread.start()
def stop(self):
"""Stop any threads and prepare for application shutdown"""
self._stop_event.set()
if self._thread:
self._thread.join(timeout=15)
if self._thread.is_alive():
logger.warning("Status reporter worker thread did not exit on time and will be killed.")
def _run(self):
"""Thread entry point: report immediately on startup, then on each interval until stopped"""
+1 -2
View File
@@ -14,14 +14,13 @@ class URLDataCache(CachedSession):
used across multiple threads, though note that URL lookups will block each other this way, so it is still better to
create one of these objects per thread if possible."""
_lock = threading.Lock()
def __init__(self, name):
super().__init__(
f"{CACHE_DIR}urls/{name}",
expire_after=timedelta(days=1),
allowable_codes=(200, 400, 401, 403, 404),
)
self._lock = threading.Lock()
def get(self, *args, **kwargs):
with self._lock:
+1 -1
View File
@@ -158,7 +158,7 @@ class Alert:
self.icon = "fa-globe-africa"
elif self.alert_type == AlertType.CONTEST:
self.icon = "fa-trophy"
elif self.alert_type == AlertType.CONTEST:
elif self.alert_type == AlertType.SATELLITE:
self.icon = "fa-satellite"
elif self.sig_refs and self.sig_refs[0].icon:
self.icon = self.sig_refs[0].icon
+14 -2
View File
@@ -12,6 +12,7 @@ from pyhamtools.locator import latlong_to_locator, locator_to_latlong
from core.call_lookup_helper import get_call_info
from core.config import MAX_SPOT_AGE
from core.constants import PROPAGATION_MODES, SIGS
from core.data_store import DATA_STORE
from core.enums import Continent, LocationSourceForSpot, Mode, ModeSource, ModeType
from core.geo_utils import lat_lon_to_cq_zone, lat_lon_to_itu_zone
from core.sig_lookup_helper import populate_missing_sig_ref_info
@@ -363,11 +364,13 @@ class Spot:
if self.propagation_mode == "Satellite":
if not self.sig:
self.sig = "AMSAT"
self.sig_refs.append(SIGRef(sig="AMSAT"))
if not any(sig_ref.sig == "AMSAT" for sig_ref in self.sig_refs):
self.sig_refs.append(SIGRef(sig="AMSAT"))
if self.propagation_mode == "Earth-Moon-Earth":
if not self.sig:
self.sig = "EME"
self.sig_refs.append(SIGRef(sig="EME"))
if not any(sig_ref.sig == "EME" for sig_ref in self.sig_refs):
self.sig_refs.append(SIGRef(sig="EME"))
# Parse "de_grid -> dx_grid" structures from the comment
if self.comment:
@@ -445,6 +448,15 @@ class Spot:
elif self.dx_call:
self.dx_itu_zone = dx_call_info.itu_zone
# DXCC lookup from callsign if nothing else has provided it
if self.dx_call and not self.dx_dxcc_id:
for regex, entity_code in DATA_STORE.dxcc_lookup_by_call_regex:
if regex.pattern and regex.match(self.dx_call):
self.dx_dxcc_id = entity_code
break
if self.dx_dxcc_id and not self.dx_flag:
self.dx_flag = get_flag_for_dxcc(self.dx_dxcc_id)
# DX Location is "good" if it is from a spot, or from QRZ if the callsign doesn't contain a slash, so the operator
# is likely at home.
self.dx_location_good = bool(
+5 -1
View File
@@ -27,11 +27,15 @@ class HTTPAlertProvider(AlertProvider):
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
# subsequent polls, so start() returns immediately and the application can continue starting.
logger.info(f"Set up query of {self.name} alert API every {self._poll_interval!s} seconds.")
self._thread = Thread(target=self._run, name=f"HTTPAlertProvider-{self.name}")
self._thread = Thread(target=self._run, name=f"HTTPAlertProvider-{self.name}", daemon=True)
self._thread.start()
def stop(self):
self._stop_event.set()
if self._thread:
self._thread.join(timeout=35)
if self._thread.is_alive():
logger.warning(f"{self.name} alert worker thread did not exit on time and will be killed.")
def _run(self):
while True:
@@ -42,7 +42,13 @@ class CallsignDataProvider:
if self.enabled:
if callsign in self._storage:
return self._storage[callsign]
try:
return self._storage[callsign]
except (ValueError, KeyError):
# Catch exceptions due to e.g. old versions of objects in the cache that are no longer compatible,
# and remove them from the cache.
del self._storage[callsign]
return None
else:
c = self._perform_new_lookup(callsign, lookup_credentials)
if c:
@@ -32,11 +32,15 @@ class FileDownloadCallsignDataProvider(CallsignDataProvider):
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
# subsequent polls, so start() returns immediately and the application can continue starting.
logger.info(f"Set up query of {self.name} callsign reference data every {self._poll_interval!s} days.")
self._thread = Thread(target=self._run, name=f"FileDownloadCallsignDataProvider-{self.name}")
self._thread = Thread(target=self._run, name=f"FileDownloadCallsignDataProvider-{self.name}", daemon=True)
self._thread.start()
def stop(self):
self._stop_event.set()
if self._thread:
self._thread.join(timeout=35)
if self._thread.is_alive():
logger.warning(f"{self.name} callsign data worker thread did not exit on time and will be killed.")
def _run(self):
while True:
+1 -1
View File
@@ -53,7 +53,7 @@ class DME(LocalFileSIGRefDataProvider):
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest
# of the data in this case
if self._stop:
if self._stop_event.is_set():
break
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
@@ -1,6 +1,6 @@
import logging
from datetime import datetime
from threading import Event, Thread
from threading import Thread
import pytz
from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout
@@ -21,19 +21,21 @@ class FileDownloadSIGRefDataProvider(SIGRefDataProvider):
self._url = url
self._poll_interval = poll_interval
self._thread = None
self._stop_event = Event()
self._url_data_cache = URLDataCache(f"sigrefdata_{sig_name}")
def start(self):
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
# subsequent polls, so start() returns immediately and the application can continue starting.
logger.info(f"Set up query of {self.sig_name} SIG ref data every {self._poll_interval!s} days.")
self._thread = Thread(target=self._run, name=f"FileDownloadSIGRefDataProvider-{self.sig_name}")
self._thread = Thread(target=self._run, name=f"FileDownloadSIGRefDataProvider-{self.sig_name}", daemon=True)
self._thread.start()
def stop(self):
super().stop()
self._stop_event.set()
if self._thread:
self._thread.join(timeout=35)
if self._thread.is_alive():
logger.warning(f"{self.sig_name} SIG ref data worker thread did not exit on time and will be killed.")
def _run(self):
while True:
@@ -1,5 +1,6 @@
import logging
from datetime import datetime
from threading import Event
import pytz
@@ -19,7 +20,7 @@ class SIGRefDataProvider:
self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC)
self.status = "Not Started" if self.enabled else "Disabled"
self.reference_count = 0
self._stop = False
self._stop_event = Event()
def start(self):
"""Start the provider. This should return immediately after spawning threads to access the remote resources"""
@@ -30,7 +31,7 @@ class SIGRefDataProvider:
"""Stop any threads and prepare for application shutdown. Subclasses should implement this method and call
super()."""
self._stop = True
self._stop_event.set()
def _add_data(self, new_data):
"""Add all the provided reference data objects to the data store."""
@@ -43,7 +44,7 @@ class SIGRefDataProvider:
# For the big data sources, loading will take a few minutes. If we want to shut down the software neatly
# within the first few minutes of startup, we need a way to abort this expensive process of filling up the
# disk cache.
if self._stop:
if self._stop_event.is_set():
break
self.reference_count = len(new_data)
+1 -1
View File
@@ -35,7 +35,7 @@ class Toilets(LocalFileSIGRefDataProvider):
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest
# of the data in this case
if self._stop:
if self._stop_event.is_set():
break
return new_data
+5 -1
View File
@@ -67,11 +67,15 @@ class GIROIonosonde(SolarConditionsProvider):
def start(self):
logger.info(f"Set up query of GIRO ionosonde data API every {POLL_INTERVAL} seconds.")
self._thread = Thread(target=self._run, name="GIROIonosondeDataProvider")
self._thread = Thread(target=self._run, name="GIROIonosondeDataProvider", daemon=True)
self._thread.start()
def stop(self):
self._stop_event.set()
if self._thread:
self._thread.join(timeout=35)
if self._thread.is_alive():
logger.warning("GIRO ionosonde worker thread did not exit on time and will be killed.")
def _run(self):
# Real interval at which we poll is the "once per hour" divided by the number of stations, so each one gets
@@ -25,11 +25,15 @@ class HTTPSolarConditionsProvider(SolarConditionsProvider):
def start(self):
logger.info(f"Set up query of {self.name} solar conditions API every {self._poll_interval!s} seconds.")
self._thread = Thread(target=self._run, name=f"HTTPSolarConditionsProvider-{self.name}")
self._thread = Thread(target=self._run, name=f"HTTPSolarConditionsProvider-{self.name}", daemon=True)
self._thread.start()
def stop(self):
self._stop_event.set()
if self._thread:
self._thread.join(timeout=35)
if self._thread.is_alive():
logger.warning(f"{self.name} solar conditions worker thread did not exit on time and will be killed.")
def _run(self):
while True:
+5 -1
View File
@@ -32,11 +32,15 @@ class KC2GProp(SolarConditionsProvider):
def start(self):
logger.info(f"Set up query of KC2G ionosonde data API every {POLL_INTERVAL} seconds.")
self._thread = Thread(target=self._run, name="KC2GPropProvider")
self._thread = Thread(target=self._run, name="KC2GPropProvider", daemon=True)
self._thread.start()
def stop(self):
self._stop_event.set()
if self._thread:
self._thread.join(timeout=35)
if self._thread.is_alive():
logger.warning("KC2G ionosonde worker thread did not exit on time and will be killed.")
def _run(self):
while True:
+55 -35
View File
@@ -1,6 +1,6 @@
import logging
from datetime import datetime
from threading import Thread
from threading import Event, Thread
import aprslib
import pytz
@@ -17,49 +17,69 @@ class APRSIS(SpotProvider):
def __init__(self, provider_config):
super().__init__("APRS-IS", provider_config)
self._thread = Thread(target=self._connect, name="APRSISSpotProvider")
self._thread.daemon = True
self._thread = None
self._aprsis = None
self._stop_event = Event()
def start(self):
self._thread = Thread(target=self._run, name="APRSISSpotProvider", daemon=True)
self._thread.start()
def _connect(self):
self._aprsis = aprslib.IS(SERVER_OWNER_CALLSIGN)
self.status = "Connecting"
logger.info("APRS-IS connecting...")
self._aprsis.connect()
self._aprsis.consumer(self._handle)
logger.info("APRS-IS connected.")
def _run(self):
while not self._stop_event.is_set():
try:
self._aprsis = aprslib.IS(SERVER_OWNER_CALLSIGN)
self.status = "Connecting"
logger.info("APRS-IS connecting...")
self._aprsis.connect()
logger.info("APRS-IS connected.")
self._aprsis.consumer(self._handle, immortal=True)
except Exception:
if not self._stop_event.is_set():
self.status = "Error"
logger.exception("Exception in APRS-IS provider")
if not self._stop_event.is_set():
self._stop_event.wait(timeout=5)
def stop(self):
self.status = "Shutting down"
self._aprsis.close()
self._thread.join()
self._stop_event.set()
if self._aprsis:
self._aprsis.close()
if self._thread:
self._thread.join(timeout=15)
if self._thread.is_alive():
logger.warning("APRS-IS worker thread did not exit on time and will be killed.")
def _handle(self, data):
# Split SSID in "from" call and store separately
from_parts = str(data["from"]).split("-")
dx_call = from_parts[0].upper()
dx_ssid = from_parts[1].upper() if len(from_parts) > 1 else None
via_parts = str(data["via"]).split("-")
de_call = via_parts[0].upper()
de_ssid = via_parts[1].upper() if len(via_parts) > 1 else None
spot = Spot(
source="APRS-IS",
dx_call=dx_call,
dx_ssid=dx_ssid,
de_call=de_call,
de_ssid=de_ssid,
comment=str(data["comment"]) if "comment" in data else None,
dx_latitude=float(data["latitude"]) if "latitude" in data else None,
dx_longitude=float(data["longitude"]) if "longitude" in data else None,
time=datetime.now(pytz.UTC).timestamp(),
) # APRS-IS spots are live so we can assume spot time is "now"
try:
# Split SSID in "from" call and store separately
from_parts = str(data["from"]).split("-")
dx_call = from_parts[0].upper()
dx_ssid = from_parts[1].upper() if len(from_parts) > 1 else None
via_parts = str(data["via"]).split("-")
de_call = via_parts[0].upper()
de_ssid = via_parts[1].upper() if len(via_parts) > 1 else None
spot = Spot(
source="APRS-IS",
dx_call=dx_call,
dx_ssid=dx_ssid,
de_call=de_call,
de_ssid=de_ssid,
comment=str(data["comment"]) if "comment" in data else None,
dx_latitude=float(data["latitude"]) if "latitude" in data else None,
dx_longitude=float(data["longitude"]) if "longitude" in data else None,
time=datetime.now(pytz.UTC).timestamp(),
) # APRS-IS spots are live so we can assume spot time is "now"
# Add to our list
self._submit(spot)
# Add to our list
self._submit(spot)
self.status = "OK"
self.last_update_time = datetime.now(pytz.UTC)
logger.debug("Data received from APRS-IS.")
self.status = "OK"
self.last_update_time = datetime.now(pytz.UTC)
logger.debug("Data received from APRS-IS.")
except Exception:
logger.exception("Exception handling APRS-IS packet")
+18 -16
View File
@@ -1,8 +1,7 @@
import logging
import re
from datetime import datetime
from threading import Thread
from time import sleep
from threading import Event, Thread
import pytz
import telnetlib3
@@ -41,23 +40,26 @@ class DXCluster(SpotProvider):
self._LINE_PATTERN_ALLOW_RBN if self._allow_rbn_spots else self._LINE_PATTERN_EXCLUDE_RBN
)
self._telnet = None
self._thread = Thread(target=self._handle, name=f"DXClusterSpotProvider-{self.name}")
self._thread.daemon = True
self._running = True
self._thread = None
self._stop_event = Event()
def start(self):
self._thread = Thread(target=self._handle, name=f"DXClusterSpotProvider-{self.name}", daemon=True)
self._thread.start()
def stop(self):
self._running = False
self._stop_event.set()
if self._telnet:
self._telnet.close()
self._thread.join()
if self._thread:
self._thread.join(timeout=15)
if self._thread.is_alive():
logger.warning(f"DX Cluster {self._hostname} worker thread did not exit on time and will be killed.")
def _handle(self):
while self._running:
while not self._stop_event.is_set():
connected = False
while not connected and self._running:
while not connected and not self._stop_event.is_set():
try:
self.status = "Connecting"
logger.info(f"DX Cluster {self._hostname} connecting...")
@@ -69,14 +71,14 @@ class DXCluster(SpotProvider):
except ConnectionRefusedError:
self.status = "Error"
logger.warning(f"Connection refused to DX cluster {self._hostname}")
sleep(300)
self._stop_event.wait(timeout=300)
except Exception:
self.status = "Error"
logger.exception(f"Exception while connecting to DX Cluster Provider ({self._hostname}).")
sleep(5)
self._stop_event.wait(timeout=5)
self.status = "Waiting for Data"
while connected and self._running:
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"))
@@ -106,19 +108,19 @@ class DXCluster(SpotProvider):
except EOFError:
connected = False
if self._running:
if not self._stop_event.is_set():
self.status = "Restarting"
logger.warning(f"Disconnected from DX Cluster {self._hostname}. Reconnecting...")
sleep(5)
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 self._running:
if not self._stop_event.is_set():
self.status = "Error"
logger.exception(f"Exception in DX Cluster Provider ({self._hostname})")
sleep(5)
self._stop_event.wait(timeout=5)
else:
logger.info(f"DX Cluster {self._hostname} shutting down...")
self.status = "Shutting down"
+5 -1
View File
@@ -28,12 +28,16 @@ class HTTPSpotProvider(SpotProvider):
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
# subsequent polls, so start() returns immediately and the application can continue starting.
logger.info(f"Set up query of {self.name} spot API every {self._poll_interval!s} seconds.")
self._thread = Thread(target=self._run, name=f"HTTPSpotProvider-{self.name}")
self._thread = Thread(target=self._run, name=f"HTTPSpotProvider-{self.name}", daemon=True)
self._thread.start()
def stop(self):
self._stop_event.set()
self._wakeup_event.set()
if self._thread:
self._thread.join(timeout=35)
if self._thread.is_alive():
logger.warning(f"{self.name} spot worker thread did not exit on time and will be killed.")
def force_poll(self):
"""Trigger an immediate poll without waiting for the normal interval."""
+17 -15
View File
@@ -1,8 +1,7 @@
import logging
import re
from datetime import datetime
from threading import Thread
from time import sleep
from threading import Event, Thread
import pytz
import telnetlib3
@@ -30,23 +29,26 @@ class RBN(SpotProvider):
super().__init__(name, provider_config)
self._port = provider_config["port"]
self._telnet = None
self._thread = Thread(target=self._handle, name=f"RBNSpotProvider-{self.name}")
self._thread.daemon = True
self._running = True
self._thread = None
self._stop_event = Event()
def start(self):
self._thread = Thread(target=self._handle, name=f"RBNSpotProvider-{self.name}", daemon=True)
self._thread.start()
def stop(self):
self._running = False
self._stop_event.set()
if self._telnet:
self._telnet.close()
self._thread.join()
if self._thread:
self._thread.join(timeout=15)
if self._thread.is_alive():
logger.warning(f"RBN (port {self._port!s}) worker thread did not exit on time and will be killed.")
def _handle(self):
while self._running:
while not self._stop_event.is_set():
connected = False
while not connected and self._running:
while not connected and not self._stop_event.is_set():
try:
self.status = "Connecting"
logger.info(f"RBN port {self._port!s} connecting...")
@@ -58,10 +60,10 @@ class RBN(SpotProvider):
except Exception:
self.status = "Error"
logger.exception(f"Exception while connecting to RBN (port {self._port!s}).")
sleep(5)
self._stop_event.wait(timeout=5)
self.status = "Waiting for Data"
while connected and self._running:
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"))
@@ -91,19 +93,19 @@ class RBN(SpotProvider):
except EOFError:
connected = False
if self._running:
if not self._stop_event.is_set():
self.status = "Restarting"
logger.warning(f"Disconnected from RBN provider (port {self._port!s}). Reconnecting...")
sleep(5)
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 self._running:
if not self._stop_event.is_set():
self.status = "Error"
logger.exception(f"Exception in RBN provider (port {self._port!s})")
sleep(5)
self._stop_event.wait(timeout=5)
else:
logger.info(f"RBN provider (port {self._port!s}) shutting down...")
self.status = "Shutting down"
+25 -10
View File
@@ -1,7 +1,6 @@
import logging
from datetime import datetime
from threading import Thread
from time import sleep
from threading import Event, Thread
import pytz
from websocket import create_connection
@@ -20,22 +19,24 @@ class WebsocketSpotProvider(SpotProvider):
self._url = url
self._ws = None
self._thread = None
self._stopped = False
self._stop_event = Event()
self._last_event_id = None
def start(self):
logger.info(f"Set up websocket connection to {self.name} spot API.")
self._stopped = False
self._stop_event.clear()
self._thread = Thread(target=self._run, name=f"WebsocketSpotProvider-{self.name}")
self._thread.daemon = True
self._thread.start()
def stop(self):
self._stopped = True
self._stop_event.set()
if self._ws:
self._ws.close()
if self._thread:
self._thread.join()
self._thread.join(timeout=15)
if self._thread.is_alive():
logger.warning(f"{self.name} websocket worker thread did not exit on time and will be killed.")
def _on_open(self):
self.status = "Waiting for Data"
@@ -44,14 +45,19 @@ class WebsocketSpotProvider(SpotProvider):
self.status = "Connecting"
def _run(self):
while not self._stopped:
while not self._stop_event.is_set():
try:
logger.debug(f"Connecting to {self.name} spot API...")
self.status = "Connecting"
self._ws = create_connection(self._url, header=HTTP_HEADERS)
self.status = "Connected"
data = self._ws.recv()
if data:
# Keep reading from this same connection until it drops or we're asked to stop, rather than
# reconnecting for every message.
while not self._stop_event.is_set():
data = self._ws.recv()
if not data:
break
try:
new_spot = self._ws_message_to_spot(data)
if new_spot:
@@ -69,7 +75,16 @@ class WebsocketSpotProvider(SpotProvider):
logger.exception(f"Exception in Websocket Spot Provider ({self.name})")
else:
self.status = "Disconnected"
sleep(5) # Wait before trying to reconnect
finally:
if self._ws:
try:
self._ws.close()
except Exception:
# No problem, we were getting rid of this object anyway.
pass
self._ws = None
if not self._stop_event.is_set():
self._stop_event.wait(timeout=5) # Wait before trying to reconnect
def _ws_message_to_spot(self, b):
"""Convert a WS message received from the API into a spot. The exact message data (in bytes) is provided here so the
@@ -29,11 +29,15 @@ class FileDownloadStaticDataProvider(StaticDataProvider):
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
# subsequent polls, so start() returns immediately and the application can continue starting.
logger.info(f"Set up query of {self.name} static reference data every {self._poll_interval!s} days.")
self._thread = Thread(target=self._run, name=f"FileDownloadStaticDataProvider-{self.name}")
self._thread = Thread(target=self._run, name=f"FileDownloadStaticDataProvider-{self.name}", daemon=True)
self._thread.start()
def stop(self):
self._stop_event.set()
if self._thread:
self._thread.join(timeout=35)
if self._thread.is_alive():
logger.warning(f"{self.name} static data worker thread did not exit on time and will be killed.")
def _run(self):
while True:
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "spothole"
version = "2.1-pre"
version = "2.1"
authors = [
{ name = "Ian Renton", email = "ian@ianrenton.com" },
]
+19 -8
View File
@@ -15,17 +15,25 @@ from webserver.webserver import WEB_SERVER
logger = logging.getLogger(__name__)
_shutdown_in_progress = False
def shutdown(_signum=None, _frame=None):
"""Shutdown function"""
# Check if this is the second time a shutdown was asked for, if so immediately kill the program.
global _shutdown_in_progress
if _shutdown_in_progress:
os._exit(1)
_shutdown_in_progress = True
logger.info("Stopping program...")
WEB_SERVER.stop()
TELNET_SERVER.stop()
DATA_PROVIDERS.stop()
CLEANUP_TIMER.stop()
DATA_STORE.close()
os._exit(0)
logger.info("Stopped.")
# Main function
@@ -43,8 +51,9 @@ if __name__ == "__main__":
logger.info("Starting...")
logger.info(f"This is Spothole version {SOFTWARE_VERSION}. This instance is run by {SERVER_OWNER_CALLSIGN}.")
# Shut down gracefully on SIGINT
# Shut down gracefully on SIGINT or SIGTERM
signal.signal(signal.SIGINT, shutdown)
signal.signal(signal.SIGTERM, shutdown)
# Set up data store
DATA_STORE.setup()
@@ -59,14 +68,16 @@ if __name__ == "__main__":
status_reporter = StatusReporter(run_interval=5)
status_reporter.start()
# Set up the web server
WEB_SERVER.setup()
# Run the telnet server
if TELNET_SERVER_ENABLED:
TELNET_SERVER.start(port=TELNET_SERVER_PORT)
# Run the web server. This is the blocking call that keeps the application running in the main thread, so this must
# be the last thing we do. web_server.stop() triggers an await condition in the web server which finishes the main
# thread.
# Set up the web server
WEB_SERVER.setup()
# Run the web server
WEB_SERVER.start()
# Block the main thread until a termination signal arrives and shutdown() is running.
while not _shutdown_in_progress:
signal.pause()
+5
View File
@@ -901,7 +901,12 @@ components:
- WAB
- WAI
- DME
- DMF
- FEA
- DMUE
- DMVE
- DCE
- DEFE
- DTMBA
- BIWOTA
- COTA
+12 -4
View File
@@ -49,6 +49,7 @@ class TelnetServer:
self._running = False
self._clients = set()
self._loop = None
self._thread = None
self._shutdown_event = asyncio.Event()
def start(self, port=7373):
@@ -58,8 +59,10 @@ class TelnetServer:
# Start the telnet server. asyncio.run() needs a coroutine, and threading.Thread needs a plain callable, so
# hand Thread the bridge between the two directly rather than writing a one-line wrapper method for it.
t = threading.Thread(target=asyncio.run, args=(self._start_internal(),), name="TelnetServer", daemon=True)
t.start()
self._thread = threading.Thread(
target=asyncio.run, args=(self._start_internal(),), name="TelnetServer", daemon=True
)
self._thread.start()
logger.debug("Telnet server background thread spawned")
# Listen for new spots and alerts being added to the cache, so we can notify SSE clients immediately
@@ -135,6 +138,10 @@ class TelnetServer:
if self._loop and self._loop.is_running():
logger.debug("Stopping telnet server...")
self._loop.call_soon_threadsafe(self._shutdown_event.set)
if self._thread:
self._thread.join(timeout=15)
if self._thread.is_alive():
logger.warning("Telnet server background thread did not exit on time and will be killed.")
@property
def client_count(self) -> int:
@@ -163,9 +170,10 @@ class TelnetServer:
# Ensure ASCII formatting for telnet clients
encoded_line = self._format_dxspider_spot(spot).encode("ascii", errors="ignore")
# Try to write to all clients, and in the process find the ones that are disconnected
# Try to write to all clients, and in the process find the ones that are disconnected. Iterate over a copy
# since a new client can connect (mutating self._clients) while we're awaiting a write below.
disconnected_clients = set()
for writer in self._clients:
for writer in list(self._clients):
try:
writer.write(encoded_line)
await writer.drain()
+1 -1
View File
@@ -114,7 +114,7 @@
Faros de España (FEA), Diploma Muesos de España (DMUE), Diploma Castillos de España (DCE), Diploma Monumentos y
Vestigios de España (DMVE), Diploma Estaciones de Ferrocarril de España (DEFE), Diploma Teatri Musei e Belle
Arti (DTMBA), British Inland Waterways on the Air (BIWOTA), Castles on the Air (COTA), Polish Gmina Award (PGA),
EME/Moonbounce, Amateur Satellite (AMSAT), and Toilets on the Air.</p>
Diplôme des Moulins de France (DMF), EME/Moonbounce, Amateur Satellite (AMSAT), and Toilets on the Air.</p>
<p>As of the time of writing in August 2026, I think Spothole captures most outdoor radio programmes that have a
defined, downloadable reference list, and almost certainly those that have a spotting/alerting API. If you know
of one I've missed, please let me know!</p>
+1 -1
View File
@@ -77,7 +77,7 @@
</div>
<script src="/static/js/add-spot.js?v=1789198850"></script>
<script src="/static/js/add-spot.js?v=1789731849"></script>
<script>$(document).ready(function () {
$("#nav-link-add-spot").addClass("active");
}); <!-- highlight active page in nav --></script>
+1 -1
View File
@@ -83,7 +83,7 @@
</div>
<script src="/static/js/alerts.js?v=1789198850"></script>
<script src="/static/js/alerts.js?v=1789731849"></script>
<script>$(document).ready(function () {
$("#nav-link-alerts").addClass("active");
}); <!-- highlight active page in nav --></script>
+2 -2
View File
@@ -76,8 +76,8 @@
</div>
<script src="/static/js/spotsbandsandmap.js?v=1789198850"></script>
<script src="/static/js/bands.js?v=1789198850"></script>
<script src="/static/js/spotsbandsandmap.js?v=1789731849"></script>
<script src="/static/js/bands.js?v=1789731849"></script>
<script>$(document).ready(function () {
$("#nav-link-bands").addClass("active");
}); <!-- highlight active page in nav --></script>
+5 -5
View File
@@ -1,6 +1,6 @@
{% extends "skeleton.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/css/style.css?v=1789198850" type="text/css">
<link rel="stylesheet" href="/static/css/style.css?v=1789731849" type="text/css">
<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/solid-6.7.2.min.css" rel="stylesheet">
@@ -16,10 +16,10 @@
window.fetchEventSource = fetchEventSource;
</script>
<script src="/static/js/utils.js?v=1789198850"></script>
<script src="/static/js/ui-ham.js?v=1789198850"></script>
<script src="/static/js/geo.js?v=1789198850"></script>
<script src="/static/js/common.js?v=1789198850"></script>
<script src="/static/js/utils.js?v=1789731849"></script>
<script src="/static/js/ui-ham.js?v=1789731849"></script>
<script src="/static/js/geo.js?v=1789731849"></script>
<script src="/static/js/common.js?v=1789731849"></script>
{% end %}
{% block body %}
<div class="container">
+1 -1
View File
@@ -284,7 +284,7 @@
</div>
<script src="/static/vendor/js/chart-4.4.9.umd.min.js"></script>
<script src="/static/js/conditions.js?v=1789198850"></script>
<script src="/static/js/conditions.js?v=1789731849"></script>
<script>$(document).ready(function () {
$("#nav-link-conditions").addClass("active");
}); <!-- 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', '') }}";
</script>
<script src="/static/js/spotsbandsandmap.js?v=1789198850"></script>
<script src="/static/js/map.js?v=1789198850"></script>
<script src="/static/js/spotsbandsandmap.js?v=1789731849"></script>
<script src="/static/js/map.js?v=1789731849"></script>
<script>$(document).ready(function () {
$("#nav-link-map").addClass("active");
}); <!-- highlight active page in nav --></script>
+2 -2
View File
@@ -125,8 +125,8 @@
</div>
<script src="/static/js/spotsbandsandmap.js?v=1789198850"></script>
<script src="/static/js/spots.js?v=1789198850"></script>
<script src="/static/js/spotsbandsandmap.js?v=1789731849"></script>
<script src="/static/js/spots.js?v=1789731849"></script>
<script>$(document).ready(function () {
$("#nav-link-spots").addClass("active");
}); <!-- highlight active page in nav --></script>
+1 -1
View File
@@ -96,7 +96,7 @@
</div>
</div>
<script src="/static/js/status.js?v=1789198850"></script>
<script src="/static/js/status.js?v=1789731849"></script>
<script>
$(document).ready(function () {
$("#nav-link-status").addClass("active");
+1 -1
View File
@@ -34,7 +34,7 @@ class V1RedirectHandler(tornado.web.RequestHandler):
response = await client.fetch(
new_url,
method=self.request.method,
headers=self.request.headers,
headers=headers,
body=None if self.request.method == "GET" else (self.request.body or b""),
raise_error=False,
follow_redirects=False,
+23 -2
View File
@@ -1,6 +1,7 @@
import asyncio
import logging
import os
import threading
import tornado
from tornado.web import StaticFileHandler
@@ -53,6 +54,8 @@ class WebServer:
self._port = WEB_SERVER_PORT
self._api_only_mode = API_ONLY_MODE
self._shutdown_event = asyncio.Event()
self._loop = None
self._thread = None
self.web_server_metrics = WebServerMetrics()
def setup(self):
@@ -69,16 +72,34 @@ class WebServer:
def start(self):
"""Start the web server"""
asyncio.run(self._start_inner())
self._thread = threading.Thread(target=asyncio.run, args=(self._start_inner(),), name="WebServer", daemon=True)
self._thread.start()
def stop(self):
"""Stop the web server"""
self._shutdown_event.set()
if self._loop and self._loop.is_running():
self._loop.call_soon_threadsafe(self._shutdown_event.set)
if self._thread:
self._thread.join(timeout=15)
if self._thread.is_alive():
logger.warning("Web server background thread did not exit on time and will be killed.")
def _handle_loop_exception(self, loop, context):
"""Ignore "cancelled" exceptions from the asyncio loop to avoid printing exceptions to the log on shutdown when
we cancel the SSE handler threads"""
exception = context.get("exception")
if isinstance(exception, asyncio.CancelledError):
return
loop.default_exception_handler(context)
async def _start_inner(self):
"""Start method (async). Sets up the Tornado application."""
self._loop = asyncio.get_running_loop()
self._loop.set_exception_handler(self._handle_loop_exception)
# Bind the SSE broadcasters to the web server's loop, so they fire correctly
self._spot_broadcaster.bind_to_web_server_loop()
self._alert_broadcaster.bind_to_web_server_loop()