Compare commits

...
9 Commits
Author SHA1 Message Date
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
33 changed files with 116 additions and 65 deletions
+1 -1
View File
@@ -35,7 +35,7 @@ class CleanupTimer:
self._stop_event.set()
if self._thread:
self._thread.join(timeout=15)
self._thread.join(timeout=5)
if self._thread.is_alive():
logger.warning("Cleanup worker thread did not exit on time and will be killed.")
+1 -1
View File
@@ -4,7 +4,7 @@ from data.band import Band
from data.sig import SIG
# General software
SOFTWARE_VERSION = "2.1"
SOFTWARE_VERSION = "2.1.2"
# HTTP headers used for spot providers that use HTTP
HTTP_HEADERS = {"User-Agent": f"Spothole v{SOFTWARE_VERSION} (operated by {SERVER_OWNER_CALLSIGN})"}
+1 -1
View File
@@ -91,7 +91,7 @@ class DataProviders:
for t in threads:
t.start()
deadline = time.monotonic() + 40
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()]
+5
View File
@@ -77,7 +77,12 @@ class LiveDataCache:
logger.exception(f"Failed to write snapshot to {self._snapshot_dir}")
def _load_snapshot(self):
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:
return
+6
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
if "object" not in self._cache:
self._cache.add("object", object_if_empty)
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):
"""Get the data object. This can then be manipulated as necessary across multiple threads. Any function
+7 -2
View File
@@ -4,7 +4,7 @@ from threading import Event, Thread
import pytz
import requests
from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout
from requests.exceptions import ConnectionError, ConnectTimeout, JSONDecodeError, ReadTimeout
from core.constants import HTTP_HEADERS
from providers.alert.alert_provider import AlertProvider
@@ -33,7 +33,7 @@ class HTTPAlertProvider(AlertProvider):
def stop(self):
self._stop_event.set()
if self._thread:
self._thread.join(timeout=35)
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.")
@@ -64,9 +64,14 @@ class HTTPAlertProvider(AlertProvider):
logger.warning(f"HTTP {http_response.status_code} when calling {self.name} alerts API.")
except ConnectionError:
self.status = "Error"
logger.warning(f"Connection error when accessing {self.name} alerts API.")
except (ConnectTimeout, ReadTimeout):
self.status = "Error"
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:
self.status = "Error"
logger.exception(f"Exception in HTTP JSON Alert Provider ({self.name})")
@@ -38,7 +38,7 @@ class FileDownloadCallsignDataProvider(CallsignDataProvider):
def stop(self):
self._stop_event.set()
if self._thread:
self._thread.join(timeout=35)
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.")
+6 -6
View File
@@ -124,8 +124,8 @@ class HamQTH(APIQueryCallsignDataProvider):
lat = None
lon = None
if (
"latitude" in data
and "longitude" in data
data.get("latitude") is not None
and data.get("longitude") is not None
and (float(data["latitude"]) != 0 or float(data["longitude"]) != 0)
and -89.9 < float(data["latitude"]) < 89.9
):
@@ -134,7 +134,7 @@ class HamQTH(APIQueryCallsignDataProvider):
# Check for sensible grids
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"]
return Callsign(
@@ -147,8 +147,8 @@ class HamQTH(APIQueryCallsignDataProvider):
latitude=lat,
longitude=lon,
grid=grid,
dxcc_id=int(data["adif"]) if "adif" in data else None,
cq_zone=int(data["cq"]) if "cq" in data else None,
itu_zone=int(data["itu"]) if "itu" in data else None,
dxcc_id=int(data["adif"]) if data.get("adif") is not None else None,
cq_zone=int(data["cq"]) if data.get("cq") is not None else None,
itu_zone=int(data["itu"]) if data.get("itu") is not None else None,
location_source=LocationSourceForCallsign.HOME_QTH,
)
+6 -6
View File
@@ -150,8 +150,8 @@ class QRZ(APIQueryCallsignDataProvider):
lat = None
lon = None
if (
"latitude" in data
and "longitude" in data
data.get("latitude") is not None
and data.get("longitude") is not None
and (float(data["latitude"]) != 0 or float(data["longitude"]) != 0)
and -89.9 < float(data["latitude"]) < 89.9
):
@@ -160,7 +160,7 @@ class QRZ(APIQueryCallsignDataProvider):
# Check for sensible grids
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"]
return Callsign(
@@ -173,8 +173,8 @@ class QRZ(APIQueryCallsignDataProvider):
latitude=lat,
longitude=lon,
grid=grid,
dxcc_id=int(data["adif"]) if "adif" in data else None,
cq_zone=int(data["cqzone"]) if "cqzone" in data else None,
itu_zone=int(data["ituzone"]) if "ituzone" in data else None,
dxcc_id=int(data["adif"]) if data.get("adif") is not None else None,
cq_zone=int(data["cqzone"]) if data.get("cqzone") is not None else None,
itu_zone=int(data["ituzone"]) if data.get("ituzone") is not None else None,
location_source=LocationSourceForCallsign.HOME_QTH,
)
@@ -33,7 +33,7 @@ class FileDownloadSIGRefDataProvider(SIGRefDataProvider):
def stop(self):
super().stop()
if self._thread:
self._thread.join(timeout=35)
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.")
@@ -36,8 +36,10 @@ class SIGRefDataProvider:
def _add_data(self, new_data):
"""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 DATA_STORE.sigrefs.transact():
# with transact() batches all writes together to save making thousands of individual sqlite writes. However,
# 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:
DATA_STORE.sigrefs.set(f"{self.sig_name}:{d.id}", d)
+1 -1
View File
@@ -73,7 +73,7 @@ class GIROIonosonde(SolarConditionsProvider):
def stop(self):
self._stop_event.set()
if self._thread:
self._thread.join(timeout=35)
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.")
@@ -31,7 +31,7 @@ class HTTPSolarConditionsProvider(SolarConditionsProvider):
def stop(self):
self._stop_event.set()
if self._thread:
self._thread.join(timeout=35)
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.")
+1 -1
View File
@@ -38,7 +38,7 @@ class KC2GProp(SolarConditionsProvider):
def stop(self):
self._stop_event.set()
if self._thread:
self._thread.join(timeout=35)
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.")
+3 -3
View File
@@ -49,7 +49,7 @@ class APRSIS(SpotProvider):
if self._aprsis:
self._aprsis.close()
if self._thread:
self._thread.join(timeout=15)
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.")
@@ -69,8 +69,8 @@ class APRSIS(SpotProvider):
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,
dx_latitude=float(data["latitude"]) if data.get("latitude") is not None else None,
dx_longitude=float(data["longitude"]) if data.get("longitude") is not None else None,
time=datetime.now(pytz.UTC).timestamp(),
) # APRS-IS spots are live so we can assume spot time is "now"
+17 -3
View File
@@ -1,7 +1,8 @@
import logging
import re
import socket
from datetime import datetime
from threading import Event, Thread
from threading import Event, Lock, Thread
import pytz
import telnetlib3
@@ -40,6 +41,7 @@ class DXCluster(SpotProvider):
self._LINE_PATTERN_ALLOW_RBN if self._allow_rbn_spots else self._LINE_PATTERN_EXCLUDE_RBN
)
self._telnet = None
self._telnet_lock = Lock()
self._thread = None
self._stop_event = Event()
@@ -49,10 +51,15 @@ class DXCluster(SpotProvider):
def stop(self):
self._stop_event.set()
with self._telnet_lock:
if self._telnet:
try:
self._telnet.sock.shutdown(socket.SHUT_RDWR)
except (AttributeError, OSError):
pass
self._telnet.close()
if self._thread:
self._thread.join(timeout=15)
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.")
@@ -63,7 +70,14 @@ class DXCluster(SpotProvider):
try:
self.status = "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.write(f"{self._login_callsign}\n".encode("latin-1"))
connected = True
+7 -2
View File
@@ -4,7 +4,7 @@ from threading import Event, Thread
import pytz
import requests
from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout
from requests.exceptions import ConnectionError, ConnectTimeout, JSONDecodeError, ReadTimeout
from core.constants import HTTP_HEADERS
from providers.spot.spot_provider import SpotProvider
@@ -35,7 +35,7 @@ class HTTPSpotProvider(SpotProvider):
self._stop_event.set()
self._wakeup_event.set()
if self._thread:
self._thread.join(timeout=35)
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.")
@@ -73,9 +73,14 @@ class HTTPSpotProvider(SpotProvider):
logger.warning(f"HTTP {http_response.status_code} when calling {self.name} spot API.")
except ConnectionError:
self.status = "Error"
logger.warning(f"Connection error when accessing {self.name} spots API.")
except (ConnectTimeout, ReadTimeout):
self.status = "Error"
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:
self.status = "Error"
logger.exception(f"Exception in HTTP Spot Provider ({self.name})")
+17 -3
View File
@@ -1,7 +1,8 @@
import logging
import re
import socket
from datetime import datetime
from threading import Event, Thread
from threading import Event, Lock, Thread
import pytz
import telnetlib3
@@ -29,6 +30,7 @@ class RBN(SpotProvider):
super().__init__(name, provider_config)
self._port = provider_config["port"]
self._telnet = None
self._telnet_lock = Lock()
self._thread = None
self._stop_event = Event()
@@ -38,10 +40,15 @@ class RBN(SpotProvider):
def stop(self):
self._stop_event.set()
with self._telnet_lock:
if self._telnet:
try:
self._telnet.sock.shutdown(socket.SHUT_RDWR)
except (AttributeError, OSError):
pass
self._telnet.close()
if self._thread:
self._thread.join(timeout=15)
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.")
@@ -52,7 +59,14 @@ class RBN(SpotProvider):
try:
self.status = "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.write(f"{SERVER_OWNER_CALLSIGN}\n".encode("latin-1"))
connected = True
+1 -1
View File
@@ -42,7 +42,7 @@ class SSESpotProvider(SpotProvider):
logger.exception(f"Exception closing SSE connection for {self.name} during stop()")
if self._thread:
self._thread.join(timeout=15)
self._thread.join(timeout=5)
if self._thread.is_alive():
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 = (
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
)
@@ -50,7 +50,7 @@ class UKPacketNet(HTTPSpotProvider):
# 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.
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:
possible_freq = comment.split(" ")[0].upper().replace("MHZ", "")
+1 -1
View File
@@ -34,7 +34,7 @@ class WebsocketSpotProvider(SpotProvider):
if self._ws:
self._ws.close()
if self._thread:
self._thread.join(timeout=15)
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.")
+1 -1
View File
@@ -36,7 +36,7 @@ class WWBOTA(SSESpotProvider):
dx_call=source_spot["call"].upper(),
de_call=source_spot["spotter"].upper(),
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"],
sig="WWBOTA",
sig_refs=refs,
@@ -35,7 +35,7 @@ class FileDownloadStaticDataProvider(StaticDataProvider):
def stop(self):
self._stop_event.set()
if self._thread:
self._thread.join(timeout=35)
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.")
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "spothole"
version = "2.1"
version = "2.1.2"
authors = [
{ name = "Ian Renton", email = "ian@ianrenton.com" },
]
+2 -2
View File
@@ -414,8 +414,8 @@ div.band-spot:hover span.band-spot-info {
/* Make map stretch to horizontal screen edges */
div#map, div#table-container, div#bands-container {
margin-left: -1em;
margin-right: -1em;
margin-left: -0.75rem;
margin-right: -0.75rem;
}
/* Avoid map page filters panel being larger than the map itself */
+1 -1
View File
@@ -77,7 +77,7 @@
</div>
<script src="/static/js/add-spot.js?v=1789731849"></script>
<script src="/static/js/add-spot.js?v=1789826851"></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=1789731849"></script>
<script src="/static/js/alerts.js?v=1789826852"></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=1789731849"></script>
<script src="/static/js/bands.js?v=1789731849"></script>
<script src="/static/js/spotsbandsandmap.js?v=1789826851"></script>
<script src="/static/js/bands.js?v=1789826851"></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=1789731849" type="text/css">
<link rel="stylesheet" href="/static/css/style.css?v=1789826851" 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=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>
<script src="/static/js/utils.js?v=1789826851"></script>
<script src="/static/js/ui-ham.js?v=1789826851"></script>
<script src="/static/js/geo.js?v=1789826851"></script>
<script src="/static/js/common.js?v=1789826851"></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=1789731849"></script>
<script src="/static/js/conditions.js?v=1789826851"></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=1789731849"></script>
<script src="/static/js/map.js?v=1789731849"></script>
<script src="/static/js/spotsbandsandmap.js?v=1789826852"></script>
<script src="/static/js/map.js?v=1789826852"></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=1789731849"></script>
<script src="/static/js/spots.js?v=1789731849"></script>
<script src="/static/js/spotsbandsandmap.js?v=1789826851"></script>
<script src="/static/js/spots.js?v=1789826851"></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=1789731849"></script>
<script src="/static/js/status.js?v=1789826851"></script>
<script>
$(document).ready(function () {
$("#nav-link-status").addClass("active");