diff --git a/alertproviders/http_alert_provider.py b/alertproviders/http_alert_provider.py index afc3225..0f2165b 100644 --- a/alertproviders/http_alert_provider.py +++ b/alertproviders/http_alert_provider.py @@ -1,7 +1,6 @@ import logging from datetime import datetime -from threading import Timer, Thread -from time import sleep +from threading import Thread, Event import pytz import requests @@ -18,22 +17,25 @@ class HTTPAlertProvider(AlertProvider): super().__init__(provider_config) self.url = url self.poll_interval = poll_interval - self.poll_timer = None + self._stop_event = Event() def start(self): - # Fire off a one-shot thread to run poll() for the first time, just to ensure start() returns immediately and - # the application can continue starting. The thread itself will then die, and the timer will kick in on its own - # thread. + # 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. logging.info("Set up query of " + self.name + " alert API every " + str(self.poll_interval) + " seconds.") - thread = Thread(target=self.poll) - thread.daemon = True - thread.start() + self._thread = Thread(target=self._run, daemon=True) + self._thread.start() def stop(self): - if self.poll_timer: - self.poll_timer.cancel() + self._stop_event.set() - def poll(self): + def _run(self): + while True: + self._poll() + if self._stop_event.wait(timeout=self.poll_interval): + break + + def _poll(self): try: # Request data from API logging.debug("Polling " + self.name + " alert API...") @@ -51,10 +53,8 @@ class HTTPAlertProvider(AlertProvider): except Exception as e: self.status = "Error" logging.exception("Exception in HTTP JSON Alert Provider (" + self.name + ")") - sleep(1) - - self.poll_timer = Timer(self.poll_interval, self.poll) - self.poll_timer.start() + # Brief pause on error before the next poll, but still respond promptly to stop() + self._stop_event.wait(timeout=1) # Convert an HTTP response returned by the API into alert data. The whole response is provided here so the subclass # implementations can check for HTTP status codes if necessary, and handle the response as JSON, XML, text, whatever diff --git a/core/cleanup.py b/core/cleanup.py index ecbf1bb..c916041 100644 --- a/core/cleanup.py +++ b/core/cleanup.py @@ -1,6 +1,6 @@ import logging from datetime import datetime -from threading import Timer +from threading import Timer, Event, Thread from time import sleep import pytz @@ -18,17 +18,23 @@ class CleanupTimer: self.cleanup_timer = None self.last_cleanup_time = datetime.min.replace(tzinfo=pytz.UTC) self.status = "Starting" + self._stop_event = Event() # Start the cleanup timer def start(self): - self.cleanup() + self._thread = Thread(target=self._run, daemon=True) + self._thread.start() # Stop any threads and prepare for application shutdown def stop(self): - self.cleanup_timer.cancel() + self._stop_event.set() + + def _run(self): + while not self._stop_event.wait(timeout=self.cleanup_interval): + self._cleanup() # Perform cleanup and reschedule next timer - def cleanup(self): + def _cleanup(self): try: # Perform cleanup via letting the data expire self.spots.expire() @@ -61,7 +67,4 @@ class CleanupTimer: except Exception as e: self.status = "Error" logging.exception("Exception in Cleanup thread") - sleep(1) - - self.cleanup_timer = Timer(self.cleanup_interval, self.cleanup) - self.cleanup_timer.start() + self._stop_event.wait(timeout=1) diff --git a/core/lookup_helper.py b/core/lookup_helper.py index b14090f..7840c09 100644 --- a/core/lookup_helper.py +++ b/core/lookup_helper.py @@ -101,6 +101,10 @@ class LookupHelper: else: logging.error("Could not download DXCC data, flags and similar data may be missing!") + # Precompile regex matches for DXCCs to improve efficiency when iterating through them + for dxcc in self.DXCC_DATA.values(): + dxcc["_prefixRegexCompiled"] = re.compile(dxcc["prefixRegex"]) + # Download the cty.plist file from country-files.com on first startup. The pyhamtools lib can actually download and use # this itself, but it's occasionally offline which causes it to throw an error. By downloading it separately, we can # catch errors and handle them, falling back to a previous copy of the file in the cache, and we can use the @@ -559,7 +563,7 @@ class LookupHelper: # Utility method to get generic DXCC data from our lookup table, if we can find it def get_dxcc_data_for_callsign(self, call): for entry in self.DXCC_DATA.values(): - if re.match(entry["prefixRegex"], call): + if entry["_prefixRegexCompiled"].match(call): return entry return None diff --git a/core/status_reporter.py b/core/status_reporter.py index 47cedf9..a664d00 100644 --- a/core/status_reporter.py +++ b/core/status_reporter.py @@ -1,6 +1,6 @@ import os from datetime import datetime -from threading import Timer +from threading import Thread, Event import psutil import pytz @@ -24,22 +24,30 @@ class StatusReporter: self.spot_providers = spot_providers self.alerts = alerts self.alert_providers = alert_providers - self.run_timer = None + self._stop_event = Event() self.startup_time = datetime.now(pytz.UTC) self.status_data["software-version"] = SOFTWARE_VERSION self.status_data["server-owner-callsign"] = SERVER_OWNER_CALLSIGN - # Start the cleanup timer + # Start the reporter thread def start(self): - self.run() + self._thread = Thread(target=self._run, daemon=True) + self._thread.start() # Stop any threads and prepare for application shutdown def stop(self): - self.run_timer.cancel() + self._stop_event.set() - # Write status information and reschedule next timer - def run(self): + # Thread entry point: report immediately on startup, then on each interval until stopped + def _run(self): + while True: + self._report() + if self._stop_event.wait(timeout=self.run_interval): + break + + # Write status information + def _report(self): self.status_data["uptime"] = (datetime.now(pytz.UTC) - self.startup_time).total_seconds() self.status_data["mem_use_mb"] = round(psutil.Process(os.getpid()).memory_info().rss / (1024 * 1024), 3) self.status_data["num_spots"] = len(self.spots) @@ -73,7 +81,4 @@ class StatusReporter: # Update Prometheus metrics memory_use_gauge.set(psutil.Process(os.getpid()).memory_info().rss * 1024) spots_gauge.set(len(self.spots)) - alerts_gauge.set(len(self.alerts)) - - self.run_timer = Timer(self.run_interval, self.run) - self.run_timer.start() + alerts_gauge.set(len(self.alerts)) \ No newline at end of file diff --git a/core/utils.py b/core/utils.py index 39e6d1a..504784d 100644 --- a/core/utils.py +++ b/core/utils.py @@ -3,3 +3,12 @@ # to receive spots without complex handling. def serialize_everything(obj): return obj.__dict__ + + +# Empty a queue +def empty_queue(q): + while not q.empty(): + try: + q.get_nowait() + except: + break \ No newline at end of file diff --git a/server/handlers/api/alerts.py b/server/handlers/api/alerts.py index 8e53bc1..34307cd 100644 --- a/server/handlers/api/alerts.py +++ b/server/handlers/api/alerts.py @@ -8,7 +8,7 @@ import tornado import tornado_eventsource.handler from core.prometheus_metrics_handler import api_requests_counter -from core.utils import serialize_everything +from core.utils import serialize_everything, empty_queue SSE_HANDLER_MAX_QUEUE_SIZE = 100 SSE_HANDLER_QUEUE_CHECK_INTERVAL = 5000 @@ -86,7 +86,11 @@ class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler): try: if self.alert_queue in self.sse_alert_queues: self.sse_alert_queues.remove(self.alert_queue) - self.alert_queue.empty() + empty_queue(self.alert_queue) + except: + pass + try: + self.heartbeat.stop() except: pass self.alert_queue = None diff --git a/server/handlers/api/spots.py b/server/handlers/api/spots.py index 209f517..c75320c 100644 --- a/server/handlers/api/spots.py +++ b/server/handlers/api/spots.py @@ -8,7 +8,7 @@ import tornado import tornado_eventsource.handler from core.prometheus_metrics_handler import api_requests_counter -from core.utils import serialize_everything +from core.utils import serialize_everything, empty_queue SSE_HANDLER_MAX_QUEUE_SIZE = 1000 SSE_HANDLER_QUEUE_CHECK_INTERVAL = 5000 @@ -88,7 +88,11 @@ class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler): try: if self.spot_queue in self.sse_spot_queues: self.sse_spot_queues.remove(self.spot_queue) - self.spot_queue.empty() + empty_queue(self.spot_queue) + except: + pass + try: + self.heartbeat.stop() except: pass self.spot_queue = None diff --git a/server/webserver.py b/server/webserver.py index 1eb4ce5..e98956e 100644 --- a/server/webserver.py +++ b/server/webserver.py @@ -5,6 +5,7 @@ import os import tornado from tornado.web import StaticFileHandler +from core.utils import empty_queue from server.handlers.api.addspot import APISpotHandler from server.handlers.api.alerts import APIAlertsHandler, APIAlertsStreamHandler from server.handlers.api.lookups import APILookupCallHandler, APILookupSIGRefHandler, APILookupGridHandler @@ -105,7 +106,7 @@ class WebServer: if q.full(): logging.warn("A full SSE spot queue was found, presumably because the client disconnected strangely. It has been removed.") self.sse_spot_queues.remove(q) - q.empty() + empty_queue(q) except: # Probably got deleted already on another thread pass @@ -114,7 +115,7 @@ class WebServer: if q.full(): logging.warn("A full SSE alert queue was found, presumably because the client disconnected strangely. It has been removed.") self.sse_alert_queues.remove(q) - q.empty() + empty_queue(q) except: # Probably got deleted already on another thread pass diff --git a/spotproviders/http_spot_provider.py b/spotproviders/http_spot_provider.py index 1a3b704..853db65 100644 --- a/spotproviders/http_spot_provider.py +++ b/spotproviders/http_spot_provider.py @@ -1,7 +1,6 @@ import logging from datetime import datetime -from threading import Timer, Thread -from time import sleep +from threading import Thread, Event import pytz import requests @@ -18,22 +17,25 @@ class HTTPSpotProvider(SpotProvider): super().__init__(provider_config) self.url = url self.poll_interval = poll_interval - self.poll_timer = None + self._stop_event = Event() def start(self): - # Fire off a one-shot thread to run poll() for the first time, just to ensure start() returns immediately and - # the application can continue starting. The thread itself will then die, and the timer will kick in on its own - # thread. + # 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. logging.info("Set up query of " + self.name + " spot API every " + str(self.poll_interval) + " seconds.") - thread = Thread(target=self.poll) - thread.daemon = True - thread.start() + self._thread = Thread(target=self._run, daemon=True) + self._thread.start() def stop(self): - if self.poll_timer: - self.poll_timer.cancel() + self._stop_event.set() - def poll(self): + def _run(self): + while True: + self._poll() + if self._stop_event.wait(timeout=self.poll_interval): + break + + def _poll(self): try: # Request data from API logging.debug("Polling " + self.name + " spot API...") @@ -51,10 +53,7 @@ class HTTPSpotProvider(SpotProvider): except Exception as e: self.status = "Error" logging.exception("Exception in HTTP JSON Spot Provider (" + self.name + ")") - sleep(1) - - self.poll_timer = Timer(self.poll_interval, self.poll) - self.poll_timer.start() + self._stop_event.wait(timeout=1) # Convert an HTTP response returned by the API into spot data. The whole response is provided here so the subclass # implementations can check for HTTP status codes if necessary, and handle the response as JSON, XML, text, whatever diff --git a/templates/about.html b/templates/about.html index e6b2af0..7f8cd6a 100644 --- a/templates/about.html +++ b/templates/about.html @@ -66,7 +66,7 @@
This software is dedicated to the memory of Tom G1PJB, SK, a friend and colleague who sadly passed away around the time I started writing it in Autumn 2025. I was looking forward to showing it to you when it was done.
- + {% end %} \ No newline at end of file diff --git a/templates/add_spot.html b/templates/add_spot.html index d23de63..a43e723 100644 --- a/templates/add_spot.html +++ b/templates/add_spot.html @@ -69,8 +69,8 @@ - - + + {% end %} \ No newline at end of file diff --git a/templates/alerts.html b/templates/alerts.html index b6d2202..27b132d 100644 --- a/templates/alerts.html +++ b/templates/alerts.html @@ -56,8 +56,8 @@ - - + + {% end %} \ No newline at end of file diff --git a/templates/bands.html b/templates/bands.html index 3e02b88..8a8789b 100644 --- a/templates/bands.html +++ b/templates/bands.html @@ -62,9 +62,9 @@ - - - + + + {% end %} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html index 1f0fe94..0abd067 100644 --- a/templates/base.html +++ b/templates/base.html @@ -46,10 +46,10 @@ crossorigin="anonymous"> - - - - + + + + diff --git a/templates/map.html b/templates/map.html index 51d8531..8865df9 100644 --- a/templates/map.html +++ b/templates/map.html @@ -70,9 +70,9 @@ - - - + + + {% end %} \ No newline at end of file diff --git a/templates/spots.html b/templates/spots.html index a232f16..7895aaf 100644 --- a/templates/spots.html +++ b/templates/spots.html @@ -87,9 +87,9 @@ - - - + + + {% end %} \ No newline at end of file diff --git a/templates/status.html b/templates/status.html index ba99d59..8863f4e 100644 --- a/templates/status.html +++ b/templates/status.html @@ -3,8 +3,8 @@ - - + + {% end %} \ No newline at end of file diff --git a/webassets/.idea/.gitignore b/webassets/.idea/.gitignore deleted file mode 100644 index ab1f416..0000000 --- a/webassets/.idea/.gitignore +++ /dev/null @@ -1,10 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Ignored default folder with query files -/queries/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml -# Editor-based HTTP Client requests -/httpRequests/ diff --git a/webassets/.idea/vcs.xml b/webassets/.idea/vcs.xml deleted file mode 100644 index 6c0b863..0000000 --- a/webassets/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - -