Compare commits

...
18 Commits
Author SHA1 Message Date
Ian Renton ed172a0063 Release 2.1.3 2026-09-20 09:48:10 +01:00
Ian Renton f45b7d51b0 Give threads less time to shut down gracefully. Attempt shutdown() rather than stop() on the telnet connections to see if that improves things 2026-09-19 15:07:31 +01:00
Ian Renton 0eb4553406 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	templates/add_spot.html
#	templates/alerts.html
#	templates/bands.html
#	templates/base.html
#	templates/conditions.html
#	templates/map.html
#	templates/spots.html
#	templates/status.html
2026-09-19 10:49:36 +01:00
Ian Renton 682e2c267c Improve handling of empty JSON repsonses from ParksNPeaks (and potentially others) 2026-09-19 10:49:09 +01:00
Ian Renton f39215ecdd Attempt at fixing slight hscroll on mobile 2026-09-19 08:33:15 +01:00
Ian Renton c9c8ffc1f7 If cache load fails because objects are from a different version and throw an exception, clear the cache. 2026-09-19 08:04:50 +01:00
Ian Renton 59d5f61d90 Potential fix for an issue where the telnet client was reconnecting right at the same time we try to shut down spothole, causing the stop() method to close one telnet object but then a new one is created and read from anyway. 2026-09-19 07:55:12 +01:00
Ian Renton ab81c136cc Release 2.1.1 2026-09-18 21:37:05 +01:00
Ian Renton f0df4f38ca Turns out "if x in list" returns true if list[x] = None, so guard against that by checking list.get(x) instead 2026-09-18 21:33:26 +01:00
Ian Renton 4b51dd9ba5 (Hopefully) fix a bug where several sig ref data providers try to write to the data store simultaneously on startup. 2026-09-18 18:57:18 +01:00
Ian Renton 556ea56378 v2.1 release 2026-09-18 12:44:09 +01:00
Ian Renton a367888e14 Missed a couple of _stop_events 2026-09-18 09:27:15 +01:00
Ian Renton 29d8654234 Use _stop_event consistently across all threads as the way to signal that it should stop. Add thread joins with timeouts to allow the program to exit cleanly 2026-09-18 09:21:38 +01:00
Ian Renton d79c8f72c8 Add missing SIGs to OpenAPI spec 2026-09-18 07:58:55 +01:00
Ian Renton a03e1336c8 Code review fixes 2026-09-18 07:56:08 +01:00
Ian Renton 0fa8cd763d Add support for Diplôme des Moulins de France 2026-09-18 07:38:30 +01:00
Ian Renton 4261c60d74 Improve protection against old unsupported objects (e.g. LocationSourceForCallsign.NONE) coming back from the cache and throwing exceptions 2026-09-18 07:28:29 +01:00
Ian Renton 29eea1edc0 Telnet fixes and banner 2026-09-12 08:40:50 +01:00
48 changed files with 494 additions and 218 deletions
+4
View File
@@ -34,6 +34,10 @@ class CleanupTimer:
"""Stop any threads and prepare for application shutdown""" """Stop any threads and prepare for application shutdown"""
self._stop_event.set() self._stop_event.set()
if self._thread:
self._thread.join(timeout=5)
if self._thread.is_alive():
logger.warning("Cleanup worker thread did not exit on time and will be killed.")
def _run(self): def _run(self):
while not self._stop_event.wait(timeout=self._cleanup_interval): 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 from data.sig import SIG
# General software # General software
SOFTWARE_VERSION = "2.1-pre" SOFTWARE_VERSION = "2.1.3"
# HTTP headers used for spot providers that use HTTP # HTTP headers used for spot providers that use HTTP
HTTP_HEADERS = {"User-Agent": f"Spothole v{SOFTWARE_VERSION} (operated by {SERVER_OWNER_CALLSIGN})"} HTTP_HEADERS = {"User-Agent": f"Spothole v{SOFTWARE_VERSION} (operated by {SERVER_OWNER_CALLSIGN})"}
@@ -263,6 +263,16 @@ SIGS = [
region_flag="🇮🇪", region_flag="🇮🇪",
refs_globally_unique=False, 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( SIG(
name="DME", name="DME",
comment_names=["DME"], comment_names=["DME"],
+52 -33
View File
@@ -1,5 +1,6 @@
import logging import logging
import threading import threading
import time
from core.config import config, create_provider_from_config from core.config import config, create_provider_from_config
@@ -16,6 +17,7 @@ class DataProviders:
self.static_data_providers = [] self.static_data_providers = []
self.sig_ref_data_providers = [] self.sig_ref_data_providers = []
self.callsign_data_providers = [] self.callsign_data_providers = []
self._startup_timers = []
def setup(self): def setup(self):
for entry in config["spot_providers"]: for entry in config["spot_providers"]:
@@ -43,41 +45,58 @@ class DataProviders:
def start(self): def start(self):
# Start data providers before spot/alert providers so the lookup data is there already for incoming spots. # 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. # 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() self._startup_timers = [
threading.Timer( threading.Timer(5.0, lambda: self.start_providers(self.static_data_providers, "static data")),
10.0, threading.Timer(10.0, lambda: self.start_providers(self.callsign_data_providers, "callsign data")),
lambda: self.start_providers(self.callsign_data_providers, "callsign data"), 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")),
threading.Timer(15.0, lambda: self.start_providers(self.spot_providers, "spot")).start() threading.Timer(
threading.Timer(20.0, lambda: self.start_providers(self.alert_providers, "alert")).start() 25.0,
threading.Timer( lambda: self.start_providers(self.solar_condition_providers, "solar condition"),
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")),
).start() ]
threading.Timer( for t in self._startup_timers:
30.0, t.daemon = True
lambda: self.start_providers(self.sig_ref_data_providers, "SIG ref data"), t.start()
).start()
def stop(self): def stop(self):
for sp in self.spot_providers: # Cancel any startup timers that haven't fired yet
if sp.enabled: for t in self._startup_timers:
sp.stop() t.cancel()
for ap in self.alert_providers:
if ap.enabled: # Stop all providers
ap.stop() all_providers = [
for scp in self.solar_condition_providers: p
if scp.enabled: for p in (
scp.stop() self.spot_providers
for srdp in self.sig_ref_data_providers: + self.alert_providers
if srdp.enabled: + self.solar_condition_providers
srdp.stop() + self.sig_ref_data_providers
for sdp in self.static_data_providers: + self.static_data_providers
if sdp.enabled: + self.callsign_data_providers
sdp.stop() )
for cdp in self.callsign_data_providers: if p.enabled
if cdp.enabled: ]
cdp.stop() 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() + 15
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 # 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 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.""" 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]: 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"])) self.dxcc_lookup_by_call_regex.append((re.compile(entry["prefixRegex"]), entry["entityCode"]))
+18 -5
View File
@@ -22,6 +22,8 @@ class LiveDataCache:
self._listeners_lock = threading.Lock() self._listeners_lock = threading.Lock()
self._snapshot_dir = snapshot_dir self._snapshot_dir = snapshot_dir
self._disk_cache = diskcache.Cache(str(snapshot_dir)) self._disk_cache = diskcache.Cache(str(snapshot_dir))
self._stop_event = threading.Event()
self._snapshot_thread = None
self._load_snapshot() self._load_snapshot()
self._start_periodic_snapshot(snapshot_interval_sec) self._start_periodic_snapshot(snapshot_interval_sec)
@@ -75,7 +77,12 @@ class LiveDataCache:
logger.exception(f"Failed to write snapshot to {self._snapshot_dir}") logger.exception(f"Failed to write snapshot to {self._snapshot_dir}")
def _load_snapshot(self): def _load_snapshot(self):
data = self._disk_cache.get("snapshot") try:
data = self._disk_cache.get("snapshot")
except Exception:
logger.warning(f"Failed to load snapshot from {self._snapshot_dir}, clearing it.")
self._disk_cache.clear()
return
if not data: if not data:
return return
@@ -89,13 +96,19 @@ class LiveDataCache:
def _start_periodic_snapshot(self, interval): def _start_periodic_snapshot(self, interval):
def loop(): def loop():
while True: while not self._stop_event.wait(timeout=interval):
time.sleep(interval)
self.save_snapshot() self.save_snapshot()
t = threading.Thread(target=loop, name=f"LiveDataCache-Snapshot-{self._snapshot_dir}") self._snapshot_thread = threading.Thread(
t.start() target=loop, name=f"LiveDataCache-Snapshot-{self._snapshot_dir}", daemon=True
)
self._snapshot_thread.start()
def close(self): 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.save_snapshot()
self._disk_cache.close() 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 # 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. # lists never do, so convert them here.
ref_id.replace(" ", "-") ref_id = ref_id.replace(" ", "-")
# Prepare the object to be returned # Prepare the object to be returned
sig_ref = SIGRef(sig=sig_name, id=ref_id) 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 # 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 # so, copy the data into the sig_ref object
key = f"{sig_name}:{ref_id}" key = f"{sig_name}:{ref_id}"
lookup_data = DATA_STORE.sigrefs.get(key) if key in DATA_STORE.sigrefs else None try:
if lookup_data: lookup_data = DATA_STORE.sigrefs.get(key) if key in DATA_STORE.sigrefs else None
for key, value in lookup_data.__dict__.items(): if lookup_data:
if value is not None and sig_ref.__dict__.get(key) is None: for attr, value in lookup_data.__dict__.items():
sig_ref.__dict__[key] = value if value is not None and sig_ref.__dict__.get(attr) is None:
sig_ref.__dict__[attr] = value
else: else:
# Maybe a super new reference we don't know about yet, but more likely a typo or a test reference, # Maybe a super new reference we don't know about yet, but more likely a typo or a test reference,
# just silently ignore it. # just silently ignore it.
logger.debug(f"{sig_name} database did not contain data for ref {ref_id}") 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: except Exception:
logger.exception(f"Exception when looking up sig_ref info for {sig_name} ref {ref_id}") logger.exception(f"Exception when looking up sig_ref info for {sig_name} ref {ref_id}")
+7 -1
View File
@@ -21,7 +21,13 @@ class SingleObjectDataCache:
# This cache stores a single object, doesn't matter what it's called so "object" will do # This cache stores a single object, doesn't matter what it's called so "object" will do
if "object" not in self._cache: if "object" not in self._cache:
self._cache.add("object", object_if_empty) self._cache.add("object", object_if_empty)
self._obj = self._cache.get("object") try:
self._obj = self._cache.get("object")
except Exception:
logger.warning(f"Failed to load cache from {cache_dir}, clearing it.")
self._cache.clear()
self._cache.add("object", object_if_empty)
self._obj = object_if_empty
def get(self): def get(self):
"""Get the data object. This can then be manipulated as necessary across multiple threads. Any function """Get the data object. This can then be manipulated as necessary across multiple threads. Any function
+8 -1
View File
@@ -1,3 +1,4 @@
import logging
import os import os
from datetime import datetime from datetime import datetime
from threading import Event, Thread 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 telnetserver.telnetserver import TELNET_SERVER
from webserver.webserver import WEB_SERVER from webserver.webserver import WEB_SERVER
logger = logging.getLogger(__name__)
class StatusReporter: class StatusReporter:
"""Provides a timed update of the application's status data.""" """Provides a timed update of the application's status data."""
@@ -33,13 +36,17 @@ class StatusReporter:
def start(self): def start(self):
"""Start the reporter thread""" """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() self._thread.start()
def stop(self): def stop(self):
"""Stop any threads and prepare for application shutdown""" """Stop any threads and prepare for application shutdown"""
self._stop_event.set() 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): def _run(self):
"""Thread entry point: report immediately on startup, then on each interval until stopped""" """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 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.""" create one of these objects per thread if possible."""
_lock = threading.Lock()
def __init__(self, name): def __init__(self, name):
super().__init__( super().__init__(
f"{CACHE_DIR}urls/{name}", f"{CACHE_DIR}urls/{name}",
expire_after=timedelta(days=1), expire_after=timedelta(days=1),
allowable_codes=(200, 400, 401, 403, 404), allowable_codes=(200, 400, 401, 403, 404),
) )
self._lock = threading.Lock()
def get(self, *args, **kwargs): def get(self, *args, **kwargs):
with self._lock: with self._lock:
+1 -1
View File
@@ -158,7 +158,7 @@ class Alert:
self.icon = "fa-globe-africa" self.icon = "fa-globe-africa"
elif self.alert_type == AlertType.CONTEST: elif self.alert_type == AlertType.CONTEST:
self.icon = "fa-trophy" self.icon = "fa-trophy"
elif self.alert_type == AlertType.CONTEST: elif self.alert_type == AlertType.SATELLITE:
self.icon = "fa-satellite" self.icon = "fa-satellite"
elif self.sig_refs and self.sig_refs[0].icon: elif self.sig_refs and self.sig_refs[0].icon:
self.icon = 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.call_lookup_helper import get_call_info
from core.config import MAX_SPOT_AGE from core.config import MAX_SPOT_AGE
from core.constants import PROPAGATION_MODES, SIGS 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.enums import Continent, LocationSourceForSpot, Mode, ModeSource, ModeType
from core.geo_utils import lat_lon_to_cq_zone, lat_lon_to_itu_zone 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 from core.sig_lookup_helper import populate_missing_sig_ref_info
@@ -363,11 +364,13 @@ class Spot:
if self.propagation_mode == "Satellite": if self.propagation_mode == "Satellite":
if not self.sig: if not self.sig:
self.sig = "AMSAT" 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 self.propagation_mode == "Earth-Moon-Earth":
if not self.sig: if not self.sig:
self.sig = "EME" 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 # Parse "de_grid -> dx_grid" structures from the comment
if self.comment: if self.comment:
@@ -445,6 +448,15 @@ class Spot:
elif self.dx_call: elif self.dx_call:
self.dx_itu_zone = dx_call_info.itu_zone 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 # 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. # is likely at home.
self.dx_location_good = bool( self.dx_location_good = bool(
+11 -2
View File
@@ -4,7 +4,7 @@ from threading import Event, Thread
import pytz import pytz
import requests import requests
from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout from requests.exceptions import ConnectionError, ConnectTimeout, JSONDecodeError, ReadTimeout
from core.constants import HTTP_HEADERS from core.constants import HTTP_HEADERS
from providers.alert.alert_provider import AlertProvider from providers.alert.alert_provider import AlertProvider
@@ -27,11 +27,15 @@ class HTTPAlertProvider(AlertProvider):
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between # 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. # 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.") 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() self._thread.start()
def stop(self): def stop(self):
self._stop_event.set() self._stop_event.set()
if self._thread:
self._thread.join(timeout=12)
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): def _run(self):
while True: while True:
@@ -60,9 +64,14 @@ class HTTPAlertProvider(AlertProvider):
logger.warning(f"HTTP {http_response.status_code} when calling {self.name} alerts API.") logger.warning(f"HTTP {http_response.status_code} when calling {self.name} alerts API.")
except ConnectionError: except ConnectionError:
self.status = "Error"
logger.warning(f"Connection error when accessing {self.name} alerts API.") logger.warning(f"Connection error when accessing {self.name} alerts API.")
except (ConnectTimeout, ReadTimeout): except (ConnectTimeout, ReadTimeout):
self.status = "Error"
logger.warning(f"Timeout when accessing {self.name} alerts API.") logger.warning(f"Timeout when accessing {self.name} alerts API.")
except JSONDecodeError:
self.status = "Error"
logger.warning(f"Invalid or empty JSON response from {self.name} alert API.")
except Exception: except Exception:
self.status = "Error" self.status = "Error"
logger.exception(f"Exception in HTTP JSON Alert Provider ({self.name})") logger.exception(f"Exception in HTTP JSON Alert Provider ({self.name})")
@@ -42,7 +42,13 @@ class CallsignDataProvider:
if self.enabled: if self.enabled:
if callsign in self._storage: 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: else:
c = self._perform_new_lookup(callsign, lookup_credentials) c = self._perform_new_lookup(callsign, lookup_credentials)
if c: 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 # 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. # 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.") 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() self._thread.start()
def stop(self): def stop(self):
self._stop_event.set() self._stop_event.set()
if self._thread:
self._thread.join(timeout=12)
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): def _run(self):
while True: while True:
+6 -6
View File
@@ -124,8 +124,8 @@ class HamQTH(APIQueryCallsignDataProvider):
lat = None lat = None
lon = None lon = None
if ( if (
"latitude" in data data.get("latitude") is not None
and "longitude" in data and data.get("longitude") is not None
and (float(data["latitude"]) != 0 or float(data["longitude"]) != 0) and (float(data["latitude"]) != 0 or float(data["longitude"]) != 0)
and -89.9 < float(data["latitude"]) < 89.9 and -89.9 < float(data["latitude"]) < 89.9
): ):
@@ -134,7 +134,7 @@ class HamQTH(APIQueryCallsignDataProvider):
# Check for sensible grids # Check for sensible grids
grid = None grid = None
if "grid" in data and not data["grid"].startswith("AA00"): if data.get("grid") and not data["grid"].startswith("AA00"):
grid = data["grid"] grid = data["grid"]
return Callsign( return Callsign(
@@ -147,8 +147,8 @@ class HamQTH(APIQueryCallsignDataProvider):
latitude=lat, latitude=lat,
longitude=lon, longitude=lon,
grid=grid, grid=grid,
dxcc_id=int(data["adif"]) if "adif" in data else None, dxcc_id=int(data["adif"]) if data.get("adif") is not None else None,
cq_zone=int(data["cq"]) if "cq" in data else None, cq_zone=int(data["cq"]) if data.get("cq") is not None else None,
itu_zone=int(data["itu"]) if "itu" in data else None, itu_zone=int(data["itu"]) if data.get("itu") is not None else None,
location_source=LocationSourceForCallsign.HOME_QTH, location_source=LocationSourceForCallsign.HOME_QTH,
) )
+6 -6
View File
@@ -150,8 +150,8 @@ class QRZ(APIQueryCallsignDataProvider):
lat = None lat = None
lon = None lon = None
if ( if (
"latitude" in data data.get("latitude") is not None
and "longitude" in data and data.get("longitude") is not None
and (float(data["latitude"]) != 0 or float(data["longitude"]) != 0) and (float(data["latitude"]) != 0 or float(data["longitude"]) != 0)
and -89.9 < float(data["latitude"]) < 89.9 and -89.9 < float(data["latitude"]) < 89.9
): ):
@@ -160,7 +160,7 @@ class QRZ(APIQueryCallsignDataProvider):
# Check for sensible grids # Check for sensible grids
grid = None grid = None
if "grid" in data and not data["grid"].startswith("AA00"): if data.get("grid") and not data["grid"].startswith("AA00"):
grid = data["grid"] grid = data["grid"]
return Callsign( return Callsign(
@@ -173,8 +173,8 @@ class QRZ(APIQueryCallsignDataProvider):
latitude=lat, latitude=lat,
longitude=lon, longitude=lon,
grid=grid, grid=grid,
dxcc_id=int(data["adif"]) if "adif" in data else None, dxcc_id=int(data["adif"]) if data.get("adif") is not None else None,
cq_zone=int(data["cqzone"]) if "cqzone" in data else None, cq_zone=int(data["cqzone"]) if data.get("cqzone") is not None else None,
itu_zone=int(data["ituzone"]) if "ituzone" in data else None, itu_zone=int(data["ituzone"]) if data.get("ituzone") is not None else None,
location_source=LocationSourceForCallsign.HOME_QTH, location_source=LocationSourceForCallsign.HOME_QTH,
) )
+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 # 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 # of the data in this case
if self._stop: if self._stop_event.is_set():
break break
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time # 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 import logging
from datetime import datetime from datetime import datetime
from threading import Event, Thread from threading import Thread
import pytz import pytz
from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout
@@ -21,19 +21,21 @@ class FileDownloadSIGRefDataProvider(SIGRefDataProvider):
self._url = url self._url = url
self._poll_interval = poll_interval self._poll_interval = poll_interval
self._thread = None self._thread = None
self._stop_event = Event()
self._url_data_cache = URLDataCache(f"sigrefdata_{sig_name}") self._url_data_cache = URLDataCache(f"sigrefdata_{sig_name}")
def start(self): def start(self):
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between # 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. # 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.") 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() self._thread.start()
def stop(self): def stop(self):
super().stop() super().stop()
self._stop_event.set() if self._thread:
self._thread.join(timeout=12)
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): def _run(self):
while True: while True:
@@ -1,5 +1,6 @@
import logging import logging
from datetime import datetime from datetime import datetime
from threading import Event
import pytz import pytz
@@ -19,7 +20,7 @@ class SIGRefDataProvider:
self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC) self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC)
self.status = "Not Started" if self.enabled else "Disabled" self.status = "Not Started" if self.enabled else "Disabled"
self.reference_count = 0 self.reference_count = 0
self._stop = False self._stop_event = Event()
def start(self): def start(self):
"""Start the provider. This should return immediately after spawning threads to access the remote resources""" """Start the provider. This should return immediately after spawning threads to access the remote resources"""
@@ -30,20 +31,22 @@ class SIGRefDataProvider:
"""Stop any threads and prepare for application shutdown. Subclasses should implement this method and call """Stop any threads and prepare for application shutdown. Subclasses should implement this method and call
super().""" super()."""
self._stop = True self._stop_event.set()
def _add_data(self, new_data): def _add_data(self, new_data):
"""Add all the provided reference data objects to the data store.""" """Add all the provided reference data objects to the data store."""
# with transact() batches all writes together to save making thousands of individual sqlite writes # with transact() batches all writes together to save making thousands of individual sqlite writes. However,
with DATA_STORE.sigrefs.transact(): # that means that each provider holds the lock while it writes, and the default behaviour for other attempted
# transact()s is to fail if they can't get the lock (?!). This behaviour is fixed by retry=True.
with DATA_STORE.sigrefs.transact(retry=True):
for d in new_data: for d in new_data:
DATA_STORE.sigrefs.set(f"{self.sig_name}:{d.id}", d) DATA_STORE.sigrefs.set(f"{self.sig_name}:{d.id}", d)
# For the big data sources, loading will take a few minutes. If we want to shut down the software neatly # 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 # within the first few minutes of startup, we need a way to abort this expensive process of filling up the
# disk cache. # disk cache.
if self._stop: if self._stop_event.is_set():
break break
self.reference_count = len(new_data) 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 # 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 # of the data in this case
if self._stop: if self._stop_event.is_set():
break break
return new_data return new_data
+5 -1
View File
@@ -67,11 +67,15 @@ class GIROIonosonde(SolarConditionsProvider):
def start(self): def start(self):
logger.info(f"Set up query of GIRO ionosonde data API every {POLL_INTERVAL} seconds.") 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() self._thread.start()
def stop(self): def stop(self):
self._stop_event.set() self._stop_event.set()
if self._thread:
self._thread.join(timeout=12)
if self._thread.is_alive():
logger.warning("GIRO ionosonde worker thread did not exit on time and will be killed.")
def _run(self): def _run(self):
# Real interval at which we poll is the "once per hour" divided by the number of stations, so each one gets # 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): def start(self):
logger.info(f"Set up query of {self.name} solar conditions API every {self._poll_interval!s} seconds.") 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() self._thread.start()
def stop(self): def stop(self):
self._stop_event.set() self._stop_event.set()
if self._thread:
self._thread.join(timeout=12)
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): def _run(self):
while True: while True:
+5 -1
View File
@@ -32,11 +32,15 @@ class KC2GProp(SolarConditionsProvider):
def start(self): def start(self):
logger.info(f"Set up query of KC2G ionosonde data API every {POLL_INTERVAL} seconds.") 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() self._thread.start()
def stop(self): def stop(self):
self._stop_event.set() self._stop_event.set()
if self._thread:
self._thread.join(timeout=12)
if self._thread.is_alive():
logger.warning("KC2G ionosonde worker thread did not exit on time and will be killed.")
def _run(self): def _run(self):
while True: while True:
+55 -35
View File
@@ -1,6 +1,6 @@
import logging import logging
from datetime import datetime from datetime import datetime
from threading import Thread from threading import Event, Thread
import aprslib import aprslib
import pytz import pytz
@@ -17,49 +17,69 @@ class APRSIS(SpotProvider):
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__("APRS-IS", provider_config) super().__init__("APRS-IS", provider_config)
self._thread = Thread(target=self._connect, name="APRSISSpotProvider") self._thread = None
self._thread.daemon = True
self._aprsis = None self._aprsis = None
self._stop_event = Event()
def start(self): def start(self):
self._thread = Thread(target=self._run, name="APRSISSpotProvider", daemon=True)
self._thread.start() self._thread.start()
def _connect(self): def _run(self):
self._aprsis = aprslib.IS(SERVER_OWNER_CALLSIGN) while not self._stop_event.is_set():
self.status = "Connecting" try:
logger.info("APRS-IS connecting...") self._aprsis = aprslib.IS(SERVER_OWNER_CALLSIGN)
self._aprsis.connect() self.status = "Connecting"
self._aprsis.consumer(self._handle) logger.info("APRS-IS connecting...")
logger.info("APRS-IS connected.") 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): def stop(self):
self.status = "Shutting down" self.status = "Shutting down"
self._aprsis.close() self._stop_event.set()
self._thread.join() if self._aprsis:
self._aprsis.close()
if self._thread:
self._thread.join(timeout=5)
if self._thread.is_alive():
logger.warning("APRS-IS worker thread did not exit on time and will be killed.")
def _handle(self, data): def _handle(self, data):
# Split SSID in "from" call and store separately try:
from_parts = str(data["from"]).split("-") # Split SSID in "from" call and store separately
dx_call = from_parts[0].upper() from_parts = str(data["from"]).split("-")
dx_ssid = from_parts[1].upper() if len(from_parts) > 1 else None dx_call = from_parts[0].upper()
via_parts = str(data["via"]).split("-") dx_ssid = from_parts[1].upper() if len(from_parts) > 1 else None
de_call = via_parts[0].upper() via_parts = str(data["via"]).split("-")
de_ssid = via_parts[1].upper() if len(via_parts) > 1 else None de_call = via_parts[0].upper()
spot = Spot( de_ssid = via_parts[1].upper() if len(via_parts) > 1 else None
source="APRS-IS", spot = Spot(
dx_call=dx_call, source="APRS-IS",
dx_ssid=dx_ssid, dx_call=dx_call,
de_call=de_call, dx_ssid=dx_ssid,
de_ssid=de_ssid, de_call=de_call,
comment=str(data["comment"]) if "comment" in data else None, de_ssid=de_ssid,
dx_latitude=float(data["latitude"]) if "latitude" in data else None, comment=str(data["comment"]) if "comment" in data else None,
dx_longitude=float(data["longitude"]) if "longitude" in data else None, dx_latitude=float(data["latitude"]) if data.get("latitude") is not None else None,
time=datetime.now(pytz.UTC).timestamp(), dx_longitude=float(data["longitude"]) if data.get("longitude") is not None else None,
) # APRS-IS spots are live so we can assume spot time is "now" time=datetime.now(pytz.UTC).timestamp(),
) # APRS-IS spots are live so we can assume spot time is "now"
# Add to our list # Add to our list
self._submit(spot) self._submit(spot)
self.status = "OK" self.status = "OK"
self.last_update_time = datetime.now(pytz.UTC) self.last_update_time = datetime.now(pytz.UTC)
logger.debug("Data received from APRS-IS.") logger.debug("Data received from APRS-IS.")
except Exception:
logger.exception("Exception handling APRS-IS packet")
+35 -19
View File
@@ -1,8 +1,8 @@
import logging import logging
import re import re
import socket
from datetime import datetime from datetime import datetime
from threading import Thread from threading import Event, Lock, Thread
from time import sleep
import pytz import pytz
import telnetlib3 import telnetlib3
@@ -41,27 +41,43 @@ class DXCluster(SpotProvider):
self._LINE_PATTERN_ALLOW_RBN if self._allow_rbn_spots else self._LINE_PATTERN_EXCLUDE_RBN self._LINE_PATTERN_ALLOW_RBN if self._allow_rbn_spots else self._LINE_PATTERN_EXCLUDE_RBN
) )
self._telnet = None self._telnet = None
self._thread = Thread(target=self._handle, name=f"DXClusterSpotProvider-{self.name}") self._telnet_lock = Lock()
self._thread.daemon = True self._thread = None
self._running = True self._stop_event = Event()
def start(self): def start(self):
self._thread = Thread(target=self._handle, name=f"DXClusterSpotProvider-{self.name}", daemon=True)
self._thread.start() self._thread.start()
def stop(self): def stop(self):
self._running = False self._stop_event.set()
if self._telnet: with self._telnet_lock:
self._telnet.close() if self._telnet:
self._thread.join() 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"DX Cluster {self._hostname} worker thread did not exit on time and will be killed.")
def _handle(self): def _handle(self):
while self._running: while not self._stop_event.is_set():
connected = False connected = False
while not connected and self._running: while not connected and not self._stop_event.is_set():
try: try:
self.status = "Connecting" self.status = "Connecting"
logger.info(f"DX Cluster {self._hostname} connecting...") logger.info(f"DX Cluster {self._hostname} connecting...")
self._telnet = telnetlib3.Telnet(self._hostname, self._port) 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.read_until(self._login_prompt.encode("latin-1"))
self._telnet.write(f"{self._login_callsign}\n".encode("latin-1")) self._telnet.write(f"{self._login_callsign}\n".encode("latin-1"))
connected = True connected = True
@@ -69,14 +85,14 @@ class DXCluster(SpotProvider):
except ConnectionRefusedError: except ConnectionRefusedError:
self.status = "Error" self.status = "Error"
logger.warning(f"Connection refused to DX cluster {self._hostname}") logger.warning(f"Connection refused to DX cluster {self._hostname}")
sleep(300) self._stop_event.wait(timeout=300)
except Exception: except Exception:
self.status = "Error" self.status = "Error"
logger.exception(f"Exception while connecting to DX Cluster Provider ({self._hostname}).") 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" self.status = "Waiting for Data"
while connected and self._running: while connected and not self._stop_event.is_set():
try: try:
# Check new telnet info against regular expression # Check new telnet info against regular expression
telnet_output = self._telnet.read_until("\n".encode("latin-1")) telnet_output = self._telnet.read_until("\n".encode("latin-1"))
@@ -106,19 +122,19 @@ class DXCluster(SpotProvider):
except EOFError: except EOFError:
connected = False connected = False
if self._running: if not self._stop_event.is_set():
self.status = "Restarting" self.status = "Restarting"
logger.warning(f"Disconnected from DX Cluster {self._hostname}. Reconnecting...") logger.warning(f"Disconnected from DX Cluster {self._hostname}. Reconnecting...")
sleep(5) self._stop_event.wait(timeout=5)
else: else:
logger.info(f"DX Cluster {self._hostname} shutting down...") logger.info(f"DX Cluster {self._hostname} shutting down...")
self.status = "Shutting down" self.status = "Shutting down"
except Exception: except Exception:
connected = False connected = False
if self._running: if not self._stop_event.is_set():
self.status = "Error" self.status = "Error"
logger.exception(f"Exception in DX Cluster Provider ({self._hostname})") logger.exception(f"Exception in DX Cluster Provider ({self._hostname})")
sleep(5) self._stop_event.wait(timeout=5)
else: else:
logger.info(f"DX Cluster {self._hostname} shutting down...") logger.info(f"DX Cluster {self._hostname} shutting down...")
self.status = "Shutting down" self.status = "Shutting down"
+11 -2
View File
@@ -4,7 +4,7 @@ from threading import Event, Thread
import pytz import pytz
import requests import requests
from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout from requests.exceptions import ConnectionError, ConnectTimeout, JSONDecodeError, ReadTimeout
from core.constants import HTTP_HEADERS from core.constants import HTTP_HEADERS
from providers.spot.spot_provider import SpotProvider from providers.spot.spot_provider import SpotProvider
@@ -28,12 +28,16 @@ class HTTPSpotProvider(SpotProvider):
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between # 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. # 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.") 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() self._thread.start()
def stop(self): def stop(self):
self._stop_event.set() self._stop_event.set()
self._wakeup_event.set() self._wakeup_event.set()
if self._thread:
self._thread.join(timeout=12)
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): def force_poll(self):
"""Trigger an immediate poll without waiting for the normal interval.""" """Trigger an immediate poll without waiting for the normal interval."""
@@ -69,9 +73,14 @@ class HTTPSpotProvider(SpotProvider):
logger.warning(f"HTTP {http_response.status_code} when calling {self.name} spot API.") logger.warning(f"HTTP {http_response.status_code} when calling {self.name} spot API.")
except ConnectionError: except ConnectionError:
self.status = "Error"
logger.warning(f"Connection error when accessing {self.name} spots API.") logger.warning(f"Connection error when accessing {self.name} spots API.")
except (ConnectTimeout, ReadTimeout): except (ConnectTimeout, ReadTimeout):
self.status = "Error"
logger.warning(f"Timeout when accessing {self.name} spots API.") logger.warning(f"Timeout when accessing {self.name} spots API.")
except JSONDecodeError:
self.status = "Error"
logger.warning(f"Invalid or empty JSON response from {self.name} spots API.")
except Exception: except Exception:
self.status = "Error" self.status = "Error"
logger.exception(f"Exception in HTTP Spot Provider ({self.name})") logger.exception(f"Exception in HTTP Spot Provider ({self.name})")
+34 -18
View File
@@ -1,8 +1,8 @@
import logging import logging
import re import re
import socket
from datetime import datetime from datetime import datetime
from threading import Thread from threading import Event, Lock, Thread
from time import sleep
import pytz import pytz
import telnetlib3 import telnetlib3
@@ -30,27 +30,43 @@ class RBN(SpotProvider):
super().__init__(name, provider_config) super().__init__(name, provider_config)
self._port = provider_config["port"] self._port = provider_config["port"]
self._telnet = None self._telnet = None
self._thread = Thread(target=self._handle, name=f"RBNSpotProvider-{self.name}") self._telnet_lock = Lock()
self._thread.daemon = True self._thread = None
self._running = True self._stop_event = Event()
def start(self): def start(self):
self._thread = Thread(target=self._handle, name=f"RBNSpotProvider-{self.name}", daemon=True)
self._thread.start() self._thread.start()
def stop(self): def stop(self):
self._running = False self._stop_event.set()
if self._telnet: with self._telnet_lock:
self._telnet.close() if self._telnet:
self._thread.join() 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"RBN (port {self._port!s}) worker thread did not exit on time and will be killed.")
def _handle(self): def _handle(self):
while self._running: while not self._stop_event.is_set():
connected = False connected = False
while not connected and self._running: while not connected and not self._stop_event.is_set():
try: try:
self.status = "Connecting" self.status = "Connecting"
logger.info(f"RBN port {self._port!s} connecting...") logger.info(f"RBN port {self._port!s} connecting...")
self._telnet = telnetlib3.Telnet("telnet.reversebeacon.net", self._port) 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.read_until("Please enter your call: ".encode("latin-1"))
self._telnet.write(f"{SERVER_OWNER_CALLSIGN}\n".encode("latin-1")) self._telnet.write(f"{SERVER_OWNER_CALLSIGN}\n".encode("latin-1"))
connected = True connected = True
@@ -58,10 +74,10 @@ class RBN(SpotProvider):
except Exception: except Exception:
self.status = "Error" self.status = "Error"
logger.exception(f"Exception while connecting to RBN (port {self._port!s}).") 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" self.status = "Waiting for Data"
while connected and self._running: while connected and not self._stop_event.is_set():
try: try:
# Check new telnet info against regular expression # Check new telnet info against regular expression
telnet_output = self._telnet.read_until("\n".encode("latin-1")) telnet_output = self._telnet.read_until("\n".encode("latin-1"))
@@ -91,19 +107,19 @@ class RBN(SpotProvider):
except EOFError: except EOFError:
connected = False connected = False
if self._running: if not self._stop_event.is_set():
self.status = "Restarting" self.status = "Restarting"
logger.warning(f"Disconnected from RBN provider (port {self._port!s}). Reconnecting...") logger.warning(f"Disconnected from RBN provider (port {self._port!s}). Reconnecting...")
sleep(5) self._stop_event.wait(timeout=5)
else: else:
logger.info(f"RBN provider (port {self._port!s}) shutting down...") logger.info(f"RBN provider (port {self._port!s}) shutting down...")
self.status = "Shutting down" self.status = "Shutting down"
except Exception: except Exception:
connected = False connected = False
if self._running: if not self._stop_event.is_set():
self.status = "Error" self.status = "Error"
logger.exception(f"Exception in RBN provider (port {self._port!s})") logger.exception(f"Exception in RBN provider (port {self._port!s})")
sleep(5) self._stop_event.wait(timeout=5)
else: else:
logger.info(f"RBN provider (port {self._port!s}) shutting down...") logger.info(f"RBN provider (port {self._port!s}) shutting down...")
self.status = "Shutting down" self.status = "Shutting down"
+1 -1
View File
@@ -42,7 +42,7 @@ class SSESpotProvider(SpotProvider):
logger.exception(f"Exception closing SSE connection for {self.name} during stop()") logger.exception(f"Exception closing SSE connection for {self.name} during stop()")
if self._thread: if self._thread:
self._thread.join(timeout=15) self._thread.join(timeout=5)
if self._thread.is_alive(): if self._thread.is_alive():
logger.warning(f"{self.name} SSE worker thread did not exit on time and will be killed.") logger.warning(f"{self.name} SSE worker thread did not exit on time and will be killed.")
+2 -2
View File
@@ -42,7 +42,7 @@ class UKPacketNet(HTTPSpotProvider):
) )
comment = ( comment = (
f"{comment} {listed_port['baud']!s} baud" f"{comment} {listed_port['baud']!s} baud"
if "baud" in listed_port and listed_port["baud"] > 0 if listed_port.get("baud") and listed_port["baud"] > 0
else comment else comment
) )
@@ -50,7 +50,7 @@ class UKPacketNet(HTTPSpotProvider):
# very hacky but a lot of node comments contain their frequency as the first or second # very hacky but a lot of node comments contain their frequency as the first or second
# word of their comment, but not in the proper data structure field. # word of their comment, but not in the proper data structure field.
freq = ( freq = (
listed_port["freq"] if "freq" in listed_port and listed_port["freq"] > 0 else None listed_port["freq"] if listed_port.get("freq") and listed_port["freq"] > 0 else None
) )
if not freq and comment: if not freq and comment:
possible_freq = comment.split(" ")[0].upper().replace("MHZ", "") possible_freq = comment.split(" ")[0].upper().replace("MHZ", "")
+25 -10
View File
@@ -1,7 +1,6 @@
import logging import logging
from datetime import datetime from datetime import datetime
from threading import Thread from threading import Event, Thread
from time import sleep
import pytz import pytz
from websocket import create_connection from websocket import create_connection
@@ -20,22 +19,24 @@ class WebsocketSpotProvider(SpotProvider):
self._url = url self._url = url
self._ws = None self._ws = None
self._thread = None self._thread = None
self._stopped = False self._stop_event = Event()
self._last_event_id = None self._last_event_id = None
def start(self): def start(self):
logger.info(f"Set up websocket connection to {self.name} spot API.") 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 = Thread(target=self._run, name=f"WebsocketSpotProvider-{self.name}")
self._thread.daemon = True self._thread.daemon = True
self._thread.start() self._thread.start()
def stop(self): def stop(self):
self._stopped = True self._stop_event.set()
if self._ws: if self._ws:
self._ws.close() self._ws.close()
if self._thread: if self._thread:
self._thread.join() self._thread.join(timeout=5)
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): def _on_open(self):
self.status = "Waiting for Data" self.status = "Waiting for Data"
@@ -44,14 +45,19 @@ class WebsocketSpotProvider(SpotProvider):
self.status = "Connecting" self.status = "Connecting"
def _run(self): def _run(self):
while not self._stopped: while not self._stop_event.is_set():
try: try:
logger.debug(f"Connecting to {self.name} spot API...") logger.debug(f"Connecting to {self.name} spot API...")
self.status = "Connecting" self.status = "Connecting"
self._ws = create_connection(self._url, header=HTTP_HEADERS) self._ws = create_connection(self._url, header=HTTP_HEADERS)
self.status = "Connected" 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: try:
new_spot = self._ws_message_to_spot(data) new_spot = self._ws_message_to_spot(data)
if new_spot: if new_spot:
@@ -69,7 +75,16 @@ class WebsocketSpotProvider(SpotProvider):
logger.exception(f"Exception in Websocket Spot Provider ({self.name})") logger.exception(f"Exception in Websocket Spot Provider ({self.name})")
else: else:
self.status = "Disconnected" 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): 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 """Convert a WS message received from the API into a spot. The exact message data (in bytes) is provided here so the
+1 -1
View File
@@ -36,7 +36,7 @@ class WWBOTA(SSESpotProvider):
dx_call=source_spot["call"].upper(), dx_call=source_spot["call"].upper(),
de_call=source_spot["spotter"].upper(), de_call=source_spot["spotter"].upper(),
freq=float(source_spot["freq"]) * 1000000, freq=float(source_spot["freq"]) * 1000000,
mode=Mode.from_name(source_spot["mode"].upper()) if "mode" in source_spot else None, mode=Mode.from_name(source_spot["mode"].upper()) if source_spot.get("mode") else None,
comment=source_spot["comment"], comment=source_spot["comment"],
sig="WWBOTA", sig="WWBOTA",
sig_refs=refs, sig_refs=refs,
@@ -29,11 +29,15 @@ class FileDownloadStaticDataProvider(StaticDataProvider):
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between # 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. # 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.") 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() self._thread.start()
def stop(self): def stop(self):
self._stop_event.set() self._stop_event.set()
if self._thread:
self._thread.join(timeout=12)
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): def _run(self):
while True: while True:
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "spothole" name = "spothole"
version = "2.1-pre" version = "2.1.3"
authors = [ authors = [
{ name = "Ian Renton", email = "ian@ianrenton.com" }, { 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__) logger = logging.getLogger(__name__)
_shutdown_in_progress = False
def shutdown(_signum=None, _frame=None): def shutdown(_signum=None, _frame=None):
"""Shutdown function""" """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...") logger.info("Stopping program...")
WEB_SERVER.stop() WEB_SERVER.stop()
TELNET_SERVER.stop() TELNET_SERVER.stop()
DATA_PROVIDERS.stop() DATA_PROVIDERS.stop()
CLEANUP_TIMER.stop() CLEANUP_TIMER.stop()
DATA_STORE.close() DATA_STORE.close()
os._exit(0) logger.info("Stopped.")
# Main function # Main function
@@ -43,8 +51,9 @@ if __name__ == "__main__":
logger.info("Starting...") logger.info("Starting...")
logger.info(f"This is Spothole version {SOFTWARE_VERSION}. This instance is run by {SERVER_OWNER_CALLSIGN}.") 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.SIGINT, shutdown)
signal.signal(signal.SIGTERM, shutdown)
# Set up data store # Set up data store
DATA_STORE.setup() DATA_STORE.setup()
@@ -59,14 +68,16 @@ if __name__ == "__main__":
status_reporter = StatusReporter(run_interval=5) status_reporter = StatusReporter(run_interval=5)
status_reporter.start() status_reporter.start()
# Set up the web server
WEB_SERVER.setup()
# Run the telnet server # Run the telnet server
if TELNET_SERVER_ENABLED: if TELNET_SERVER_ENABLED:
TELNET_SERVER.start(port=TELNET_SERVER_PORT) 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 # Set up the web server
# be the last thing we do. web_server.stop() triggers an await condition in the web server which finishes the main WEB_SERVER.setup()
# thread.
# Run the web server
WEB_SERVER.start() 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 - WAB
- WAI - WAI
- DME - DME
- DMF
- FEA - FEA
- DMUE
- DMVE
- DCE
- DEFE
- DTMBA - DTMBA
- BIWOTA - BIWOTA
- COTA - COTA
+2 -2
View File
@@ -414,8 +414,8 @@ div.band-spot:hover span.band-spot-info {
/* Make map stretch to horizontal screen edges */ /* Make map stretch to horizontal screen edges */
div#map, div#table-container, div#bands-container { div#map, div#table-container, div#bands-container {
margin-left: -1em; margin-left: -0.75rem;
margin-right: -1em; margin-right: -0.75rem;
} }
/* Avoid map page filters panel being larger than the map itself */ /* Avoid map page filters panel being larger than the map itself */
+58 -13
View File
@@ -4,6 +4,7 @@ import threading
from datetime import datetime from datetime import datetime
import pytz import pytz
from pyhamtools import callinfo
from core.config import SERVER_OWNER_CALLSIGN from core.config import SERVER_OWNER_CALLSIGN
from core.constants import SOFTWARE_VERSION from core.constants import SOFTWARE_VERSION
@@ -12,6 +13,33 @@ from data.spot import Spot
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
BANNER = (
"""
==
==== ##### ##### ####
== == ..### ..### ..###
== ###==######## ###### ####### .####### ###### .### ######
==###.. ..###..### ###..###...###. .###..### ###..### .### ###..###
==###.### .### .###.### .### .### .### .### .### .### .### .#######
== .###.###.### .###.### .### .### ### .### .### .### .### .### .###...
== ...### .####### ..###### ..##### #### #####..###### #####..######
== .###. .###=.. ...... ..... .... ..... ...... ..... ......
== %% ... %%%%###==
== %%%%%%%%%%%#####== \r\n"""
+ "== ..... =="
+ f"Welcome to Spothole v{SOFTWARE_VERSION}".rjust(56)
+ "\r\n"
+ "========================"
+ f"This server is run by {SERVER_OWNER_CALLSIGN}".rjust(56)
)
MOTD = (
"Spothole's telnet server is a new feature and may not work properly in all\r\n"
+ "loggers. Please give it a try in your logger of choice and let me know if it\r\n"
+ "(or doesn't!) Please note that DXSpider-like commands are not yet supported\r\n"
+ "so you are not yet able to filter spots server-side or log in at all."
)
class TelnetServer: class TelnetServer:
"""A telnet server designed to provide spots in the same format as DXSpider, for compatibility with desktop loggers.""" """A telnet server designed to provide spots in the same format as DXSpider, for compatibility with desktop loggers."""
@@ -21,6 +49,7 @@ class TelnetServer:
self._running = False self._running = False
self._clients = set() self._clients = set()
self._loop = None self._loop = None
self._thread = None
self._shutdown_event = asyncio.Event() self._shutdown_event = asyncio.Event()
def start(self, port=7373): def start(self, port=7373):
@@ -30,8 +59,10 @@ class TelnetServer:
# Start the telnet server. asyncio.run() needs a coroutine, and threading.Thread needs a plain callable, so # 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. # 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) self._thread = threading.Thread(
t.start() target=asyncio.run, args=(self._start_internal(),), name="TelnetServer", daemon=True
)
self._thread.start()
logger.debug("Telnet server background thread spawned") 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 # Listen for new spots and alerts being added to the cache, so we can notify SSE clients immediately
@@ -56,15 +87,22 @@ class TelnetServer:
logger.debug("Telnet client connected") logger.debug("Telnet client connected")
self._clients.add(writer) self._clients.add(writer)
# Print MOTD # Print banner and MOTD
try: try:
motd = ( text = (
f"Welcome to Spothole v{SOFTWARE_VERSION}.\r\nThis server is run by {SERVER_OWNER_CALLSIGN}.\r\n===\r\n" BANNER
+ "\r\n\r\n"
+ "================================================================================"
+ "\r\n"
+ MOTD
+ "\r\n"
+ "================================================================================"
+ "\r\n\r\n"
) )
writer.write(motd.encode("ascii")) writer.write(text.encode("ascii"))
await writer.drain() await writer.drain()
except Exception: except Exception:
pass logger.exception("Exception printing telnet motd")
# Set up buffer for user input # Set up buffer for user input
input_buffer = "" input_buffer = ""
@@ -100,6 +138,10 @@ class TelnetServer:
if self._loop and self._loop.is_running(): if self._loop and self._loop.is_running():
logger.debug("Stopping telnet server...") logger.debug("Stopping telnet server...")
self._loop.call_soon_threadsafe(self._shutdown_event.set) 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 @property
def client_count(self) -> int: def client_count(self) -> int:
@@ -128,9 +170,10 @@ class TelnetServer:
# Ensure ASCII formatting for telnet clients # Ensure ASCII formatting for telnet clients
encoded_line = self._format_dxspider_spot(spot).encode("ascii", errors="ignore") 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() disconnected_clients = set()
for writer in self._clients: for writer in list(self._clients):
try: try:
writer.write(encoded_line) writer.write(encoded_line)
await writer.drain() await writer.drain()
@@ -163,12 +206,14 @@ class TelnetServer:
@staticmethod @staticmethod
def _format_dxspider_spot(spot: Spot) -> str: def _format_dxspider_spot(spot: Spot) -> str:
"""Formats a spot into the format DXspider uses: """Formats a spot into the format DXspider uses:
DX de CALLSIGN: FREQUENCY DX_CALLSIGN COMMENTS TIME_UTC""" DX de CALLSIGN: FREQUENCY DX_CALLSIGN COMMENTS TIME_UTC.
Always use the base call for the spotter to save space. Everything must align properly to parse in clients,
and everything must be renderable in ASCII."""
de_call = f"{spot.de_call + ':' if spot.de_call else '???:'!s:<9}" de_call = f"{callinfo.Callinfo.get_homecall(spot.de_call)[:6] + ':' if spot.de_call else '???:'!s:<7}"
frequency = f"{(spot.freq / 1000.0):8.1f}" frequency = f"{(spot.freq / 1000.0):10.1f}"
dx_call = f"{spot.dx_call!s:<12}" dx_call = f"{spot.dx_call!s:<12}"
comment = f"{spot.comment[:29]:<30}" comment = f"{spot.comment.encode('ascii', errors='ignore').decode()[:29]:<30}"
if spot.time: if spot.time:
timestamp = datetime.fromtimestamp(spot.time, tz=pytz.utc).strftime("%H%M") + "Z" timestamp = datetime.fromtimestamp(spot.time, tz=pytz.utc).strftime("%H%M") + "Z"
else: else:
+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 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 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), 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 <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 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> of one I've missed, please let me know!</p>
+1 -1
View File
@@ -77,7 +77,7 @@
</div> </div>
<script src="/static/js/add-spot.js?v=1789162324"></script> <script src="/static/js/add-spot.js?v=1789894090"></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
@@ -83,7 +83,7 @@
</div> </div>
<script src="/static/js/alerts.js?v=1789162324"></script> <script src="/static/js/alerts.js?v=1789894090"></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=1789162324"></script> <script src="/static/js/spotsbandsandmap.js?v=1789894090"></script>
<script src="/static/js/bands.js?v=1789162324"></script> <script src="/static/js/bands.js?v=1789894090"></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=1789162324" type="text/css"> <link rel="stylesheet" href="/static/css/style.css?v=1789894090" 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=1789162324"></script> <script src="/static/js/utils.js?v=1789894090"></script>
<script src="/static/js/ui-ham.js?v=1789162324"></script> <script src="/static/js/ui-ham.js?v=1789894090"></script>
<script src="/static/js/geo.js?v=1789162324"></script> <script src="/static/js/geo.js?v=1789894090"></script>
<script src="/static/js/common.js?v=1789162324"></script> <script src="/static/js/common.js?v=1789894090"></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=1789162324"></script> <script src="/static/js/conditions.js?v=1789894090"></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=1789162323"></script> <script src="/static/js/spotsbandsandmap.js?v=1789894090"></script>
<script src="/static/js/map.js?v=1789162323"></script> <script src="/static/js/map.js?v=1789894090"></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=1789162323"></script> <script src="/static/js/spotsbandsandmap.js?v=1789894090"></script>
<script src="/static/js/spots.js?v=1789162323"></script> <script src="/static/js/spots.js?v=1789894090"></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=1789162324"></script> <script src="/static/js/status.js?v=1789894090"></script>
<script> <script>
$(document).ready(function () { $(document).ready(function () {
$("#nav-link-status").addClass("active"); $("#nav-link-status").addClass("active");
+1 -1
View File
@@ -34,7 +34,7 @@ class V1RedirectHandler(tornado.web.RequestHandler):
response = await client.fetch( response = await client.fetch(
new_url, new_url,
method=self.request.method, method=self.request.method,
headers=self.request.headers, headers=headers,
body=None if self.request.method == "GET" else (self.request.body or b""), body=None if self.request.method == "GET" else (self.request.body or b""),
raise_error=False, raise_error=False,
follow_redirects=False, follow_redirects=False,
+23 -2
View File
@@ -1,6 +1,7 @@
import asyncio import asyncio
import logging import logging
import os import os
import threading
import tornado import tornado
from tornado.web import StaticFileHandler from tornado.web import StaticFileHandler
@@ -53,6 +54,8 @@ class WebServer:
self._port = WEB_SERVER_PORT self._port = WEB_SERVER_PORT
self._api_only_mode = API_ONLY_MODE self._api_only_mode = API_ONLY_MODE
self._shutdown_event = asyncio.Event() self._shutdown_event = asyncio.Event()
self._loop = None
self._thread = None
self.web_server_metrics = WebServerMetrics() self.web_server_metrics = WebServerMetrics()
def setup(self): def setup(self):
@@ -69,16 +72,34 @@ class WebServer:
def start(self): def start(self):
"""Start the web server""" """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): def stop(self):
"""Stop the web server""" """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): async def _start_inner(self):
"""Start method (async). Sets up the Tornado application.""" """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 # Bind the SSE broadcasters to the web server's loop, so they fire correctly
self._spot_broadcaster.bind_to_web_server_loop() self._spot_broadcaster.bind_to_web_server_loop()
self._alert_broadcaster.bind_to_web_server_loop() self._alert_broadcaster.bind_to_web_server_loop()