Author SHA1 Message Date
Ian Renton ba08555a30 Improve error reporting 2026-07-25 09:33:18 +01:00
Ian Renton 374a326874 Improve error reporting 2026-07-25 09:02:57 +01:00
Ian Renton c397009ada Improve error reporting 2026-07-25 08:53:55 +01:00
Ian Renton 66010dc682 Improve error reporting consistency and ensure SEMI_STATIC_URL_DATA_CACHE caches 400-series responses from the server so we don't keep requesting summit/park/bunker data for a reference that the user typod and doesn't actually exist. 2026-07-25 08:29:22 +01:00
Ian Renton 2fc9658571 Improve error handling to spare the server log from user's malformed requests and stop trying to parse data from providers if the HTTP status was not 200 2026-07-25 08:17:17 +01:00
Ian Renton 34e3933d5d Towers refs can have three letters 2026-07-10 17:50:55 +01:00
Ian Renton eee0d70140 Create SIGRef objects when converting dict to Spot object in the spot object's own __post_init__ method rather than in add_spot.py 2026-07-10 17:45:43 +01:00
Ian Renton 600a330e63 Fix a bug where SIGs weren't being handled properly in the add spot JS 2026-07-10 17:38:33 +01:00
Ian Renton f2a6bfa515 Support "SAT" propagation mode 2026-07-10 17:25:38 +01:00
Ian Renton 4d29e0026f Config option to disable logging web requests, to spare the log file. 2026-07-10 12:17:09 +01:00
Ian Renton c7b6163440 Extra search and replaces in PNP spot frequencies to cope with a weird one encountered 2026-07-06 17:58:45 +01:00
Ian Renton c51648738f Update GMA API endpoints 2026-07-06 17:56:40 +01:00
Ian Renton 88923bc96e Fix a logging call 2026-06-30 21:15:16 +01:00
ian 565a3f7564 LLOTA References are now prefixed with LL 2026-06-30 18:13:25 +00:00
Ian Renton b09108b41b Fix a bug where some NaN values could leak into the JSON output, causing it to be invalid. Created a new safe_json_dumps method since we would otherwise now be providing multiple parameters all over the code 2026-06-28 18:09:44 +01:00
ian 72f367f607 Honestly 5 seconds delay for the denounce is fine, 500ms was unnecessarily quick 2026-06-26 13:23:46 +00:00
Ian Renton 998b394d2c Update map and bands pages to use SSE endpoint after initial load. Closes #43. 2026-06-26 09:09:15 +01:00
Ian Renton d1df772649 Disambiguation between Towers and Toilets on the Air 2026-06-25 19:25:05 +01:00
Ian Renton 215b61593b Seen GMA send "QRT" as a frequency 2026-06-23 19:36:23 +01:00
Ian Renton eb1d575623 Switch to an authoritative source of Spanish municipality data #114 and credit it in README #115 2026-06-23 19:26:45 +01:00
Ian Renton e4c3a52299 Add support for DME. Closes #114. 2026-06-23 06:45:03 +01:00
53 changed files with 9024 additions and 8675 deletions
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding">
<file url="file://$PROJECT_DIR$/datafiles/MUNICIPIOS.csv" charset="ISO-8859-1" />
</component>
</project>
+1
View File
@@ -2,6 +2,7 @@
<module type="PYTHON_MODULE" version="4"> <module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager"> <component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$"> <content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/.venv" /> <excludeFolder url="file://$MODULE_DIR$/.venv" />
<excludeFolder url="file://$MODULE_DIR$/webassets/vendor" /> <excludeFolder url="file://$MODULE_DIR$/webassets/vendor" />
</content> </content>
+10 -4
View File
@@ -19,6 +19,8 @@ Supported data sources include DX Clusters, the Reverse Beacon Network (RBN), th
SOTA, WWFF, GMA, WWBOTA, HEMA, Parks 'n' Peaks, ZLOTA, WOTA, BOTA, LLOTA, WWTOTA, Tiles on the Air, the UK Packet SOTA, WWFF, GMA, WWBOTA, HEMA, Parks 'n' Peaks, ZLOTA, WOTA, BOTA, LLOTA, WWTOTA, Tiles on the Air, the UK Packet
Repeater Network, NG3K, and any site based on the xOTA software by nischu. Repeater Network, NG3K, and any site based on the xOTA software by nischu.
Additional Special Interest Groups (SIGs) without their own specific data source include WAB, WAI and DME.
![Screenshot](/images/screenshot2.png) ![Screenshot](/images/screenshot2.png)
![Screenshot](/images/screenshot3.png) ![Screenshot](/images/screenshot3.png)
@@ -93,9 +95,9 @@ Various approaches exist to writing your own client, but in general:
`https://spothole.app/api/v1/spots` once every few minutes. Apply filters if necessary. `https://spothole.app/api/v1/spots` once every few minutes. Apply filters if necessary.
* Call the "options" API to get an idea of which bands, modes etc. the server knows about. You might want to do that * Call the "options" API to get an idea of which bands, modes etc. the server knows about. You might want to do that
first before calling the spots/alerts APIs, to allow you to populate your filters correctly. first before calling the spots/alerts APIs, to allow you to populate your filters correctly.
* Refer to the provided HTML/JS interface for a reference on different approaches. For example, the "map" and "bands" * Refer to the provided HTML/JS interface for a reference on different approaches. For example, the "alerts"/"upcoming"
pages simply query the main spot API on a timer, whereas the main/spots page combines this approach with using the page simply query the main spot API on a timer, whereas the spots, map and bands pages combine this approach with
Server-Sent Events (SSE) endpoint to update live. using the Server-Sent Events (SSE) endpoint to update live.
* Let me know if you get stuck, I'm happy to help. * Let me know if you get stuck, I'm happy to help.
Please don't hammer the API with an unnecessarily high request rate. For example, Spothole only queries the POTA API Please don't hammer the API with an unnecessarily high request rate. For example, Spothole only queries the POTA API
@@ -515,7 +517,11 @@ The same approach as above is also used for alert providers.
As well as being my work, I have also gratefully received feature patches from Steven, M1SDH. As well as being my work, I have also gratefully received feature patches from Steven, M1SDH.
The project contains GeoJSON files for CQ and ITU zones, in the `/datafiles/` directory. These are MIT-licenced and, to The project contains GeoJSON files for CQ and ITU zones, in the `/datafiles/` directory. These are MIT-licenced and, to
my knowledge, created by HA8TKS for his CQ and ITU zone layers for Leaflet. my knowledge, created by HA8TKS for his CQ and ITU zone layers for Leaflet. `/datafiles` also contains a
`MUNICIPIOS.csv` file, from the "Nomenclátor Geográfico de Municipios y Entidades de Población" data set sourced from
[el Centro Nacional de Información Geográfica](https://centrodedescargas.cnig.es/CentroDescargas/home).
`didbase-stations.csv` and the TOTA CSV files were created by me based on publicly available data from GIRO and from
maps of conference centres.
The project contains a set of flag icons generated using the "Noto Color Emoji" font on a Debian system, in the The project contains a set of flag icons generated using the "Noto Color Emoji" font on a Debian system, in the
`/webassets/img/flags/` directory. `/webassets/img/flags/` directory.
+7
View File
@@ -41,6 +41,8 @@ class HTTPAlertProvider(AlertProvider):
# Request data from API # Request data from API
logging.debug("Polling " + self.name + " alert API...") logging.debug("Polling " + self.name + " alert API...")
http_response = requests.get(self._url, headers=HTTP_HEADERS, timeout=(5, 30)) http_response = requests.get(self._url, headers=HTTP_HEADERS, timeout=(5, 30))
# Check response code was good
if http_response.ok:
# Pass off to the subclass for processing # Pass off to the subclass for processing
new_alerts = self._http_response_to_alerts(http_response) new_alerts = self._http_response_to_alerts(http_response)
# Submit the new alerts for processing. There might not be any alerts for the less popular programs. # Submit the new alerts for processing. There might not be any alerts for the less popular programs.
@@ -50,7 +52,12 @@ class HTTPAlertProvider(AlertProvider):
self.status = "OK" self.status = "OK"
self.last_update_time = datetime.now(pytz.UTC) self.last_update_time = datetime.now(pytz.UTC)
logging.debug("Received data from " + self.name + " alert API.") logging.debug("Received data from " + self.name + " alert API.")
else:
self.status = "Error"
logging.warning(f"HTTP {http_response.status_code} when calling {self.name} alerts API.")
except ConnectionError:
logging.warning(f"Connection error when accessing {self.name} alerts API.")
except Exception: except Exception:
self.status = "Error" self.status = "Error"
logging.exception("Exception in HTTP JSON Alert Provider (" + self.name + ")") logging.exception("Exception in HTTP JSON Alert Provider (" + self.name + ")")
+10 -2
View File
@@ -66,8 +66,8 @@ spot-providers:
name: "LLOTA" name: "LLOTA"
enabled: true enabled: true
- class: "WWTOTA" - class: "Towers"
name: "WWTOTA" name: "Towers"
enabled: true enabled: true
- class: "Tiles" - class: "Tiles"
@@ -211,6 +211,14 @@ clublog-api-key: ""
# Allow submitting spots to the Spothole API? # Allow submitting spots to the Spothole API?
allow-spotting: true allow-spotting: true
# Log web requests? Useful to see what your users are requesting in terms of pages and API endpoints, but will fill up
# your log quickly on a popular server.
log-web-requests: false
# Minimum severity of log statements to print to the system log. Defaults to INFO, change to DEBUG if you need more
# information.
log-level: INFO
# Options for the web UI. # Options for the web UI.
web-ui-options: web-ui-options:
spot-count: [ 10, 25, 50, 100 ] spot-count: [ 10, 25, 50, 100 ]
+5 -2
View File
@@ -8,8 +8,11 @@ from requests_cache import CachedSession
# of time has passed. This is used throughout Spothole to cache data that does not change # of time has passed. This is used throughout Spothole to cache data that does not change
# rapidly. The ThreadSafeSession construct here protects it against some multithreading # rapidly. The ThreadSafeSession construct here protects it against some multithreading
# contention weirdness we sometimes used to see on startup where the cache was hammered # contention weirdness we sometimes used to see on startup where the cache was hammered
# pretty hard. # pretty hard. The expanded list of allowable_codes ensures we also cache and return 400-type
_session = CachedSession("cache/semi_static_url_data_cache", expire_after=timedelta(days=30)) # responses, e.g "this SOTA summit ref doesn't actually exist", to avoid hammering remote
# servers for data they've told us they can't provide.
_session = CachedSession("cache/semi_static_url_data_cache", expire_after=timedelta(days=30),
allowable_codes=(200, 400, 401, 403, 404))
_lock = threading.Lock() _lock = threading.Lock()
+2
View File
@@ -22,6 +22,8 @@ WEB_SERVER_PORT = config["web-server-port"]
ALLOW_SPOTTING = config["allow-spotting"] ALLOW_SPOTTING = config["allow-spotting"]
WEB_UI_OPTIONS = config["web-ui-options"] WEB_UI_OPTIONS = config["web-ui-options"]
API_ONLY_MODE = config.get("api-only-mode", False) API_ONLY_MODE = config.get("api-only-mode", False)
LOG_LEVEL = config.get("log-level", "INFO")
LOG_WEB_REQUESTS = config.get("log-web-requests", False)
# For ease of config, each spot provider owns its own config about whether it should be enabled by default in the web UI # For ease of config, each spot provider owns its own config about whether it should be enabled by default in the web UI
# but for consistency we provide this to the front-end in web-ui-options because it has no impact outside of the web UI. # but for consistency we provide this to the front-end in web-ui-options because it has no impact outside of the web UI.
+25 -24
View File
@@ -11,29 +11,29 @@ HAMQTH_PRG = ("Spothole v" + SOFTWARE_VERSION + " operated by " + SERVER_OWNER_C
# Special Interest Groups # Special Interest Groups
SIGS = [ SIGS = [
SIG(name="POTA", description="Parks on the Air", ref_regex=r"[A-Z]{2}\-\d{4,5}"), SIG(name="POTA", comment_names=["POTA"], description="Parks on the Air", ref_regex=r"[A-Z]{2}\-\d{4,5}"),
SIG(name="SOTA", description="Summits on the Air", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}"), SIG(name="SOTA", comment_names=["SOTA"], description="Summits on the Air", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}"),
SIG(name="WWFF", description="World Wide Flora & Fauna", ref_regex=r"[A-Z0-9]{1,3}FF\-\d{4}"), SIG(name="WWFF", comment_names=["WWFF"], description="World Wide Flora & Fauna", ref_regex=r"[A-Z0-9]{1,3}FF\-\d{4}"),
SIG(name="GMA", description="Global Mountain Activity", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}"), SIG(name="GMA", comment_names=["GMA"], description="Global Mountain Activity", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}"),
SIG(name="WWBOTA", description="Worldwide Bunkers on the Air", ref_regex=r"B\/[A-Z0-9]{1,3}\-\d{3,4}"), SIG(name="WWBOTA", comment_names=["WWBOTA", "BOTA"], description="Worldwide Bunkers on the Air", ref_regex=r"B\/[A-Z0-9]{1,3}\-\d{3,4}"),
SIG(name="HEMA", description="HuMPs Excluding Marilyns Award", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{3}\-\d{3}"), SIG(name="HEMA", comment_names=["HEMA"], description="HuMPs Excluding Marilyns Award", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{3}\-\d{3}"),
SIG(name="IOTA", description="Islands on the Air", ref_regex=r"[A-Z]{2}\-\d{3}"), SIG(name="IOTA", comment_names=["IOTA"], description="Islands on the Air", ref_regex=r"[A-Z]{2}\-\d{3}"),
SIG(name="MOTA", description="Mills on the Air", ref_regex=r"X\d{4,6}"), SIG(name="MOTA", comment_names=["MOTA"], description="Mills on the Air", ref_regex=r"X\d{4,6}"),
SIG(name="ARLHS", description="Amateur Radio Lighthouse Society", ref_regex=r"[A-Z]{3}\-\d{3,4}"), SIG(name="ARLHS", comment_names=["ARLHS"], description="Amateur Radio Lighthouse Society", ref_regex=r"[A-Z]{3}\-\d{3,4}"),
SIG(name="ILLW", description="International Lighthouse & Lightship Weekend", ref_regex=r"[A-Z]{2}\d{4}"), SIG(name="ILLW", comment_names=["ILLW"], description="International Lighthouse & Lightship Weekend", ref_regex=r"[A-Z]{2}\d{4}"),
SIG(name="SIOTA", description="Silos on the Air", ref_regex=r"[A-Z]{2}\-[A-Z]{3}\d"), SIG(name="SiOTA", comment_names=["SIOTA"], description="Silos on the Air", ref_regex=r"[A-Z]{2}\-[A-Z]{3}\d"),
SIG(name="WCA", description="World Castles Award", ref_regex=r"[A-Z0-9]{1,3}\-\d{5}"), SIG(name="WCA", comment_names=["WCA"], description="World Castles Award", ref_regex=r"[A-Z0-9]{1,3}\-\d{5}"),
SIG(name="ZLOTA", description="New Zealand on the Air", ref_regex=r"ZL[A-Z]/[A-Z]{2}\-\d{3,4}"), SIG(name="ZLOTA", comment_names=["ZLOTA"], description="New Zealand on the Air", ref_regex=r"ZL[A-Z]/[A-Z]{2}\-\d{3,4}"),
SIG(name="WOTA", description="Wainwrights on the Air", ref_regex=r"[A-Z]{3}-[0-9]{2}"), SIG(name="WOTA", comment_names=["WOTA"], description="Wainwrights on the Air", ref_regex=r"[A-Z]{3}-[0-9]{2}"),
SIG(name="BOTA", description="Beaches on the Air"), SIG(name="BOTA", comment_names=[], description="Beaches on the Air"),
SIG(name="KRMNPA", description="Keith Roget Memorial National Parks Award"), SIG(name="KRMNPA", comment_names=["KRMNPA"], description="Keith Roget Memorial National Parks Award"),
SIG(name="LLOTA", description="Lagos y Lagunas on the Air", ref_regex=r"[A-Z]{2}\-\d{4}"), SIG(name="LLOTA", comment_names=["LLOTA"], description="Lagos y Lagunas on the Air", ref_regex=r"LL[A-Z]{2}\-\d{4}"),
SIG(name="WWTOTA", description="Towers on the Air", ref_regex=r"[A-Z]{2}R\-\d{4}"), SIG(name="Towers", comment_names=["TOTA"], description="Towers on the Air", ref_regex=r"[A-Z]{2,3}R\-\d{4}"),
SIG(name="Tiles", description="Tiles on the Air", ref_regex=r"[A-Za-z]{2}[0-9]{2}[A-Za-z]{2}"), SIG(name="Tiles", comment_names=[], description="Tiles on the Air", ref_regex=r"[A-Za-z]{2}[0-9]{2}[A-Za-z]{2}"),
SIG(name="WAB", description="Worked All Britain", ref_regex=r"[A-Z]{1,2}[0-9]{2}"), SIG(name="WAB", comment_names=["WAB"], description="Worked All Britain", ref_regex=r"[A-Z]{1,2}[0-9]{2}"),
SIG(name="WAI", description="Worked All Ireland", ref_regex=r"[A-Z][0-9]{2}"), SIG(name="WAI", comment_names=["WAI"], description="Worked All Ireland", ref_regex=r"[A-Z][0-9]{2}"),
SIG(name="DME", description="Diplomas de Municipios Españoles", ref_regex=r"\d{4,5}"), SIG(name="DME", comment_names=["DME"], description="Diplomas de Municipios Españoles", ref_regex=r"\d{4,5}"),
SIG(name="TOTA", description="Toilets on the Air", ref_regex=r"T\-[0-9]{2}") SIG(name="Toilets", comment_names=[], description="Toilets on the Air", ref_regex=r"T\-[0-9]{2}")
] ]
# Modes. Note "DIGI" and "DIGITAL" are also supported but are normalised into "DATA". # Modes. Note "DIGI" and "DIGITAL" are also supported but are normalised into "DATA".
@@ -102,5 +102,6 @@ PROPAGATION_MODES = {
"AU": "Aurora", "AU": "Aurora",
"MS": "Meteor scatter", "MS": "Meteor scatter",
"RS": "Rain scatter", "RS": "Rain scatter",
"AS": "Aircraft scatter" "AS": "Aircraft scatter",
"SAT": "Satellite"
} }
+51 -17
View File
@@ -5,7 +5,6 @@ import re
import urllib.parse import urllib.parse
from datetime import timedelta from datetime import timedelta
import requests
import xmltodict import xmltodict
from diskcache import Cache from diskcache import Cache
from pyhamtools import LookupLib, Callinfo, callinfo from pyhamtools import LookupLib, Callinfo, callinfo
@@ -143,13 +142,19 @@ class LookupHelper:
try: try:
logging.info("Downloading Country-files.com cty.plist...") logging.info("Downloading Country-files.com cty.plist...")
response = SEMI_STATIC_URL_DATA_CACHE.get("https://www.country-files.com/cty/cty.plist", response = SEMI_STATIC_URL_DATA_CACHE.get("https://www.country-files.com/cty/cty.plist",
headers=HTTP_HEADERS).text headers=HTTP_HEADERS)
if response.ok:
with open(self._country_files_cty_plist_download_location, "w") as f: with open(self._country_files_cty_plist_download_location, "w") as f:
f.write(response) f.write(response.text)
f.flush() f.flush()
return True return True
else:
logging.warning(f"HTTP {response.status_code} when downloading Country-files.com cty.plist.")
return False
except ConnectionError:
logging.warning(f"Connection error when downloading Clublog cty.xml.")
except Exception as e: except Exception as e:
logging.error("Exception when downloading Clublog cty.xml", e) logging.error("Exception when downloading Clublog cty.xml", e)
return False return False
@@ -161,13 +166,19 @@ class LookupHelper:
logging.info("Downloading dxcc.json...") logging.info("Downloading dxcc.json...")
response = SEMI_STATIC_URL_DATA_CACHE.get( response = SEMI_STATIC_URL_DATA_CACHE.get(
"https://raw.githubusercontent.com/k0swe/dxcc-json/refs/heads/main/dxcc.json", "https://raw.githubusercontent.com/k0swe/dxcc-json/refs/heads/main/dxcc.json",
headers=HTTP_HEADERS).text headers=HTTP_HEADERS)
if response.ok:
with open(self._dxcc_json_download_location, "w") as f: with open(self._dxcc_json_download_location, "w") as f:
f.write(response) f.write(response.text)
f.flush() f.flush()
return True return True
else:
logging.warning(f"HTTP {response.status_code} when downloading dxcc.json.")
return False
except ConnectionError:
logging.warning(f"Connection error when downloading dxcc.json.")
except Exception as e: except Exception as e:
logging.error("Exception when downloading dxcc.json", e) logging.error("Exception when downloading dxcc.json", e)
return False return False
@@ -496,19 +507,35 @@ class LookupHelper:
for lookup_call in calls_to_try: for lookup_call in calls_to_try:
try: try:
lookup_response = requests.get( response = SEMI_STATIC_URL_DATA_CACHE.get(
self._qrz_base_url + "?s=" + session_key + "&callsign=" + urllib.parse.quote_plus(lookup_call), self._qrz_base_url + "?s=" + session_key + "&callsign=" + urllib.parse.quote_plus(lookup_call),
headers=HTTP_HEADERS, timeout=10).content headers=HTTP_HEADERS, timeout=10)
raw = xmltodict.parse(lookup_response).get("QRZDatabase", {}).get("Callsign") if response.ok:
if raw: qrz_response = xmltodict.parse(response.content).get("QRZDatabase", {})
data = _normalize_qrz_data(raw) if qrz_response:
if "Callsign" in qrz_response:
data = _normalize_qrz_data(qrz_response.get("Callsign"))
self._qrz_callsign_data_cache.add(call, data, expire=604800) # 1 week in seconds self._qrz_callsign_data_cache.add(call, data, expire=604800) # 1 week in seconds
return data return data
elif "Session" in qrz_response and "Error" in qrz_response.get("Session"):
# Errors here are normally just "callsign not in database", no need to log that ourselves
# above debug level.
logging.debug("QRZ returned an error looking up callsign %s: %s", lookup_call,
qrz_response.get("Session").get("Error"))
elif not response.from_cache:
logging.warning("QRZ returned a malformed response looking up callsign %s", lookup_call)
elif not response.from_cache:
logging.warning("HTTP %d looking up callsign %s using QRZ", lookup_call)
except (KeyError, ValueError): except (KeyError, ValueError):
continue continue
except ConnectionError:
logging.warning(f"Connection error when looking up callsign %s using QRZ", lookup_call)
continue
except Exception: except Exception:
logging.error("Exception when looking up QRZ data") logging.error("Exception when looking up callsign %s using QRZ", lookup_call, exc_info=True)
return None continue
# Not found in QRZ; cache None so we don't keep retrying # Not found in QRZ; cache None so we don't keep retrying
self._qrz_callsign_data_cache.add(call, None, expire=604800) # 1 week in seconds self._qrz_callsign_data_cache.add(call, None, expire=604800) # 1 week in seconds
@@ -552,17 +579,24 @@ class LookupHelper:
for lookup_call in calls_to_try: for lookup_call in calls_to_try:
try: try:
lookup_data = SEMI_STATIC_URL_DATA_CACHE.get( response = SEMI_STATIC_URL_DATA_CACHE.get(
self._hamqth_base_url + "?id=" + session_id + "&callsign=" + urllib.parse.quote_plus( self._hamqth_base_url + "?id=" + session_id + "&callsign=" + urllib.parse.quote_plus(
lookup_call) + "&prg=" + HAMQTH_PRG, headers=HTTP_HEADERS).content lookup_call) + "&prg=" + HAMQTH_PRG, headers=HTTP_HEADERS)
data = xmltodict.parse(lookup_data)["HamQTH"]["search"] if response.ok:
data = xmltodict.parse(response.content)["HamQTH"]["search"]
self._hamqth_callsign_data_cache.add(call, data, expire=604800) # 1 week in seconds self._hamqth_callsign_data_cache.add(call, data, expire=604800) # 1 week in seconds
return data return data
elif not response.from_cache:
logging.warning("HTTP %d looking up callsign %s using HamQTH", response.status_code, lookup_call)
except (KeyError, ValueError): except (KeyError, ValueError):
continue continue
except ConnectionError:
logging.warning(f"Connection error when looking up callsign %s using HamQTH", lookup_call)
continue
except Exception: except Exception:
logging.error("Exception when looking up HamQTH data") logging.error("Exception when looking up callsign %s using HamQTH", lookup_call, exc_info=True)
return None continue
# Not found in HamQTH; cache None so we don't keep retrying # Not found in HamQTH; cache None so we don't keep retrying
self._hamqth_callsign_data_cache.add(call, None, expire=604800) # 1 week in seconds self._hamqth_callsign_data_cache.add(call, None, expire=604800) # 1 week in seconds
+103 -47
View File
@@ -7,8 +7,10 @@ from core.cache_utils import SEMI_STATIC_URL_DATA_CACHE
from core.constants import SIGS, HTTP_HEADERS from core.constants import SIGS, HTTP_HEADERS
from core.geo_utils import wab_wai_square_to_lat_lon from core.geo_utils import wab_wai_square_to_lat_lon
with open("datafiles/dme-geodata.csv", encoding="utf-8") as _f: # Load Spanish municipality data for the DME programme. There's no convenient lookup API for this, so we embed the data
_DME_INDEX = {row["dme"]: row for row in csv.DictReader(_f)} # file in Spothole and load it on startup.
with open("datafiles/MUNICIPIOS.csv", encoding="latin-1") as _f:
_DME_INDEX = {row["COD_INE"][:5]: row for row in csv.DictReader(_f, delimiter=";")}
def get_ref_regex_for_sig(sig): def get_ref_regex_for_sig(sig):
@@ -20,6 +22,16 @@ def get_ref_regex_for_sig(sig):
return None return None
def get_sig_name_from_comment_name(sig):
"""Utility function to get the name of a SIG from its "comment name". Generally these will be the same but there are
some cases (e.g. is "TOTA" Towers, Tiles or Toilets?) where we need to transform one to the other."""
for s in SIGS:
if any(n.upper() == sig.upper() for n in s.comment_names):
return s.name
return None
def populate_sig_ref_info(sig_ref): def populate_sig_ref_info(sig_ref):
"""Look up details of a SIG reference (e.g. POTA park) such as name, lat/lon, and grid. Takes in a sig_ref object which """Look up details of a SIG reference (e.g. POTA park) such as name, lat/lon, and grid. Takes in a sig_ref object which
must at minimum have a "sig" and an "id". The rest of the object will be populated and returned. must at minimum have a "sig" and an "id". The rest of the object will be populated and returned.
@@ -33,9 +45,8 @@ def populate_sig_ref_info(sig_ref):
try: try:
if sig.upper() == "POTA": if sig.upper() == "POTA":
response = SEMI_STATIC_URL_DATA_CACHE.get("https://api.pota.app/park/" + ref_id, headers=HTTP_HEADERS) response = SEMI_STATIC_URL_DATA_CACHE.get("https://api.pota.app/park/" + ref_id, headers=HTTP_HEADERS)
if not response.ok: if response.ok:
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id) data = response.json()
data = response.json() if response.ok else None
if data: if data:
fullname = str(data["name"]) if "name" in data else None fullname = str(data["name"]) if "name" in data else None
if fullname and "parktypeDesc" in data and data["parktypeDesc"] != "": if fullname and "parktypeDesc" in data and data["parktypeDesc"] != "":
@@ -45,12 +56,16 @@ def populate_sig_ref_info(sig_ref):
sig_ref.grid = data["grid6"] if "grid6" in data else None sig_ref.grid = data["grid6"] if "grid6" in data else None
sig_ref.latitude = data["latitude"] if "latitude" in data else None sig_ref.latitude = data["latitude"] if "latitude" in data else None
sig_ref.longitude = data["longitude"] if "longitude" in data else None sig_ref.longitude = data["longitude"] if "longitude" in data else None
elif not response.from_cache:
logging.warning("Malformed response looking up %s ref %s", sig, ref_id)
elif not response.from_cache:
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
elif sig.upper() == "SOTA": elif sig.upper() == "SOTA":
response = SEMI_STATIC_URL_DATA_CACHE.get("https://api-db2.sota.org.uk/api/summits/" + ref_id, response = SEMI_STATIC_URL_DATA_CACHE.get("https://api-db2.sota.org.uk/api/summits/" + ref_id,
headers=HTTP_HEADERS) headers=HTTP_HEADERS)
if not response.ok: if response.ok:
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id) data = response.json()
data = response.json() if response.ok else None
if data: if data:
sig_ref.name = data["name"] if "name" in data else None sig_ref.name = data["name"] if "name" in data else None
sig_ref.url = "https://www.sotadata.org.uk/en/summit/" + ref_id sig_ref.url = "https://www.sotadata.org.uk/en/summit/" + ref_id
@@ -58,64 +73,83 @@ def populate_sig_ref_info(sig_ref):
sig_ref.latitude = data["latitude"] if "latitude" in data else None sig_ref.latitude = data["latitude"] if "latitude" in data else None
sig_ref.longitude = data["longitude"] if "longitude" in data else None sig_ref.longitude = data["longitude"] if "longitude" in data else None
sig_ref.activation_score = data["points"] if "points" in data else None sig_ref.activation_score = data["points"] if "points" in data else None
elif not response.from_cache:
logging.warning("Malformed response looking up %s ref %s", sig, ref_id)
elif not response.from_cache:
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
elif sig.upper() == "WWBOTA": elif sig.upper() == "WWBOTA":
response = SEMI_STATIC_URL_DATA_CACHE.get("https://api.wwbota.org/bunkers/" + ref_id, response = SEMI_STATIC_URL_DATA_CACHE.get("https://api.wwbota.org/bunkers/" + ref_id,
headers=HTTP_HEADERS) headers=HTTP_HEADERS)
if not response.ok: if response.ok:
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id) data = response.json()
data = response.json() if response.ok else None
if data: if data:
sig_ref.name = data["name"] if "name" in data else None sig_ref.name = data["name"] if "name" in data else None
sig_ref.url = "https://bunkerwiki.org/?s=" + ref_id if ref_id.startswith("B/G") else None sig_ref.url = "https://bunkerwiki.org/?s=" + ref_id if ref_id.startswith("B/G") else None
sig_ref.grid = data["locator"] if "locator" in data else None sig_ref.grid = data["locator"] if "locator" in data else None
sig_ref.latitude = data["lat"] if "lat" in data else None sig_ref.latitude = data["lat"] if "lat" in data else None
sig_ref.longitude = data["long"] if "long" in data else None sig_ref.longitude = data["long"] if "long" in data else None
elif not response.from_cache:
logging.warning("Malformed response looking up %s ref %s", sig, ref_id)
elif not response.from_cache:
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
elif sig.upper() == "GMA" or sig.upper() == "ARLHS" or sig.upper() == "ILLW" or sig.upper() == "WCA" or sig.upper() == "MOTA" or sig.upper() == "IOTA": elif sig.upper() == "GMA" or sig.upper() == "ARLHS" or sig.upper() == "ILLW" or sig.upper() == "WCA" or sig.upper() == "MOTA" or sig.upper() == "IOTA":
response = SEMI_STATIC_URL_DATA_CACHE.get("https://www.cqgma.org/api/ref/?" + ref_id, response = SEMI_STATIC_URL_DATA_CACHE.get("https://www.cqgma.org/api/ref/?" + ref_id,
headers=HTTP_HEADERS) headers=HTTP_HEADERS)
if not response.ok: if response.ok:
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id) data = response.json()
data = response.json() if response.ok else None
if data: if data:
sig_ref.name = data["name"] if "name" in data else None sig_ref.name = data["name"] if "name" in data else None
sig_ref.url = "https://www.cqgma.org/zinfo.php?ref=" + ref_id sig_ref.url = "https://www.cqgma.org/zinfo.php?ref=" + ref_id
sig_ref.grid = data["locator"] if "locator" in data else None sig_ref.grid = data["locator"] if "locator" in data else None
sig_ref.latitude = data["latitude"] if "latitude" in data else None sig_ref.latitude = data["latitude"] if "latitude" in data else None
sig_ref.longitude = data["longitude"] if "longitude" in data else None sig_ref.longitude = data["longitude"] if "longitude" in data else None
elif not response.from_cache:
logging.warning("Malformed response looking up %s ref %s via GMA", sig, ref_id)
elif not response.from_cache:
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
elif sig.upper() == "WWFF": elif sig.upper() == "WWFF":
wwff_response = SEMI_STATIC_URL_DATA_CACHE.get("https://wwff.co/wwff-data/wwff_directory.csv", response = SEMI_STATIC_URL_DATA_CACHE.get("https://wwff.co/wwff-data/wwff_directory.csv",
headers=HTTP_HEADERS) headers=HTTP_HEADERS)
if not wwff_response.ok: if response.ok:
logging.warning("HTTP %d looking up %s ref %s", wwff_response.status_code, sig, ref_id) wwff_index = {row["reference"]: row for row in csv.DictReader(response.content.decode().splitlines())}
return sig_ref
wwff_index = {row["reference"]: row for row in csv.DictReader(wwff_response.content.decode().splitlines())}
row = wwff_index.get(ref_id) row = wwff_index.get(ref_id)
if row: if row:
sig_ref.name = row["name"] if "name" in row else None sig_ref.name = row["name"] if "name" in row else None
sig_ref.url = "https://wwff.co/directory/?showRef=" + ref_id sig_ref.url = "https://wwff.co/directory/?showRef=" + ref_id
sig_ref.grid = row["iaruLocator"] if "iaruLocator" in row and row["iaruLocator"] != "-" else None sig_ref.grid = row["iaruLocator"] if "iaruLocator" in row and row["iaruLocator"] != "-" else None
sig_ref.latitude = float(row["latitude"]) if "latitude" in row and row["latitude"] != "-" else None sig_ref.latitude = float(row["latitude"]) if "latitude" in row and row["latitude"] != "-" else None
sig_ref.longitude = float(row["longitude"]) if "longitude" in row and row["longitude"] != "-" else None sig_ref.longitude = float(row["longitude"]) if "longitude" in row and row[
"longitude"] != "-" else None
elif not response.from_cache:
logging.warning("WWFF database did not contain data for ref %s", ref_id)
elif not response.from_cache:
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
elif sig.upper() == "SIOTA": elif sig.upper() == "SIOTA":
siota_response = SEMI_STATIC_URL_DATA_CACHE.get("https://www.silosontheair.com/data/silos.csv", response = SEMI_STATIC_URL_DATA_CACHE.get("https://www.silosontheair.com/data/silos.csv",
headers=HTTP_HEADERS) headers=HTTP_HEADERS)
if not siota_response.ok: if response.ok:
logging.warning("HTTP %d looking up %s ref %s", siota_response.status_code, sig, ref_id)
return sig_ref
siota_index = {row["SILO_CODE"]: row for row in siota_index = {row["SILO_CODE"]: row for row in
csv.DictReader(siota_response.content.decode().splitlines())} csv.DictReader(response.content.decode().splitlines())}
row = siota_index.get(ref_id) row = siota_index.get(ref_id)
if row: if row:
sig_ref.name = row["NAME"] if "NAME" in row else None sig_ref.name = row["NAME"] if "NAME" in row else None
sig_ref.grid = row["LOCATOR"] if "LOCATOR" in row else None sig_ref.grid = row["LOCATOR"] if "LOCATOR" in row else None
sig_ref.latitude = float(row["LAT"]) if "LAT" in row else None sig_ref.latitude = float(row["LAT"]) if "LAT" in row else None
sig_ref.longitude = float(row["LNG"]) if "LNG" in row else None sig_ref.longitude = float(row["LNG"]) if "LNG" in row else None
elif not response.from_cache:
logging.warning("SIOTA database did not contain data for ref %s", ref_id)
elif not response.from_cache:
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
elif sig.upper() == "WOTA": elif sig.upper() == "WOTA":
response = SEMI_STATIC_URL_DATA_CACHE.get("https://www.wota.org.uk/mapping/data/summits.json", response = SEMI_STATIC_URL_DATA_CACHE.get("https://www.wota.org.uk/mapping/data/summits.json",
headers=HTTP_HEADERS) headers=HTTP_HEADERS)
if not response.ok: if response.ok:
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id) data = response.json()
data = response.json() if response.ok else None
if data: if data:
for feature in data.get("features", []): for feature in data.get("features", []):
if feature["properties"]["wotaId"] == ref_id: if feature["properties"]["wotaId"] == ref_id:
@@ -130,16 +164,20 @@ def populate_sig_ref_info(sig_ref):
sig_ref.latitude = feature["geometry"]["coordinates"][1] sig_ref.latitude = feature["geometry"]["coordinates"][1]
sig_ref.longitude = feature["geometry"]["coordinates"][0] sig_ref.longitude = feature["geometry"]["coordinates"][0]
break break
elif not response.from_cache:
logging.warning("Malformed response looking up %s ref %s", sig, ref_id)
elif not response.from_cache:
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
elif sig.upper() == "ZLOTA": elif sig.upper() == "ZLOTA":
response = SEMI_STATIC_URL_DATA_CACHE.get("https://ontheair.nz/assets/assets.json", headers=HTTP_HEADERS) response = SEMI_STATIC_URL_DATA_CACHE.get("https://ontheair.nz/assets/assets.json", headers=HTTP_HEADERS)
if not response.ok: if response.ok:
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id) data = response.json()
data = response.json() if response.ok else None
if isinstance(data, list): if isinstance(data, list):
for asset in data: for asset in data:
if asset["code"] == ref_id: if asset["code"] == ref_id:
sig_ref.name = asset["name"] sig_ref.name = asset["name"]
sig_ref.url = "https://ontheair.nz/assets/ZLI_OT-030" + ref_id.replace("/", "_") sig_ref.url = "https://ontheair.nz/assets/" + ref_id.replace("/", "_")
try: try:
sig_ref.grid = latlong_to_locator(asset["y"], asset["x"], 6) sig_ref.grid = latlong_to_locator(asset["y"], asset["x"], 6)
except: except:
@@ -147,16 +185,21 @@ def populate_sig_ref_info(sig_ref):
sig_ref.latitude = asset["y"] sig_ref.latitude = asset["y"]
sig_ref.longitude = asset["x"] sig_ref.longitude = asset["x"]
break break
elif not response.from_cache:
logging.warning("Malformed response looking up %s ref %s", sig, ref_id)
elif not response.from_cache:
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
elif sig.upper() == "BOTA": elif sig.upper() == "BOTA":
if not sig_ref.name: if not sig_ref.name:
sig_ref.name = sig_ref.id sig_ref.name = sig_ref.id
sig_ref.url = "https://www.beachesontheair.com/beaches/" + sig_ref.name.lower().replace(" ", "-") sig_ref.url = "https://www.beachesontheair.com/beaches/" + sig_ref.name.lower().replace(" ", "-")
elif sig.upper() == "LLOTA": elif sig.upper() == "LLOTA":
response = SEMI_STATIC_URL_DATA_CACHE.get("https://llota.app/api/public/references", response = SEMI_STATIC_URL_DATA_CACHE.get("https://llota.app/api/public/references",
headers=HTTP_HEADERS) headers=HTTP_HEADERS)
if not response.ok: if response.ok:
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id) data = response.json()
data = response.json() if response.ok else None
if isinstance(data, list): if isinstance(data, list):
for ref in data: for ref in data:
if ref["reference_code"] == ref_id: if ref["reference_code"] == ref_id:
@@ -167,10 +210,16 @@ def populate_sig_ref_info(sig_ref):
sig_ref.latitude = ll[0] sig_ref.latitude = ll[0]
sig_ref.longitude = ll[1] sig_ref.longitude = ll[1]
break break
elif not response.from_cache:
logging.warning("Malformed response looking up %s ref %s", sig, ref_id)
elif not response.from_cache:
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
elif sig.upper() == "WWTOTA": elif sig.upper() == "WWTOTA":
if not sig_ref.name: if not sig_ref.name:
sig_ref.name = sig_ref.id sig_ref.name = sig_ref.id
sig_ref.url = "https://wwtota.com/seznam/karta_rozhledny.php?ref=" + str(sig_ref.name) sig_ref.url = "https://wwtota.com/seznam/karta_rozhledny.php?ref=" + str(sig_ref.name)
elif sig.upper() == "TILES": elif sig.upper() == "TILES":
# Tiles on the Air just uses Maidenhead 6-digit squares, so ID, Name and Grid are all the same # Tiles on the Air just uses Maidenhead 6-digit squares, so ID, Name and Grid are all the same
if not sig_ref.name: if not sig_ref.name:
@@ -181,6 +230,7 @@ def populate_sig_ref_info(sig_ref):
ll = locator_to_latlong(str(sig_ref.grid)) ll = locator_to_latlong(str(sig_ref.grid))
sig_ref.latitude = ll[0] sig_ref.latitude = ll[0]
sig_ref.longitude = ll[1] sig_ref.longitude = ll[1]
elif sig.upper() == "WAB" or sig.upper() == "WAI": elif sig.upper() == "WAB" or sig.upper() == "WAI":
ll = wab_wai_square_to_lat_lon(ref_id) ll = wab_wai_square_to_lat_lon(ref_id)
if ll: if ll:
@@ -190,25 +240,31 @@ def populate_sig_ref_info(sig_ref):
sig_ref.latitude = ll[0] sig_ref.latitude = ll[0]
sig_ref.longitude = ll[1] sig_ref.longitude = ll[1]
except: except:
logging.debug("Invalid lat/lon received for reference") logging.warning("Invalid lat/lon received for WAB/WAI reference")
elif sig.upper() == "DME": elif sig.upper() == "DME":
row = _DME_INDEX.get(ref_id) # Zero-pad to 5 digits to match our source data
row = _DME_INDEX.get(ref_id.zfill(5))
if row: if row:
sig_ref.name = row["municipio"] + ", " + row["provincia"] sig_ref.name = row["NOMBRE_ACTUAL"] + ", " + row["PROVINCIA"]
sig_ref.latitude = float(row["lat"]) if row.get("lat") else None sig_ref.latitude = float(row["LATITUD_ETRS89_REGCAN95"].replace(",", ".")) if row.get(
sig_ref.longitude = float(row["lon"]) if row.get("lon") else None "LATITUD_ETRS89_REGCAN95") else None
sig_ref.longitude = float(row["LONGITUD_ETRS89_REGCAN95"].replace(",", ".")) if row.get(
"LONGITUD_ETRS89_REGCAN95") else None
if sig_ref.latitude and sig_ref.longitude: if sig_ref.latitude and sig_ref.longitude:
try: try:
sig_ref.grid = latlong_to_locator(sig_ref.latitude, sig_ref.longitude, 6) sig_ref.grid = latlong_to_locator(sig_ref.latitude, sig_ref.longitude, 6)
except Exception: except Exception:
logging.debug("Invalid lat/lon received for reference") logging.warning("Invalid lat/lon received for DME reference")
else:
logging.warning("DME database did not contain data for ref %s", ref_id)
except ConnectionError:
logging.warning("Connection error when looking up sig_ref info for " + sig + " ref " + ref_id)
except Exception: except Exception:
logging.warning("Failed to look up sig_ref info for " + sig + " ref " + ref_id, exc_info=True) logging.error("Exception when looking up sig_ref info for " + sig + " ref " + ref_id, exc_info=True)
return sig_ref return sig_ref
# Regex matching any SIG # Regex matching any SIG's "comment name", i.e. how it may be referred to in spot comments
ANY_SIG_REGEX = r"(" + r"|".join(list(map(lambda p: p.name, SIGS))) + r")" ANY_SIG_REGEX = r"(" + r"|".join(n for s in SIGS for n in s.comment_names) + r")"
# Regex matching any SIG reference
ANY_XOTA_SIG_REF_REGEX = r"[\w\/]+\-\d+"
+8 -5
View File
@@ -1,8 +1,11 @@
def serialize_everything(obj): import simplejson
"""Convert objects to serialisable things. Used by JSON serialiser as a default when it encounters unserializable things.
Just converts objects to dict. Try to avoid doing anything clever here when serialising spots, because we also need
to receive spots without complex handling.""" def safe_json_dumps(obj):
return obj.__dict__ """Safe version of json.dumps that also converts objects to dicts so they can be output, and ignores NaN floats
which are invalid in JSON."""
return simplejson.dumps(obj, ensure_ascii=False, ignore_nan=True, default=lambda o: o.__dict__)
def empty_queue(q): def empty_queue(q):
+13 -5
View File
@@ -1,13 +1,21 @@
from dataclasses import dataclass from dataclasses import dataclass, field
@dataclass @dataclass
class SIG: class SIG:
"""Data class that defines a Special Interest Group.""" """Data class that defines a Special Interest Group. Each contains a name and a longer form description.
They also contain comment_names which attempts to separate out the way people might refer to it in
cluster comments from how it is referred to in the UI & API. (For example, "TOTA" in cluster spot comments
almost always means Towers on the Air, but no single programme is referred to in the UI as "TOTA" as
it's ambiguous between Towers, Toilets and Tiles. And while Beaches got the name "BOTA" first, "BOTA" spots
are much more likely to be bunkers.) Finally, there is a ref_regex which provides a regular expression to
match what references (such as parks and summits) look like for that programme."""
# SIG name, e.g. "POTA" # SIG name as used in the UI and API, e.g. "Towers"
name: str name: str
# Description, e.g. "Parks on the Air" # Description, e.g. "Towers on the Air"
description: str description: str
# SIG names as they might appear in cluster spot comments, e.g. ["TOTA"]
comment_names: list[str] = field(default_factory=list)
# Regex matcher for references, e.g. for POTA r"[A-Z]{2}\-\d+". # Regex matcher for references, e.g. for POTA r"[A-Z]{2}\-\d+".
ref_regex: str = None ref_regex: str | None = None
+14 -3
View File
@@ -14,7 +14,7 @@ from core.constants import MODE_ALIASES, PROPAGATION_MODES
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.lookup_helper import lookup_helper, infer_band_from_freq, infer_mode_from_comment, \ from core.lookup_helper import lookup_helper, infer_band_from_freq, infer_mode_from_comment, \
infer_mode_from_frequency, infer_mode_type_from_mode infer_mode_from_frequency, infer_mode_type_from_mode
from core.sig_utils import populate_sig_ref_info, ANY_SIG_REGEX, get_ref_regex_for_sig from core.sig_utils import populate_sig_ref_info, ANY_SIG_REGEX, get_ref_regex_for_sig, get_sig_name_from_comment_name
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
@@ -133,6 +133,17 @@ class Spot:
# The ID the source gave it, if any. # The ID the source gave it, if any.
source_id: str | None = None source_id: str | None = None
def __post_init__(self):
"""Normalise fields that don't survive a plain dict to Spot conversion. This is used in the "add spot" API
endpoint where the client is submitting JSON, and we want to recreate a full Spot object, including nested
objects such as the sig_refs list.."""
if self.sig_refs:
self.sig_refs = [
sig_ref if isinstance(sig_ref, SIGRef) else SIGRef(**sig_ref)
for sig_ref in self.sig_refs
]
def infer_missing(self, credentials=None): def infer_missing(self, credentials=None):
"""Infer missing parameters where possible""" """Infer missing parameters where possible"""
@@ -255,7 +266,7 @@ class Spot:
for sig_match in sig_matches: for sig_match in sig_matches:
# First of all, if we haven't got a SIG for this spot set yet, now we have. This covers things like cluster # First of all, if we haven't got a SIG for this spot set yet, now we have. This covers things like cluster
# spots where the comment is just "POTA". # spots where the comment is just "POTA".
found_sig = sig_match.group(2).upper() found_sig = get_sig_name_from_comment_name(sig_match.group(2))
if not self.sig: if not self.sig:
self.sig = found_sig self.sig = found_sig
@@ -312,7 +323,7 @@ class Spot:
self.propagation_mode = PROPAGATION_MODES[mode_tag] self.propagation_mode = PROPAGATION_MODES[mode_tag]
else: else:
self.propagation_mode = mode_tag self.propagation_mode = mode_tag
logging.info("Seen a new propagation mode tag not yet in the system: {}", mode_tag) logging.info("Seen a new propagation mode tag not yet in the system: %s", mode_tag)
# DX Grid to lat/lon and vice versa in case one is missing # DX Grid to lat/lon and vice versa in case one is missing
if self.dx_grid and not self.dx_latitude: if self.dx_grid and not self.dx_latitude:
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1
View File
@@ -17,3 +17,4 @@ websocket-client~=1.8.0
tornado~=6.4.2 tornado~=6.4.2
tornado_eventsource~=3.0.0 tornado_eventsource~=3.0.0
geopandas~=0.13.2 geopandas~=0.13.2
simplejson~=4.1.1
+14 -32
View File
@@ -1,4 +1,3 @@
import json
import logging import logging
import re import re
from datetime import datetime from datetime import datetime
@@ -14,8 +13,7 @@ from core.constants import UNKNOWN_BAND
from core.lookup_helper import infer_band_from_freq from core.lookup_helper import infer_band_from_freq
from core.prometheus_metrics_handler import api_requests_counter from core.prometheus_metrics_handler import api_requests_counter
from core.sig_utils import get_ref_regex_for_sig from core.sig_utils import get_ref_regex_for_sig
from core.utils import serialize_everything from core.utils import safe_json_dumps
from data.sig_ref import SIGRef
from data.spot import Spot from data.spot import Spot
@@ -42,8 +40,7 @@ class APISpotHandler(tornado.web.RequestHandler):
# Reject if not allowed # Reject if not allowed
if not ALLOW_SPOTTING: if not ALLOW_SPOTTING:
self.set_status(401) self.set_status(401)
self.write(json.dumps("Error - this server does not allow new spots to be added via the API.", self.write(safe_json_dumps("Error - this server does not allow new spots to be added via the API."))
default=serialize_everything))
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
return return
@@ -51,8 +48,7 @@ class APISpotHandler(tornado.web.RequestHandler):
# Reject if format not json # Reject if format not json
if not self.request.headers.get('Content-Type', '').startswith("application/json"): if not self.request.headers.get('Content-Type', '').startswith("application/json"):
self.set_status(415) self.set_status(415)
self.write( self.write(safe_json_dumps("Error - request Content-Type must be application/json"))
json.dumps("Error - request Content-Type must be application/json", default=serialize_everything))
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
return return
@@ -61,7 +57,7 @@ class APISpotHandler(tornado.web.RequestHandler):
post_data = self.request.body post_data = self.request.body
if not post_data: if not post_data:
self.set_status(422) self.set_status(422)
self.write(json.dumps("Error - request body is empty", default=serialize_everything)) self.write(safe_json_dumps("Error - request body is empty"))
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
return return
@@ -70,19 +66,10 @@ class APISpotHandler(tornado.web.RequestHandler):
json_spot = tornado.escape.json_decode(post_data) json_spot = tornado.escape.json_decode(post_data)
spot = Spot(**json_spot) spot = Spot(**json_spot)
# Converting to a spot object this way won't have coped with sig_ref objects, so fix that. (Would be nice to
# redo this in a functional style)
if spot.sig_refs:
real_sig_refs = []
for dict_obj in spot.sig_refs:
real_sig_refs.append(json.loads(json.dumps(dict_obj), object_hook=lambda d: SIGRef(**d)))
spot.sig_refs = real_sig_refs
# Reject if no timestamp, frequency, dx_call or de_call # Reject if no timestamp, frequency, dx_call or de_call
if not spot.time or not spot.dx_call or not spot.freq or not spot.de_call: if not spot.time or not spot.dx_call or not spot.freq or not spot.de_call:
self.set_status(422) self.set_status(422)
self.write(json.dumps("Error - 'time', 'dx_call', 'freq' and 'de_call' must be provided as a minimum.", self.write(safe_json_dumps("Error - 'time', 'dx_call', 'freq' and 'de_call' must be provided as a minimum."))
default=serialize_everything))
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
return return
@@ -90,15 +77,13 @@ class APISpotHandler(tornado.web.RequestHandler):
# Reject invalid-looking callsigns # Reject invalid-looking callsigns
if not re.match(r"^[A-Za-z0-9/\-]*$", spot.dx_call): if not re.match(r"^[A-Za-z0-9/\-]*$", spot.dx_call):
self.set_status(422) self.set_status(422)
self.write(json.dumps("Error - '" + spot.dx_call + "' does not look like a valid callsign.", self.write(safe_json_dumps("Error - '" + spot.dx_call + "' does not look like a valid callsign."))
default=serialize_everything))
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
return return
if not re.match(r"^[A-Za-z0-9/\-]*$", spot.de_call): if not re.match(r"^[A-Za-z0-9/\-]*$", spot.de_call):
self.set_status(422) self.set_status(422)
self.write(json.dumps("Error - '" + spot.de_call + "' does not look like a valid callsign.", self.write(safe_json_dumps("Error - '" + spot.de_call + "' does not look like a valid callsign."))
default=serialize_everything))
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
return return
@@ -106,8 +91,7 @@ class APISpotHandler(tornado.web.RequestHandler):
# Reject if frequency not in a known band # Reject if frequency not in a known band
if infer_band_from_freq(spot.freq) == UNKNOWN_BAND: if infer_band_from_freq(spot.freq) == UNKNOWN_BAND:
self.set_status(422) self.set_status(422)
self.write(json.dumps("Error - Frequency of " + str(spot.freq / 1000.0) + "kHz is not in a known band.", self.write(safe_json_dumps("Error - Frequency of " + str(spot.freq / 1000.0) + "kHz is not in a known band."))
default=serialize_everything))
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
return return
@@ -117,8 +101,7 @@ class APISpotHandler(tornado.web.RequestHandler):
r"^([A-R]{2}[0-9]{2}[A-X]{2}[0-9]{2}[A-X]{2}|[A-R]{2}[0-9]{2}[A-X]{2}[0-9]{2}|[A-R]{2}[0-9]{2}[A-X]{2}|[A-R]{2}[0-9]{2})$", r"^([A-R]{2}[0-9]{2}[A-X]{2}[0-9]{2}[A-X]{2}|[A-R]{2}[0-9]{2}[A-X]{2}[0-9]{2}|[A-R]{2}[0-9]{2}[A-X]{2}|[A-R]{2}[0-9]{2})$",
spot.dx_grid.upper()): spot.dx_grid.upper()):
self.set_status(422) self.set_status(422)
self.write(json.dumps("Error - '" + spot.dx_grid + "' does not look like a valid Maidenhead grid.", self.write(safe_json_dumps("Error - '" + spot.dx_grid + "' does not look like a valid Maidenhead grid."))
default=serialize_everything))
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
return return
@@ -127,9 +110,8 @@ class APISpotHandler(tornado.web.RequestHandler):
if spot.sig and spot.sig_refs and len(spot.sig_refs) > 0 and spot.sig_refs[0].id and get_ref_regex_for_sig( if spot.sig and spot.sig_refs and len(spot.sig_refs) > 0 and spot.sig_refs[0].id and get_ref_regex_for_sig(
spot.sig) and not re.match(get_ref_regex_for_sig(spot.sig), spot.sig_refs[0].id): spot.sig) and not re.match(get_ref_regex_for_sig(spot.sig), spot.sig_refs[0].id):
self.set_status(422) self.set_status(422)
self.write(json.dumps( self.write(safe_json_dumps(
"Error - '" + spot.sig_refs[0].id + "' does not look like a valid reference for " + spot.sig + ".", "Error - '" + spot.sig_refs[0].id + "' does not look like a valid reference for " + spot.sig + "."))
default=serialize_everything))
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
return return
@@ -139,14 +121,14 @@ class APISpotHandler(tornado.web.RequestHandler):
spot.infer_missing() spot.infer_missing()
self._spots.add(spot.id, spot, expire=MAX_SPOT_AGE) self._spots.add(spot.id, spot, expire=MAX_SPOT_AGE)
self.write(json.dumps("OK", default=serialize_everything)) self.write(safe_json_dumps("OK"))
self.set_status(201) self.set_status(201)
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
except Exception as e: except Exception as e:
logging.error(e) logging.error("Exception when handling client request to add spot API: %s", e, exc_info=True)
self.write(json.dumps("Error - an internal server error occurred.", default=serialize_everything)) self.write(safe_json_dumps("Error - an internal server error occurred."))
self.set_status(500) self.set_status(500)
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
+6 -8
View File
@@ -1,5 +1,4 @@
import copy import copy
import json
import logging import logging
from datetime import datetime from datetime import datetime
from queue import Queue from queue import Queue
@@ -12,7 +11,7 @@ from tornado import httputil
from tornado.web import Application from tornado.web import Application
from core.prometheus_metrics_handler import api_requests_counter from core.prometheus_metrics_handler import api_requests_counter
from core.utils import serialize_everything, empty_queue from core.utils import safe_json_dumps, empty_queue
from data.lookup_credentials import extract_credentials from data.lookup_credentials import extract_credentials
SSE_HANDLER_MAX_QUEUE_SIZE = 100 SSE_HANDLER_MAX_QUEUE_SIZE = 100
@@ -57,15 +56,14 @@ class APIAlertsHandler(tornado.web.RequestHandler):
data = get_alert_list_with_filters(self._alerts, query_params) data = get_alert_list_with_filters(self._alerts, query_params)
if credentials: if credentials:
data = self._enrich(data, credentials) data = self._enrich(data, credentials)
self.write(json.dumps(data, default=serialize_everything)) self.write(safe_json_dumps(data))
self.set_status(200) self.set_status(200)
except ValueError as e: except ValueError as e:
logging.error(e) self.write(safe_json_dumps("Bad request - " + str(e)))
self.write(json.dumps("Bad request - " + str(e), default=serialize_everything))
self.set_status(400) self.set_status(400)
except Exception as e: except Exception as e:
logging.error(e) logging.error("Exception when handling client request to alerts API: %s", e, exc_info=True)
self.write(json.dumps("Error - an internal server error occurred.", default=serialize_everything)) self.write(safe_json_dumps("Error - an internal server error occurred."))
self.set_status(500) self.set_status(500)
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
@@ -150,7 +148,7 @@ class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
if self._credentials: if self._credentials:
alert = copy.deepcopy(alert) alert = copy.deepcopy(alert)
alert.infer_missing(self._credentials) alert.infer_missing(self._credentials)
self.write_message(msg=json.dumps(alert, default=serialize_everything)) self.write_message(msg=safe_json_dumps(alert))
else: else:
# Send a keepalive comment if the queue was empty # Send a keepalive comment if the queue was empty
+8
View File
@@ -1,4 +1,5 @@
import json import json
import logging
from collections import Counter from collections import Counter
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any from typing import Any
@@ -9,6 +10,7 @@ from tornado import httputil
from tornado.web import Application from tornado.web import Application
from core.prometheus_metrics_handler import api_requests_counter from core.prometheus_metrics_handler import api_requests_counter
from core.utils import safe_json_dumps
CONTINENTS = ["EU", "NA", "SA", "AS", "AF", "OC", "AN"] CONTINENTS = ["EU", "NA", "SA", "AS", "AF", "OC", "AN"]
BANDS = ["160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m"] BANDS = ["160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m"]
@@ -29,6 +31,7 @@ class APIDxStatsHandler(tornado.web.RequestHandler):
self._web_server_metrics = web_server_metrics self._web_server_metrics = web_server_metrics
def get(self): def get(self):
try:
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC) self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
self._web_server_metrics["api_access_counter"] += 1 self._web_server_metrics["api_access_counter"] += 1
self._web_server_metrics["status"] = "OK" self._web_server_metrics["status"] = "OK"
@@ -55,3 +58,8 @@ class APIDxStatsHandler(tornado.web.RequestHandler):
self.set_status(200) self.set_status(200)
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
except Exception as e:
logging.error("Exception when handling client request to dx stats API: %s", e, exc_info=True)
self.write(safe_json_dumps("Error - an internal server error occurred."))
self.set_status(500)
+18 -22
View File
@@ -1,4 +1,3 @@
import json
import logging import logging
import re import re
from datetime import datetime from datetime import datetime
@@ -13,7 +12,7 @@ from core.constants import SIGS
from core.geo_utils import lat_lon_for_grid_sw_corner_plus_size, lat_lon_to_cq_zone, lat_lon_to_itu_zone from core.geo_utils import lat_lon_for_grid_sw_corner_plus_size, lat_lon_to_cq_zone, lat_lon_to_itu_zone
from core.prometheus_metrics_handler import api_requests_counter from core.prometheus_metrics_handler import api_requests_counter
from core.sig_utils import get_ref_regex_for_sig, populate_sig_ref_info from core.sig_utils import get_ref_regex_for_sig, populate_sig_ref_info
from core.utils import serialize_everything from core.utils import safe_json_dumps
from data.lookup_credentials import extract_credentials from data.lookup_credentials import extract_credentials
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from data.spot import Spot from data.spot import Spot
@@ -65,19 +64,18 @@ class APILookupCallHandler(tornado.web.RequestHandler):
"longitude": fake_spot.dx_longitude, "longitude": fake_spot.dx_longitude,
"location_source": fake_spot.dx_location_source "location_source": fake_spot.dx_location_source
} }
self.write(json.dumps(data, default=serialize_everything)) self.write(safe_json_dumps(data))
else: else:
self.write(json.dumps("Error - '" + call + "' does not look like a valid callsign.", self.write(safe_json_dumps("Error - '" + call + "' does not look like a valid callsign."))
default=serialize_everything))
self.set_status(422) self.set_status(422)
else: else:
self.write(json.dumps("Error - call must be provided", default=serialize_everything)) self.write(safe_json_dumps("Error - call must be provided"))
self.set_status(422) self.set_status(422)
except Exception as e: except Exception as e:
logging.error(e) logging.error("Exception when handling client request to call lookup API: %s", e, exc_info=True)
self.write(json.dumps("Error - an internal server error occurred.", default=serialize_everything)) self.write(safe_json_dumps("Error - an internal server error occurred."))
self.set_status(500) self.set_status(500)
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
@@ -111,27 +109,25 @@ class APILookupSIGRefHandler(tornado.web.RequestHandler):
if "sig" in query_params.keys() and "id" in query_params.keys(): if "sig" in query_params.keys() and "id" in query_params.keys():
sig = str(query_params.get("sig")).upper() sig = str(query_params.get("sig")).upper()
ref_id = str(query_params.get("id")).upper() ref_id = str(query_params.get("id")).upper()
if sig in list(map(lambda p: p.name, SIGS)): if sig in list(map(lambda p: p.name.upper(), SIGS)):
if not get_ref_regex_for_sig(sig) or re.match(get_ref_regex_for_sig(sig), ref_id): if not get_ref_regex_for_sig(sig) or re.match(get_ref_regex_for_sig(sig), ref_id):
data = populate_sig_ref_info(SIGRef(id=ref_id, sig=sig)) data = populate_sig_ref_info(SIGRef(id=ref_id, sig=sig))
self.write(json.dumps(data, default=serialize_everything)) self.write(safe_json_dumps(data))
else: else:
self.write( self.write(safe_json_dumps(
json.dumps( "Error - '" + ref_id + "' does not look like a valid reference ID for " + sig + "."))
"Error - '" + ref_id + "' does not look like a valid reference ID for " + sig + ".",
default=serialize_everything))
self.set_status(422) self.set_status(422)
else: else:
self.write(json.dumps("Error - sig '" + sig + "' is not known.", default=serialize_everything)) self.write(safe_json_dumps("Error - sig '" + sig + "' is not known."))
self.set_status(422) self.set_status(422)
else: else:
self.write(json.dumps("Error - sig and id must be provided", default=serialize_everything)) self.write(safe_json_dumps("Error - sig and id must be provided"))
self.set_status(422) self.set_status(422)
except Exception as e: except Exception as e:
logging.error(e) logging.error("Exception when handling client request to sig ref lookup API: %s", e, exc_info=True)
self.write(json.dumps("Error - an internal server error occurred.", default=serialize_everything)) self.write(safe_json_dumps("Error - an internal server error occurred."))
self.set_status(500) self.set_status(500)
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
@@ -185,15 +181,15 @@ class APILookupGridHandler(tornado.web.RequestHandler):
"latitude": lat + lat_cell_size, "latitude": lat + lat_cell_size,
"longitude": lon + lon_cell_size, "longitude": lon + lon_cell_size,
}} }}
self.write(json.dumps(response, default=serialize_everything)) self.write(safe_json_dumps(response))
else: else:
self.write(json.dumps("Error - grid must be provided", default=serialize_everything)) self.write(safe_json_dumps("Error - grid must be provided"))
self.set_status(422) self.set_status(422)
except Exception as e: except Exception as e:
logging.error(e) logging.error("Exception when handling client request to grid ref lookup API: %s", e, exc_info=True)
self.write(json.dumps("Error - an internal server error occurred.", default=serialize_everything)) self.write(safe_json_dumps("Error - an internal server error occurred."))
self.set_status(500) self.set_status(500)
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
+9 -3
View File
@@ -1,4 +1,4 @@
import json import logging
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
@@ -10,7 +10,7 @@ from tornado.web import Application
from core.config import MAX_SPOT_AGE, ALLOW_SPOTTING from core.config import MAX_SPOT_AGE, ALLOW_SPOTTING
from core.constants import BANDS, ALL_MODES, MODE_TYPES, SIGS, CONTINENTS, PROPAGATION_MODES from core.constants import BANDS, ALL_MODES, MODE_TYPES, SIGS, CONTINENTS, PROPAGATION_MODES
from core.prometheus_metrics_handler import api_requests_counter from core.prometheus_metrics_handler import api_requests_counter
from core.utils import serialize_everything from core.utils import safe_json_dumps
class APIOptionsHandler(tornado.web.RequestHandler): class APIOptionsHandler(tornado.web.RequestHandler):
@@ -26,6 +26,7 @@ class APIOptionsHandler(tornado.web.RequestHandler):
self._web_server_metrics = web_server_metrics self._web_server_metrics = web_server_metrics
def get(self): def get(self):
try:
# Metrics # Metrics
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC) self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
self._web_server_metrics["api_access_counter"] += 1 self._web_server_metrics["api_access_counter"] += 1
@@ -50,7 +51,12 @@ class APIOptionsHandler(tornado.web.RequestHandler):
if ALLOW_SPOTTING: if ALLOW_SPOTTING:
options["spot_sources"].append("API") options["spot_sources"].append("API")
self.write(json.dumps(options, default=serialize_everything)) self.write(safe_json_dumps(options))
self.set_status(200) self.set_status(200)
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
except Exception as e:
logging.error("Exception when handling client request to options API: %s", e, exc_info=True)
self.write(safe_json_dumps("Error - an internal server error occurred."))
self.set_status(500)
+8
View File
@@ -1,3 +1,4 @@
import logging
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
@@ -7,6 +8,7 @@ from tornado import httputil
from tornado.web import Application from tornado.web import Application
from core.prometheus_metrics_handler import api_requests_counter from core.prometheus_metrics_handler import api_requests_counter
from core.utils import safe_json_dumps
class APISolarConditionsHandler(tornado.web.RequestHandler): class APISolarConditionsHandler(tornado.web.RequestHandler):
@@ -22,6 +24,7 @@ class APISolarConditionsHandler(tornado.web.RequestHandler):
self._web_server_metrics = web_server_metrics self._web_server_metrics = web_server_metrics
def get(self): def get(self):
try:
# Metrics # Metrics
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC) self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
self._web_server_metrics["api_access_counter"] += 1 self._web_server_metrics["api_access_counter"] += 1
@@ -32,3 +35,8 @@ class APISolarConditionsHandler(tornado.web.RequestHandler):
self.set_status(200) self.set_status(200)
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
except Exception as e:
logging.error("Exception when handling client request to solar conditions API: %s", e, exc_info=True)
self.write(safe_json_dumps("Error - an internal server error occurred."))
self.set_status(500)
+6 -8
View File
@@ -1,5 +1,4 @@
import copy import copy
import json
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from queue import Queue from queue import Queue
@@ -12,7 +11,7 @@ from tornado import httputil
from tornado.web import Application from tornado.web import Application
from core.prometheus_metrics_handler import api_requests_counter from core.prometheus_metrics_handler import api_requests_counter
from core.utils import serialize_everything, empty_queue from core.utils import safe_json_dumps, empty_queue
from data.lookup_credentials import extract_credentials from data.lookup_credentials import extract_credentials
SSE_HANDLER_MAX_QUEUE_SIZE = 1000 SSE_HANDLER_MAX_QUEUE_SIZE = 1000
@@ -57,15 +56,14 @@ class APISpotsHandler(tornado.web.RequestHandler):
data = get_spot_list_with_filters(self._spots, query_params) data = get_spot_list_with_filters(self._spots, query_params)
if credentials: if credentials:
data = self._enrich(data, credentials) data = self._enrich(data, credentials)
self.write(json.dumps(data, default=serialize_everything)) self.write(safe_json_dumps(data))
self.set_status(200) self.set_status(200)
except ValueError as e: except ValueError as e:
logging.error(e) self.write(safe_json_dumps("Bad request - " + str(e)))
self.write(json.dumps("Bad request - " + str(e), default=serialize_everything))
self.set_status(400) self.set_status(400)
except Exception as e: except Exception as e:
logging.error(e) logging.error("Excedption when handling client request to spots API: %s", e, exc_info=True)
self.write(json.dumps("Error - an internal server error occurred.", default=serialize_everything)) self.write(safe_json_dumps("Error - an internal server error occurred."))
self.set_status(500) self.set_status(500)
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
@@ -152,7 +150,7 @@ class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
if self._credentials: if self._credentials:
spot = copy.deepcopy(spot) spot = copy.deepcopy(spot)
spot.infer_missing(self._credentials) spot.infer_missing(self._credentials)
self.write_message(msg=json.dumps(spot, default=serialize_everything)) self.write_message(msg=safe_json_dumps(spot))
else: else:
# Send a keepalive comment if the queue was empty # Send a keepalive comment if the queue was empty
+9 -3
View File
@@ -1,4 +1,4 @@
import json import logging
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
@@ -8,7 +8,7 @@ from tornado import httputil
from tornado.web import Application from tornado.web import Application
from core.prometheus_metrics_handler import api_requests_counter from core.prometheus_metrics_handler import api_requests_counter
from core.utils import serialize_everything from core.utils import safe_json_dumps
class APIStatusHandler(tornado.web.RequestHandler): class APIStatusHandler(tornado.web.RequestHandler):
@@ -24,13 +24,19 @@ class APIStatusHandler(tornado.web.RequestHandler):
self._web_server_metrics = web_server_metrics self._web_server_metrics = web_server_metrics
def get(self): def get(self):
try:
# Metrics # Metrics
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC) self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
self._web_server_metrics["api_access_counter"] += 1 self._web_server_metrics["api_access_counter"] += 1
self._web_server_metrics["status"] = "OK" self._web_server_metrics["status"] = "OK"
api_requests_counter.inc() api_requests_counter.inc()
self.write(json.dumps(self._status_data, default=serialize_everything)) self.write(safe_json_dumps(self._status_data))
self.set_status(200) self.set_status(200)
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
except Exception as e:
logging.error("Exception when handling client request to status API: %s", e, exc_info=True)
self.write(safe_json_dumps("Error - an internal server error occurred."))
self.set_status(500)
+5 -2
View File
@@ -5,7 +5,7 @@ import os
import tornado import tornado
from tornado.web import StaticFileHandler from tornado.web import StaticFileHandler
from core.config import ALLOW_SPOTTING, WEB_SERVER_PORT, API_ONLY_MODE from core.config import ALLOW_SPOTTING, WEB_SERVER_PORT, API_ONLY_MODE, LOG_WEB_REQUESTS, BASE_URL
from core.utils import empty_queue from core.utils import empty_queue
from server.handlers.api.addspot import APISpotHandler from server.handlers.api.addspot import APISpotHandler
from server.handlers.api.alerts import APIAlertsHandler, APIAlertsStreamHandler from server.handlers.api.alerts import APIAlertsHandler, APIAlertsStreamHandler
@@ -112,6 +112,7 @@ class WebServer:
debug=False) debug=False)
app.listen(self._port, xheaders=True) app.listen(self._port, xheaders=True)
logging.info("Web server running on port " + str(WEB_SERVER_PORT)) logging.info("Web server running on port " + str(WEB_SERVER_PORT))
logging.info("You can access your copy of Spothole at " + BASE_URL)
await self._shutdown_event.wait() await self._shutdown_event.wait()
def notify_new_spot(self, spot): def notify_new_spot(self, spot):
@@ -165,8 +166,10 @@ class WebServer:
pass pass
def request_log(handler): def request_log(handler):
"""Custom log function to provide more data about requests.""" """Custom log function to provide more data about requests when enabled, and to provide the ability to turn off
web request logging altogetether."""
if LOG_WEB_REQUESTS:
if handler.get_status() < 500: if handler.get_status() < 500:
log_method = logging.info log_method = logging.info
else: else:
+8 -3
View File
@@ -127,10 +127,15 @@ class GIROIonosonde(SolarConditionsProvider):
from_str = from_time.strftime("%Y.%m.%d+%H:%M:%S") from_str = from_time.strftime("%Y.%m.%d+%H:%M:%S")
to_str = to_time.strftime("%Y.%m.%d+%H:%M:%S") to_str = to_time.strftime("%Y.%m.%d+%H:%M:%S")
url = f"{LGDC_URL}?ursiCode={ursi}&charName=foF2,MUFD,fmin&DMUF=3000&fromDate={from_str}&toDate={to_str}" url = f"{LGDC_URL}?ursiCode={ursi}&charName=foF2,MUFD,fmin&DMUF=3000&fromDate={from_str}&toDate={to_str}"
response = requests.get(url, headers=HTTP_HEADERS, timeout=(5, 15)) try:
if response.status_code != 200: http_response = requests.get(url, headers=HTTP_HEADERS, timeout=(5, 15))
if not http_response.ok:
logging.warning(f"HTTP {http_response.status_code} when calling Giro ionosonde API.")
return None, None, None
return self._parse_all(http_response.text)
except ConnectionError:
logging.warning("Connection error when accessing Giro ionosonde API.")
return None, None, None return None, None, None
return self._parse_all(response.text)
@staticmethod @staticmethod
def _parse_all(text): def _parse_all(text):
-4
View File
@@ -18,10 +18,6 @@ class HamQSL(HTTPSolarConditionsProvider):
super().__init__(provider_config, URL, POLL_INTERVAL) super().__init__(provider_config, URL, POLL_INTERVAL)
def _http_response_to_solar_conditions(self, http_response): def _http_response_to_solar_conditions(self, http_response):
if http_response.status_code != 200:
logging.warning("HamQSL solar conditions API returned HTTP " + str(http_response.status_code))
return None
root = ElementTree.fromstring(http_response.text) root = ElementTree.fromstring(http_response.text)
sd = root.find("solardata") sd = root.find("solardata")
if sd is None: if sd is None:
@@ -39,13 +39,20 @@ class HTTPSolarConditionsProvider(SolarConditionsProvider):
try: try:
logging.debug("Polling " + self.name + " solar conditions API...") logging.debug("Polling " + self.name + " solar conditions API...")
http_response = requests.get(self._url, headers=HTTP_HEADERS, timeout=(5, 30)) http_response = requests.get(self._url, headers=HTTP_HEADERS, timeout=(5, 30))
# Check response code was good
if http_response.ok:
new_data = self._http_response_to_solar_conditions(http_response) new_data = self._http_response_to_solar_conditions(http_response)
self.update_data(new_data) self.update_data(new_data)
self.status = "OK" self.status = "OK"
self.last_update_time = datetime.now(pytz.UTC) self.last_update_time = datetime.now(pytz.UTC)
logging.debug("Received data from " + self.name + " solar conditions API.") logging.debug("Received data from " + self.name + " solar conditions API.")
else:
self.status = "Error"
logging.warning(f"HTTP {http_response.status_code} when calling {self.name} solar conditions API.")
except ConnectionError:
logging.warning(f"Connection error when accessing {self.name} solar conditions API.")
except Exception: except Exception:
self.status = "Error" self.status = "Error"
logging.exception("Exception in HTTP Solar Conditions Provider (" + self.name + ")") logging.exception("Exception in HTTP Solar Conditions Provider (" + self.name + ")")
+6 -4
View File
@@ -44,9 +44,9 @@ class KC2GProp(SolarConditionsProvider):
def _poll(self): def _poll(self):
try: try:
logging.debug("Polling KC2G ionosonde data...") logging.debug("Polling KC2G ionosonde data...")
response = requests.get(KC2G_URL, headers=HTTP_HEADERS, timeout=(5, 30)) http_response = requests.get(KC2G_URL, headers=HTTP_HEADERS, timeout=(5, 30))
if response.status_code != 200: if not http_response.ok:
logging.warning(f"KC2G ionosonde API returned HTTP {response.status_code}") logging.warning(f"HTTP {http_response.status_code} when calling KG2G ionosonde API.")
return return
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
@@ -57,7 +57,7 @@ class KC2GProp(SolarConditionsProvider):
ionosonde_data = dict(self._solar_conditions.ionosonde_data or {}) ionosonde_data = dict(self._solar_conditions.ionosonde_data or {})
updated_count = 0 updated_count = 0
for reading in response.json(): for reading in http_response.json():
station = reading.get("station", {}) station = reading.get("station", {})
ursi = station.get("code") ursi = station.get("code")
name = station.get("name") name = station.get("name")
@@ -115,6 +115,8 @@ class KC2GProp(SolarConditionsProvider):
self.last_update_time = datetime.now(pytz.UTC) self.last_update_time = datetime.now(pytz.UTC)
logging.debug(f"Updated KC2G ionosonde data for {updated_count} stations.") logging.debug(f"Updated KC2G ionosonde data for {updated_count} stations.")
except ConnectionError:
logging.warning("Connection error when accessing KC2G ionosonde API.")
except Exception: except Exception:
self.status = "Error" self.status = "Error"
logging.exception("Exception in KC2G ionosonde data provider") logging.exception("Exception in KC2G ionosonde data provider")
@@ -80,10 +80,6 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider):
return result if result else None return result if result else None
def _http_response_to_solar_conditions(self, http_response): def _http_response_to_solar_conditions(self, http_response):
if http_response.status_code != 200:
logging.warning("NOAA K-index forecast API returned HTTP " + str(http_response.status_code))
return None
lines = http_response.text.splitlines() lines = http_response.text.splitlines()
# Find the "NOAA Kp index breakdown" section header # Find the "NOAA Kp index breakdown" section header
+3 -3
View File
@@ -8,7 +8,7 @@ import sys
from diskcache import Cache from diskcache import Cache
from core.cleanup import CleanupTimer from core.cleanup import CleanupTimer
from core.config import config, SERVER_OWNER_CALLSIGN from core.config import config, SERVER_OWNER_CALLSIGN, LOG_LEVEL
from core.constants import SOFTWARE_VERSION from core.constants import SOFTWARE_VERSION
from core.lookup_helper import lookup_helper from core.lookup_helper import lookup_helper
from core.status_reporter import StatusReporter from core.status_reporter import StatusReporter
@@ -84,9 +84,9 @@ def get_solar_conditions_provider_from_config(config_providers_entry):
if __name__ == '__main__': if __name__ == '__main__':
# Set up logging # Set up logging
root = logging.getLogger() root = logging.getLogger()
root.setLevel(logging.INFO) root.setLevel(LOG_LEVEL)
handler = logging.StreamHandler(sys.stdout) handler = logging.StreamHandler(sys.stdout)
handler.setLevel(logging.INFO) handler.setLevel(LOG_LEVEL)
formatter = logging.Formatter("%(levelname)s : %(message)s") formatter = logging.Formatter("%(levelname)s : %(message)s")
handler.setFormatter(formatter) handler.setFormatter(formatter)
root.handlers.clear() root.handlers.clear()
+25 -13
View File
@@ -14,9 +14,9 @@ class GMA(HTTPSpotProvider):
"""Spot provider for General Mountain Activity""" """Spot provider for General Mountain Activity"""
POLL_INTERVAL_SEC = 120 POLL_INTERVAL_SEC = 120
SPOTS_URL = "https://www.cqgma.org/api/spots/25/" SPOTS_URL = "https://www.gma.rocks/api/spots/25/"
# GMA spots don't contain the details of the programme they are for, we need a separate lookup for that # GMA spots don't contain the details of the programme they are for, we need a separate lookup for that
REF_INFO_URL_ROOT = "https://www.cqgma.org/api/ref/?" REF_INFO_URL_ROOT = "https://www.gma.rocks/api/ref/?"
def __init__(self, provider_config): def __init__(self, provider_config):
# Ensure there is an API key in our config, and set up the query URL using it. If no key is provided, # Ensure there is an API key in our config, and set up the query URL using it. If no key is provided,
@@ -37,34 +37,38 @@ class GMA(HTTPSpotProvider):
spot = Spot(source=self.name, spot = Spot(source=self.name,
dx_call=source_spot["ACTIVATOR"].upper(), dx_call=source_spot["ACTIVATOR"].upper(),
de_call=source_spot["SPOTTER"].upper(), de_call=source_spot["SPOTTER"].upper(),
freq=float(source_spot["QRG"]) * 1000 if (source_spot["QRG"] != "") else None, # Seen GMA spots with no frequency or with "QRT" in this field
# Seen GMA spots with no frequency freq=float(source_spot["QRG"]) * 1000 if (
mode=source_spot["MODE"].upper() if "<>" not in source_spot["MODE"] else None, source_spot["QRG"] != "" and source_spot["QRG"] != "QRT") else None,
# Filter out some weird mode strings # Filter out some weird mode strings
mode=source_spot["MODE"].upper() if "<>" not in source_spot["MODE"] else None,
comment=source_spot["TEXT"], comment=source_spot["TEXT"],
sig_refs=[SIGRef(id=source_spot["REF"], sig="", name=source_spot["NAME"])], sig_refs=[SIGRef(id=source_spot["REF"], sig="", name=source_spot["NAME"])],
time=datetime.strptime(source_spot["DATE"] + source_spot["TIME"], "%Y%m%d%H%M").replace( time=datetime.strptime(source_spot["DATE"] + source_spot["TIME"], "%Y%m%d%H%M").replace(
tzinfo=pytz.UTC).timestamp(), tzinfo=pytz.UTC).timestamp(),
# Seen GMA spots with no (or empty) lat/lon
dx_latitude=float(source_spot["LAT"]) if ( dx_latitude=float(source_spot["LAT"]) if (
source_spot["LAT"] and source_spot["LAT"] != "") else None, source_spot["LAT"] and source_spot["LAT"] != "") else None,
# Seen GMA spots with no (or empty) lat/lon
dx_longitude=float(source_spot["LON"]) if ( dx_longitude=float(source_spot["LON"]) if (
source_spot["LON"] and source_spot["LON"] != "") else None) source_spot["LON"] and source_spot["LON"] != "") else None,
qrt=source_spot["QRG"] == "QRT")
# GMA doesn't give what programme (SIG) the reference is for until we separately look it up. # GMA doesn't give what programme (SIG) the reference is for until we separately look it up.
if "REF" in source_spot: if "REF" in source_spot:
try: try:
ref_response = SEMI_STATIC_URL_DATA_CACHE.get(self.REF_INFO_URL_ROOT + source_spot["REF"], ref_response = SEMI_STATIC_URL_DATA_CACHE.get(self.REF_INFO_URL_ROOT + source_spot["REF"],
headers=HTTP_HEADERS) headers=HTTP_HEADERS)
# Sometimes this is blank, so handle that # Sometimes this is blank even if it's a 200 response, so handle that
if ref_response.text is not None and ref_response.text != "": if ref_response.ok and ref_response.text is not None and ref_response.text != "":
ref_info = ref_response.json() ref_info = ref_response.json()
# If this is POTA, SOTA or WWFF data we already have it through other means, so ignore. POTA and WWFF # If this is POTA, SOTA or WWFF data we already have it through other means, so ignore. POTA and WWFF
# spots come through with reftype=POTA or reftype=WWFF. SOTA is harder to figure out because both SOTA # spots come through with reftype=POTA or reftype=WWFF. SOTA is harder to figure out because both SOTA
# and GMA summits come through with reftype=Summit, so we must check for the presence of a "sota" entry # and GMA summits come through with reftype=Summit, so we must check for the presence of a "sota" entry
# to determine if it's a SOTA summit. # to determine if it's a SOTA summit.
if spot.sig_refs and "reftype" in ref_info and ref_info["reftype"] not in ["POTA", "WWFF"] and ( if spot.sig_refs and "reftype" in ref_info and ref_info["reftype"] not in ["POTA",
ref_info["reftype"] != "Summit" or "sota" not in ref_info or ref_info["sota"] == ""): "WWFF"] and (
ref_info["reftype"] != "Summit" or "sota" not in ref_info or ref_info[
"sota"] == ""):
match ref_info["reftype"]: match ref_info["reftype"]:
case "Summit": case "Summit":
spot.sig_refs[0].sig = "GMA" spot.sig_refs[0].sig = "GMA"
@@ -90,9 +94,17 @@ class GMA(HTTPSpotProvider):
spot.sig_refs[0].sig = ref_info["reftype"] spot.sig_refs[0].sig = ref_info["reftype"]
spot.sig = ref_info["reftype"] spot.sig = ref_info["reftype"]
# Add to our list. Don't worry about de-duping, removing old spots etc. at this point; other code will do # Add to our list. Don't worry about de-duping, removing old spots etc. at this point;
# that for us. # other code will do that for us.
new_spots.append(spot) new_spots.append(spot)
elif not ref_response.from_cache:
if not ref_response.ok:
logging.warning(
f"HTTP {ref_response.status_code} when looking up GMA ref {source_spot["REF"]}")
else:
logging.warning(
f"GMA API returned a malformed response when looking up ref {source_spot["REF"]}")
except: except:
logging.warning("Exception when looking up " + self.REF_INFO_URL_ROOT + source_spot[ logging.warning("Exception when looking up " + self.REF_INFO_URL_ROOT + source_spot[
"REF"] + ", ignoring this spot for now") "REF"] + ", ignoring this spot for now")
+6 -2
View File
@@ -1,3 +1,4 @@
import logging
import re import re
from datetime import datetime from datetime import datetime
@@ -35,6 +36,7 @@ class HEMA(HTTPSpotProvider):
new_spots = [] new_spots = []
# OK, if the spot seed actually changed, now we make the real request for data. # OK, if the spot seed actually changed, now we make the real request for data.
if spot_seed_changed: if spot_seed_changed:
try:
source_data = requests.get(self.SPOTS_URL, headers=HTTP_HEADERS, timeout=(5, 30)) source_data = requests.get(self.SPOTS_URL, headers=HTTP_HEADERS, timeout=(5, 30))
source_data_items = source_data.text.split("=") source_data_items = source_data.text.split("=")
# Iterate through source data items. # Iterate through source data items.
@@ -62,7 +64,9 @@ class HEMA(HTTPSpotProvider):
dx_latitude=float(spot_items[7]), dx_latitude=float(spot_items[7]),
dx_longitude=float(spot_items[8])) dx_longitude=float(spot_items[8]))
# Add to our list. Don't worry about de-duping, removing old spots etc. at this point; other code will do # Add to our list. Don't worry about de-duping, removing old spots etc. at this point; other
# that for us. # code will do that for us.
new_spots.append(spot) new_spots.append(spot)
except ConnectionError:
logging.warning("Connection error when accessing HEMA spots API.")
return new_spots return new_spots
+7
View File
@@ -41,6 +41,8 @@ class HTTPSpotProvider(SpotProvider):
# Request data from API # Request data from API
logging.debug("Polling " + self.name + " spot API...") logging.debug("Polling " + self.name + " spot API...")
http_response = requests.get(self._url, headers=HTTP_HEADERS, timeout=(5, 30)) http_response = requests.get(self._url, headers=HTTP_HEADERS, timeout=(5, 30))
# Check response code was good
if http_response.ok:
# Pass off to the subclass for processing # Pass off to the subclass for processing
new_spots = self._http_response_to_spots(http_response) new_spots = self._http_response_to_spots(http_response)
# Submit the new spots for processing. There might not be any spots for the less popular programs. # Submit the new spots for processing. There might not be any spots for the less popular programs.
@@ -50,7 +52,12 @@ class HTTPSpotProvider(SpotProvider):
self.status = "OK" self.status = "OK"
self.last_update_time = datetime.now(pytz.UTC) self.last_update_time = datetime.now(pytz.UTC)
logging.debug("Received data from " + self.name + " spot API.") logging.debug("Received data from " + self.name + " spot API.")
else:
self.status = "Error"
logging.warning(f"HTTP {http_response.status_code} when calling {self.name} spot API.")
except ConnectionError:
logging.warning(f"Connection error when accessing {self.name} spots API.")
except Exception: except Exception:
self.status = "Error" self.status = "Error"
logging.exception("Exception in HTTP JSON Spot Provider (" + self.name + ")") logging.exception("Exception in HTTP JSON Spot Provider (" + self.name + ")")
+2 -2
View File
@@ -30,8 +30,8 @@ class ParksNPeaks(HTTPSpotProvider):
dx_call=source_spot["actCallsign"].upper(), dx_call=source_spot["actCallsign"].upper(),
de_call=source_spot["actSpoter"].upper() if source_spot["actSpoter"] != "" else None, de_call=source_spot["actSpoter"].upper() if source_spot["actSpoter"] != "" else None,
# typo exists in API # typo exists in API
freq=float(source_spot["actFreq"].replace(",", "")) * 1000000 if ( freq=float(source_spot["actFreq"].replace(",", "").replace("+-", "")
source_spot["actFreq"] != "") else None, .replace("+/-", "").strip()) * 1000000 if (source_spot["actFreq"] != "") else None,
# Seen PNP spots with empty frequency, and with comma-separated thousands digits # Seen PNP spots with empty frequency, and with comma-separated thousands digits
mode=source_spot["actMode"].upper(), mode=source_spot["actMode"].upper(),
comment=source_spot["actComments"], comment=source_spot["actComments"],
+4
View File
@@ -1,3 +1,4 @@
import logging
from datetime import datetime from datetime import datetime
import requests import requests
@@ -33,6 +34,7 @@ class SOTA(HTTPSpotProvider):
new_spots = [] new_spots = []
# OK, if the epoch actually changed, now we make the real request for data. # OK, if the epoch actually changed, now we make the real request for data.
if epoch_changed: if epoch_changed:
try:
source_data = requests.get(self.SPOTS_URL, headers=HTTP_HEADERS, timeout=(5, 30)).json() source_data = requests.get(self.SPOTS_URL, headers=HTTP_HEADERS, timeout=(5, 30)).json()
# Iterate through source data # Iterate through source data
for source_spot in source_data: for source_spot in source_data:
@@ -55,4 +57,6 @@ class SOTA(HTTPSpotProvider):
# Add to our list. Don't worry about de-duping, removing old spots etc. at this point; other code will do # Add to our list. Don't worry about de-duping, removing old spots etc. at this point; other code will do
# that for us. # that for us.
new_spots.append(spot) new_spots.append(spot)
except ConnectionError:
logging.warning("Connection error when accessing SOTA spots API")
return new_spots return new_spots
@@ -6,7 +6,7 @@ from data.spot import Spot
from spotproviders.http_spot_provider import HTTPSpotProvider from spotproviders.http_spot_provider import HTTPSpotProvider
class WWTOTA(HTTPSpotProvider): class Towers(HTTPSpotProvider):
"""Spot provider for Towers on the Air""" """Spot provider for Towers on the Air"""
POLL_INTERVAL_SEC = 120 POLL_INTERVAL_SEC = 120
+3 -3
View File
@@ -101,9 +101,9 @@
(MOTA), the Amateur Radio Lighthouse Socirty (ARLHS), International Lighthouse Lightship Weekend (ILLW), Silos (MOTA), the Amateur Radio Lighthouse Socirty (ARLHS), International Lighthouse Lightship Weekend (ILLW), Silos
on the Air (SIOTA), World Castles Award (WCA), New Zealand on the Air (ZLOTA), Keith Roget Memorial National on the Air (SIOTA), World Castles Award (WCA), New Zealand on the Air (ZLOTA), Keith Roget Memorial National
Parks Award (KRMNPA), Wainwrights on the Air (WOTA), Beaches on the Air (BOTA), Lagos y Lagunas On the Air Parks Award (KRMNPA), Wainwrights on the Air (WOTA), Beaches on the Air (BOTA), Lagos y Lagunas On the Air
(LLOTA), Towers on the Air (WWTOTA), Tiles on the Air, Worked All Britain (WAB), Worked All Ireland (WAI), and (LLOTA), Towers on the Air (WWTOTA), Tiles on the Air, Worked All Britain (WAB), Worked All Ireland (WAI), el
Toilets on the Air (TOTA).</p> Diploma Municipios de España (DME) and Toilets on the Air (TOTA).</p>
<p>As of the time of writing in November 2025, I think Spothole captures essentially all outdoor radio programmes <p>As of the time of writing in June 2026, I think Spothole captures essentially all outdoor radio programmes
that have a defined reference list, and almost certainly those that have a spotting/alerting API. If you know of that have a defined 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> one I've missed, please let me know!</p>
<h4 class="mt-4">Why can I filter spots by both SIG and Source? Isn't that basically the same thing?</h4> <h4 class="mt-4">Why can I filter spots by both SIG and Source? Isn't that basically the same thing?</h4>
+1 -1
View File
@@ -76,7 +76,7 @@
</div> </div>
<script src="/js/add-spot.js?v=1782076701"></script> <script src="/js/add-spot.js?v=1784968398"></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
@@ -75,7 +75,7 @@
</div> </div>
<script src="/js/alerts.js?v=1782076701"></script> <script src="/js/alerts.js?v=1784968398"></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>
+3 -5
View File
@@ -3,9 +3,7 @@
<div class="mt-3"> <div class="mt-3">
<div id="settingsButtonRow" class="row mb-3"> <div id="settingsButtonRow" class="row mb-3">
<div class="col-auto me-auto pt-3"> <div class="col-auto me-auto pt-3"></div>
{% module Template("widgets/refresh-timer.html", web_ui_options=web_ui_options) %}
</div>
<div class="col-auto"> <div class="col-auto">
<div class="d-inline-flex gap-1"> <div class="d-inline-flex gap-1">
{% module Template("widgets/filters-display-data-buttons.html", web_ui_options=web_ui_options) %} {% module Template("widgets/filters-display-data-buttons.html", web_ui_options=web_ui_options) %}
@@ -77,8 +75,8 @@
<script> <script>
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %}; let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
</script> </script>
<script src="/js/spotsbandsandmap.js?v=1782076701"></script> <script src="/js/spotsbandsandmap.js?v=1784968398"></script>
<script src="/js/bands.js?v=1782076701"></script> <script src="/js/bands.js?v=1784968398"></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="/css/style.css?v=1782076701" type="text/css"> <link rel="stylesheet" href="/css/style.css?v=1784968398" type="text/css">
<link href="/vendor/css/bootstrap-5.3.8.min.css" rel="stylesheet"> <link href="/vendor/css/bootstrap-5.3.8.min.css" rel="stylesheet">
<link href="/vendor/css/fontawesome-6.7.2.min.css" rel="stylesheet"> <link href="/vendor/css/fontawesome-6.7.2.min.css" rel="stylesheet">
<link href="/vendor/css/solid-6.7.2.min.css" rel="stylesheet"> <link href="/vendor/css/solid-6.7.2.min.css" rel="stylesheet">
@@ -10,10 +10,10 @@
<script src="/vendor/js/bootstrap-5.3.8.bundle.min.js"></script> <script src="/vendor/js/bootstrap-5.3.8.bundle.min.js"></script>
<script src="/vendor/js/tinycolor2-1.6.0.min.js"></script> <script src="/vendor/js/tinycolor2-1.6.0.min.js"></script>
<script src="/js/utils.js?v=1782076701"></script> <script src="/js/utils.js?v=1784968398"></script>
<script src="/js/ui-ham.js?v=1782076701"></script> <script src="/js/ui-ham.js?v=1784968398"></script>
<script src="/js/geo.js?v=1782076701"></script> <script src="/js/geo.js?v=1784968398"></script>
<script src="/js/common.js?v=1782076701"></script> <script src="/js/common.js?v=1784968398"></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="/vendor/js/chart-4.4.9.umd.min.js"></script> <script src="/vendor/js/chart-4.4.9.umd.min.js"></script>
<script src="/js/conditions.js?v=1782076701"></script> <script src="/js/conditions.js?v=1784968398"></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
@@ -95,8 +95,8 @@
<script> <script>
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %}; let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
</script> </script>
<script src="/js/spotsbandsandmap.js?v=1782076701"></script> <script src="/js/spotsbandsandmap.js?v=1784968398"></script>
<script src="/js/map.js?v=1782076701"></script> <script src="/js/map.js?v=1784968398"></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
@@ -116,8 +116,8 @@
<script> <script>
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %}; let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
</script> </script>
<script src="/js/spotsbandsandmap.js?v=1782076701"></script> <script src="/js/spotsbandsandmap.js?v=1784968398"></script>
<script src="/js/spots.js?v=1782076701"></script> <script src="/js/spots.js?v=1784968398"></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
@@ -59,7 +59,7 @@
</div> </div>
</div> </div>
<script src="/js/status.js?v=1782076701"></script> <script src="/js/status.js?v=1784968398"></script>
<script> <script>
$(document).ready(function () { $(document).ready(function () {
$("#nav-link-status").addClass("active"); $("#nav-link-status").addClass("active");
+20 -4
View File
@@ -18,6 +18,11 @@ info:
### 1.4 ### 1.4
* Spots can now include a "propagation_mode" field, and the `/options` call enumerates the options that can have. * Spots can now include a "propagation_mode" field, and the `/options` call enumerates the options that can have.
* Added support for the Diploma de Municipios Españoles (DME) SIG
* Renamed some SIGs to avoid confusion between Towers, Tiles and Toilets
* Added `comment_names` to SIGs in the `/options`, to reflect how they might be referred to in spot comments where
it differs from their `name`.
* Added `propagation_mode` field to spots
### 1.3 ### 1.3
@@ -771,7 +776,7 @@ components:
- ZLOTA - ZLOTA
- WOTA - WOTA
- LLOTA - LLOTA
- WWTOTA - Towers
- Tiles - Tiles
- Cluster - Cluster
- RBN - RBN
@@ -790,7 +795,7 @@ components:
- HEMA - HEMA
- WCA - WCA
- MOTA - MOTA
- SIOTA - SiOTA
- ARLHS - ARLHS
- ILLW - ILLW
- ZLOTA - ZLOTA
@@ -799,11 +804,12 @@ components:
- WOTA - WOTA
- BOTA - BOTA
- LLOTA - LLOTA
- WWTOTA - Towers
- Tiles - Tiles
- WAB - WAB
- WAI - WAI
- TOTA - DME
- Toilets
example: POTA example: POTA
SIGNameIncludingNoSIG: SIGNameIncludingNoSIG:
@@ -1373,6 +1379,16 @@ components:
type: string type: string
description: The full name of the SIG description: The full name of the SIG
example: Parks on the Air example: Parks on the Air
comment_names:
type: array
description: >
Names by which this SIG may be referred to in cluster spot comments. Most SIGs have a
single entry matching their programme name (e.g. ["POTA"]), but some have none (where
the name is ambiguous with other SIGs, such as Tiles and Toilets on the Air) or multiple
entries (e.g. WWBOTA accepts both "WWBOTA" and "BOTA" since the latter is often used).
items:
type: string
example: [ "TOTA" ]
ref_regex: ref_regex:
type: string type: string
description: > description: >
+1 -1
View File
@@ -62,7 +62,7 @@ function addSpot() {
spot["sig"] = sig; spot["sig"] = sig;
} }
if (sigRef !== "") { if (sigRef !== "") {
spot["sig_refs"] = [{id: sigRef}]; spot["sig_refs"] = [{sig: sig, id: sigRef}];
} }
if (dxGrid !== "") { if (dxGrid !== "") {
spot["dx_grid"] = dxGrid; spot["dx_grid"] = dxGrid;
+23
View File
@@ -1,5 +1,7 @@
// How often to query the server? // How often to query the server?
const REFRESH_INTERVAL_SEC = 60 * 10; const REFRESH_INTERVAL_SEC = 60 * 10;
// Last time the alerts list was updated on display.
let lastUpdateTime;
// Storage for the alert data that the server gives us. // Storage for the alert data that the server gives us.
let alerts = []; let alerts = [];
@@ -309,6 +311,27 @@ function filtersUpdated() {
saveSettings(); saveSettings();
} }
// Update the refresh timing display
function updateRefreshDisplay() {
if (lastUpdateTime != null) {
let secSinceUpdate = moment.duration(moment().diff(lastUpdateTime)).asSeconds();
let count = REFRESH_INTERVAL_SEC;
let updatingString = "Updating..."
if (secSinceUpdate < REFRESH_INTERVAL_SEC) {
count = REFRESH_INTERVAL_SEC - secSinceUpdate;
let number;
if (count <= 60) {
number = count.toFixed(0);
updatingString = "<span class='nowrap'>Updating in " + number + " second" + (number !== "1" ? "s" : "") + ".</span>";
} else {
number = Math.round(count / 60.0).toFixed(0);
updatingString = "<span class='nowrap'>Updating in " + number + " minute" + (number !== "1" ? "s" : "") + ".</span>";
}
}
$("#timing-container").html("Last updated at " + lastUpdateTime.format('HH:mm') + " UTC. " + updatingString);
}
}
// Startup // Startup
$(document).ready(function () { $(document).ready(function () {
// Call loadOptions(), this will then trigger loading alerts and setting up timers. // Call loadOptions(), this will then trigger loading alerts and setting up timers.
+67 -17
View File
@@ -1,5 +1,9 @@
// How often to query the server? // SSE connection for live spot updates
const REFRESH_INTERVAL_SEC = 60; let evtSource;
let restartSSEOnErrorTimeoutId;
// Debounce timer so rapid SSE bursts only trigger one updateBands() call, as these could trigger a flash of the user
// visible content
let updateBandsDebounceId;
// A couple of constants that must match what's in CSS. We need to know them before the content actually renders, so we // A couple of constants that must match what's in CSS. We need to know them before the content actually renders, so we
// can't just ask the elements themselves for their dimensions. // can't just ask the elements themselves for their dimensions.
@@ -12,19 +16,62 @@ BAND_COLUMN_SPOT_DIV_HEIGHT_PX = BAND_COLUMN_FONT_SIZE * 1.6;
// Load spots and populate the bands display. // Load spots and populate the bands display.
function loadSpots() { function loadSpots() {
$.getJSON('/api/v1/spots' + buildQueryString(false), function (jsonData) { // Close any existing SSE connection before fetching fresh data
// Store last updated time if (evtSource != null) {
lastUpdateTime = moment.utc(); evtSource.close();
updateRefreshDisplay(); }
$.getJSON('/api/v1/spots' + buildQueryString(), function (jsonData) {
// Store data // Store data
spots = jsonData; spots = jsonData;
// Update bands display // Update bands display
updateBands(); updateBands();
// Start the ongoing SSE connection
startSSEConnection();
}); });
} }
// Build a query string for the API, based on the filters that the user has selected. // Start an SSE connection to receive new spots as they arrive.
function buildQueryString(includeCredentials) { function startSSEConnection() {
if (evtSource != null) {
evtSource.close();
}
evtSource = new EventSource('/api/v1/spots/stream' + buildQueryString());
evtSource.onmessage = function (event) {
const newSpot = JSON.parse(event.data);
// Replace any existing spot for this callsign
spots = spots.filter(s => newSpot["dx_call"] !== s["dx_call"]);
spots.unshift(newSpot);
// Debounce. Wait 500ms after the last message before re-rendering to avoid too many ugly flashes
clearTimeout(updateBandsDebounceId);
updateBandsDebounceId = setTimeout(updateBands, 5000);
};
evtSource.onerror = function () {
if (evtSource != null) {
evtSource.close();
}
clearTimeout(restartSSEOnErrorTimeoutId);
restartSSEOnErrorTimeoutId = setTimeout(startSSEConnection, 1000);
};
}
// Remove spots from the display that are older than the selected max age.
function expireOldSpots() {
const maxAgeSeconds = parseInt($("#max-spot-age option:selected").val());
const cutoff = (Date.now() / 1000) - maxAgeSeconds;
const before = spots.length;
spots = spots.filter(s => s["time"] && s["time"] >= cutoff);
if (spots.length !== before) {
updateBands();
}
}
// Build a query string for the API, based on the filters that the user has selected. There's no need for credentials
// in the bands page's version of this, because nothing QRZ.com/HamQTH can provide will affect the display.
function buildQueryString() {
let str = "?"; let str = "?";
["dx_continent", "de_continent", "mode", "source", "band", "sig"].forEach(fn => { ["dx_continent", "de_continent", "mode", "source", "band", "sig"].forEach(fn => {
if (!allFilterOptionsSelected(fn)) { if (!allFilterOptionsSelected(fn)) {
@@ -34,9 +81,6 @@ function buildQueryString(includeCredentials) {
str = str + "max_age=" + $("#max-spot-age option:selected").val(); str = str + "max_age=" + $("#max-spot-age option:selected").val();
// Additional filters for the bands view: No dupes, no QRT // Additional filters for the bands view: No dupes, no QRT
str = str + "&dedupe=true&allow_qrt=false"; str = str + "&dedupe=true&allow_qrt=false";
if (includeCredentials) {
str = str + getCredentialQueryString();
}
return str; return str;
} }
@@ -257,22 +301,28 @@ function loadOptions() {
// Load settings from settings storage now all the controls are available // Load settings from settings storage now all the controls are available
loadSettings(); loadSettings();
// Load spots and set up the timer // Load spots and start SSE for live updates
loadSpots(); loadSpots();
setInterval(loadSpots, REFRESH_INTERVAL_SEC * 1000); // Set up spot expiry checker
setInterval(expireOldSpots, 60 * 1000);
}); });
} }
// Method called when any display property is changed to reload the map and persist the display settings. // Method called when any display property is changed to reload the bands display and persist settings.
function displayUpdated() { function displayUpdated() {
updateMap(); updateBands();
saveSettings(); saveSettings();
} }
// Startup // Startup
$(document).ready(function () { $(document).ready(function () {
// Close SSE connection cleanly when navigating away
window.addEventListener('beforeunload', function () {
if (evtSource != null) {
evtSource.close();
}
});
// Call loadOptions(), this will then trigger loading spots and setting up timers. // Call loadOptions(), this will then trigger loading spots and setting up timers.
loadOptions(); loadOptions();
// Update the refresh timing display every second
setInterval(updateRefreshDisplay, 1000);
}); });
-23
View File
@@ -1,7 +1,5 @@
// Storage for the options that the server gives us. This will define our filters. // Storage for the options that the server gives us. This will define our filters.
let options = {}; let options = {};
// Last time we updated the spots/alerts list on display.
let lastUpdateTime;
// Normally load user settings from local storage, unless embedded mode is in use // Normally load user settings from local storage, unless embedded mode is in use
let useLocalStorage = true; let useLocalStorage = true;
@@ -145,27 +143,6 @@ function toggleFilterButtons(filterQuery, state) {
filtersUpdated(); filtersUpdated();
} }
// Update the refresh timing display
function updateRefreshDisplay() {
if (lastUpdateTime != null) {
let secSinceUpdate = moment.duration(moment().diff(lastUpdateTime)).asSeconds();
let count = REFRESH_INTERVAL_SEC;
let updatingString = "Updating..."
if (secSinceUpdate < REFRESH_INTERVAL_SEC) {
count = REFRESH_INTERVAL_SEC - secSinceUpdate;
let number;
if (count <= 60) {
number = count.toFixed(0);
updatingString = "<span class='nowrap'>Updating in " + number + " second" + (number !== "1" ? "s" : "") + ".</span>";
} else {
number = Math.round(count / 60.0).toFixed(0);
updatingString = "<span class='nowrap'>Updating in " + number + " minute" + (number !== "1" ? "s" : "") + ".</span>";
}
}
$("#timing-container").html("Last updated at " + lastUpdateTime.format('HH:mm') + " UTC. " + updatingString);
}
}
// When the "use local time" field is changed, reload the table and save settings // When the "use local time" field is changed, reload the table and save settings
function timeZoneUpdated() { function timeZoneUpdated() {
updateTable(); updateTable();
+124 -24
View File
@@ -1,6 +1,3 @@
// How often to query the server?
const REFRESH_INTERVAL_SEC = 60;
// Colours // Colours
const MAIDENHEAD_GRID_COLOR_LIGHT = 'rgba(200, 140, 140, 1.0)'; const MAIDENHEAD_GRID_COLOR_LIGHT = 'rgba(200, 140, 140, 1.0)';
const CQ_ZONES_COLOR_LIGHT = 'rgba(140, 200, 140, 1.0)'; const CQ_ZONES_COLOR_LIGHT = 'rgba(140, 200, 140, 1.0)';
@@ -11,6 +8,15 @@ const CQ_ZONES_COLOR_DARK = 'rgba(60, 120, 60, 1.0)';
const ITU_ZONES_COLOR_DARK = 'rgba(120, 120, 60, 1.0)'; const ITU_ZONES_COLOR_DARK = 'rgba(120, 120, 60, 1.0)';
const WAB_WAI_GRID_COLOR_DARK = 'rgba(60, 60, 120, 1.0)'; const WAB_WAI_GRID_COLOR_DARK = 'rgba(60, 60, 120, 1.0)';
// SSE connection for live spot updates
let evtSource;
let restartSSEOnErrorTimeoutId;
// Map dx_call to a pair of marker & geodesic line, so we can de-duplicate spots as they arrive and only show
// one marker per dx_call. The key here is actually dx_call + SSID if the spot gives us an SSID; this is mostly for
// if APRS spots are enabled so we can ID a home and mobile station separately rather than our marker oscillating
// between the two as updates come in.
let spotMarkers = new Map();
// Map layers // Map layers
let backgroundTileLayer; let backgroundTileLayer;
let markersLayer; let markersLayer;
@@ -28,7 +34,13 @@ let firstLoad = true;
// Load spots and populate the map. // Load spots and populate the map.
function loadSpots() { function loadSpots() {
$.getJSON('/api/v1/spots' + buildQueryString(true), function (jsonData) { // Close any existing SSE connection before fetching fresh data
if (evtSource != null) {
evtSource.close();
}
// On first fetch, don't include QRZ/HamQTH credentials. This will cause some inaccurate
// marker positions but avoids having to wait a minute or more for all the lookups to fire.
$.getJSON('/api/v1/spots' + buildQueryString(false), function (jsonData) {
// Store data // Store data
spots = jsonData; spots = jsonData;
// Update map // Update map
@@ -36,9 +48,102 @@ function loadSpots() {
if ($("#showTerminator")[0].checked) { if ($("#showTerminator")[0].checked) {
terminator.setTime(); terminator.setTime();
} }
// Start the ongoing SSE connection
startSSEConnection();
}); });
} }
// Start the SSE connection to receive new spots as they arrive
function startSSEConnection() {
if (evtSource != null) {
evtSource.close();
}
// SSE is going to fetch only a few spots at a time, so now we include QRZ/HamQTH credentials because the delay won't be significant.
evtSource = new EventSource('/api/v1/spots/stream' + buildQueryString(true));
evtSource.onmessage = function (event) {
const newSpot = JSON.parse(event.data);
const key = spotKey(newSpot);
// Remove existing marker/geodesic for this callsign if present
removeSpotFromMap(key);
spots = spots.filter(s => spotKey(s) !== key);
// Skip spots with no map coordinates
if (newSpot["dx_latitude"] == null || newSpot["dx_longitude"] == null) {
return;
}
// Add to data store and map
spots.unshift(newSpot);
addSpotToMap(newSpot);
};
evtSource.onerror = function () {
if (evtSource != null) {
evtSource.close();
}
clearTimeout(restartSSEOnErrorTimeoutId);
restartSSEOnErrorTimeoutId = setTimeout(startSSEConnection, 1000);
};
}
// Remove spots from the map that are older than the selected max age.
function expireOldSpots() {
const maxAgeSeconds = parseInt($("#max-spot-age option:selected").val());
const cutoff = (Date.now() / 1000) - maxAgeSeconds;
spots = spots.filter(function (s) {
if (s["time"] && s["time"] < cutoff) {
removeSpotFromMap(spotKey(s));
return false;
}
return true;
});
}
// Returns a unique key for a spot based on its callsign and SSID. This is in case the APRS spot source is enabled and
// we want to display all SSIDs for a given callsign, not just the latest one.
function spotKey(s) {
return s["dx_call"] + (s["dx_ssid"] ? "-" + s["dx_ssid"] : "");
}
// Add a single spot's marker and geodesic to the map and to spotMarkers
function addSpotToMap(s) {
const m = L.marker([s["dx_latitude"], s["dx_longitude"]], {icon: getIcon(s)});
m.bindPopup(getTooltipText(s));
markersLayer.addLayer(m);
oms.addMarker(m);
let geodesic = null;
if ($("#mapShowGeodesics")[0].checked && s["de_latitude"] != null && s["de_longitude"] != null) {
try {
geodesic = L.geodesic([[s["de_latitude"], s["de_longitude"]], m.getLatLng()], {
color: bandToColor(s['band']),
wrap: false,
steps: 5
});
geodesicsLayer.addLayer(geodesic);
} catch (e) {
// Not sure what causes these but better to continue than to crash out
}
}
spotMarkers.set(spotKey(s), {marker: m, geodesic: geodesic});
}
// Remove a spot's marker and geodesic from the map and from spotMarkers.
function removeSpotFromMap(key) {
const entry = spotMarkers.get(key);
if (entry) {
markersLayer.removeLayer(entry.marker);
oms.removeMarker(entry.marker);
if (entry.geodesic) {
geodesicsLayer.removeLayer(entry.geodesic);
}
spotMarkers.delete(key);
}
}
// Build a query string for the API, based on the filters that the user has selected. // Build a query string for the API, based on the filters that the user has selected.
function buildQueryString(includeCredentials) { function buildQueryString(includeCredentials) {
let str = "?"; let str = "?";
@@ -62,27 +167,14 @@ function updateMap() {
markersLayer.clearLayers(); markersLayer.clearLayers();
geodesicsLayer.clearLayers(); geodesicsLayer.clearLayers();
oms.clearMarkers(); oms.clearMarkers();
spotMarkers.clear();
// Make new markers for all spots that match the filter // Make new markers for all spots
spots.forEach(function (s) { spots.forEach(function (s) {
const m = L.marker([s["dx_latitude"], s["dx_longitude"]], {icon: getIcon(s)}); if (s["dx_latitude"] == null || s["dx_longitude"] == null) {
m.bindPopup(getTooltipText(s)); return;
markersLayer.addLayer(m);
oms.addMarker(m);
// Create geodesics if required
if ($("#mapShowGeodesics")[0].checked && s["de_latitude"] != null && s["de_longitude"] != null) {
try {
const geodesic = L.geodesic([[s["de_latitude"], s["de_longitude"]], m.getLatLng()], {
color: bandToColor(s['band']),
wrap: false,
steps: 5
});
geodesicsLayer.addLayer(geodesic);
} catch (e) {
// Not sure what causes these but better to continue than to crash out
}
} }
addSpotToMap(s);
}); });
// On first load, zoom to the extent of the markers // On first load, zoom to the extent of the markers
@@ -239,9 +331,10 @@ function loadOptions() {
enableITUZones($("#showITUZones")[0].checked); enableITUZones($("#showITUZones")[0].checked);
enableWABWAIGrid($("#showWABWAIGrid")[0].checked); enableWABWAIGrid($("#showWABWAIGrid")[0].checked);
// Load spots and set up the timer // Load spots and start SSE for live updates
loadSpots(); loadSpots();
setInterval(loadSpots, REFRESH_INTERVAL_SEC * 1000); // Set up spot expiry checker
setInterval(expireOldSpots, 60 * 1000);
}); });
} }
@@ -483,6 +576,13 @@ function setUpMap() {
// Startup // Startup
$(document).ready(function () { $(document).ready(function () {
// Close SSE connection cleanly when navigating away
window.addEventListener('beforeunload', function () {
if (evtSource != null) {
evtSource.close();
}
});
// Hide the extra things that need to be hidden on this page // Hide the extra things that need to be hidden on this page
$(".hideonmap").hide(); $(".hideonmap").hide();
// Set up map // Set up map
+2 -2
View File
@@ -332,12 +332,12 @@ const SIG_ICONS = {
"BOTA": "fa-umbrella-beach", "BOTA": "fa-umbrella-beach",
"KRMNPA": "fa-earth-oceania", "KRMNPA": "fa-earth-oceania",
"LLOTA": "fa-water", "LLOTA": "fa-water",
"WWTOTA": "fa-tower-observation", "Towers": "fa-tower-observation",
"WAB": "fa-table-cells-large", "WAB": "fa-table-cells-large",
"WAI": "fa-table-cells-large", "WAI": "fa-table-cells-large",
"DME": "fa-building", "DME": "fa-building",
"Tiles": "fa-square", "Tiles": "fa-square",
"TOTA": "fa-toilet" "Toilets": "fa-toilet"
} }
// Get the Font Awesome icon for a given SIG. If the SIG is unknown, the provided default symbol will be returned // Get the Font Awesome icon for a given SIG. If the SIG is unknown, the provided default symbol will be returned