11 Commits
Author SHA1 Message Date
Ian Renton 7dfcdb00e8 Merge branch 'main' into 2.0-pre
# Conflicts:
#	core/constants.py
#	templates/status.html
2026-08-05 20:34:26 +01:00
Ian Renton 5699627069 Docker config 2026-08-05 20:28:11 +01:00
Ian Renton 66081f32f9 Docker config 2026-08-05 20:24:10 +01:00
Ian Renton 4f8f748d88 Docker config 2026-08-05 20:14:30 +01:00
Ian Renton 62b3414d29 Refactor of caching & data storage part 17 #118 2026-08-04 22:11:34 +01:00
Ian Renton 459d999f57 Refactor of caching & data storage part 16 #118 2026-08-03 22:15:29 +01:00
Ian Renton 27d73c27d6 Refactor of caching & data storage part 15 #118 2026-08-03 19:43:38 +01:00
Ian Renton f82d611b7e Refactor of caching & data storage part 14 #118 2026-08-03 19:25:25 +01:00
Ian Renton 50ab27e4a2 Refactor of caching & data storage part 13 #118 2026-08-03 08:56:34 +01:00
Ian Renton e31b145b8e Refactor of caching & data storage part 12 #118 2026-08-02 21:57:22 +01:00
Ian Renton c2ecef1003 Release 1.4 2026-08-02 11:40:28 +01:00
55 changed files with 806 additions and 886 deletions
+7
View File
@@ -0,0 +1,7 @@
/.venv
__pycache__
*.pyc
/config.yml
/cache/
.git
.gitignore
+8
View File
@@ -0,0 +1,8 @@
FROM python:3.13-slim
WORKDIR /app
COPY . .
RUN pip install --no-cache-dir -r requirements.txt
EXPOSE 8080
CMD ["python3", "spothole.py"]
+30 -2
View File
@@ -166,12 +166,40 @@ source .venv/bin/activate
python3 spothole.py
```
The software can take a few seconds to start up, mostly because it is downloading an updated file to match callsigns to
countries. This is normal, don't panic!
The software can take a few seconds to start up, mostly because it is downloading updated files containing the various
bits of reference data it uses. This is normal, don't panic! Once you see `You can access your copy of Spothole at
http://localhost:8080` in the log, your server is good to go.
If you see some errors on startup, check your configuration, e.g. in case you have specified a port for the web server
that is already in use by something else.
### Running using Docker
Spothole comes with a Docker configuration to make it easy to run it in a containerised environment. To set it up using
Docker, the easiest way is to use a Docker Compose file. Create a new directory such as `/opt/docker/spothole` and
create a `compose.yaml` file inside it with the following contents:
```yaml
services:
spothole:
container_name: spothole
build:
context: https://git.ianrenton.com/ian/spothole.git#main
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- ./config.yml:/app/config.yml
- ./cache:/app/cache
```
Save the file. You will still need to create a copy of `config-example.yml` and name it `config.yml`, though with the
Docker setup nothing has actually been downloaded yet, so you will have to copy the example from the repository some
other way, e.g. [from the repo in a web browser](https://git.ianrenton.com/ian/spothole/src/branch/main/config-example.yml).
With that in place, run `docker compose up` and you should be good to go. To detach, press `d` or run the command with
the `-d` flag.
### Multiple cluster nodes with different settings
Dan, S50U has written in with his Spothole cluster settings. He is using a cluster node which provides RBN spots, and
+29 -6
View File
@@ -251,21 +251,44 @@ sig-ref-data-providers:
# Callsign data providers to use. These allow Spothole to provide information about callsigns, either from static
# lookups or from web-based services such as QRZ.
# lookups or from web-based services such as QRZ. Note that use of QRZ and HamQTH is *on behalf of the user*, and
# requires the user to enter their credentials into the website UI or provide them in an API call. Spothole will not
# look up all calls via QRZ/HamQTH using the server owner's credentials, as this is against their usage policy.
callsign-data-providers:
- class: "CountryFiles"
- class: "QRZ"
enabled: true
# Callsign data providers can often provide conflicting data, e.g. a home location from QRZ vs the centre of the
# DXCC entity from a country file lookup. The priority flag sets which source "wins" in the event of conflict.
# Lower numbers take priority over higher numbers. Generally then, QRZ/HamQTH should have low numbers as they are
# likely to have the most accurate data.
priority: 1
# No server-side credentials for QRZ. Users must provide their own as per QRZ policy.
- class: "HamQTH"
enabled: true
priority: 2
# No server-side credentials for HamQTH. Users must provide their own.
- class: "ClublogAPI"
# Querying the Clublog API directly doesn't provide any more data than the XML version, it just provides slightly
# more up-to-date information in the rare case that the prefix data changes, at a significant cost of looking up
# every callsign via an API call. Normally left disabled but it exists as an option.
enabled: false
priority: 3
# API key for Clublog to look up information. Required in order to enable this provider. Unlike QRZ and HamQTH,
# Clublog uses an API key issued to Spothole, not to the end user.
api-key: ""
- class: "ClublogXML"
enabled: true
priority: 4
# API key for Clublog to look up information. Required in order to enable this provider. You will need to request
# one via their helpdesk portal if you want to use callsign lookups from Clublog.
clublog-api-key: ""
api-key: ""
- class: "ClublogAPI"
- class: "CountryFiles"
priority: 5
enabled: true
# API key for Clublog to look up information. Required in order to enable this provider.
clublog-api-key: ""
# Maximum time to keep spots and alerts in the system before deleting them. By default, one hour for spots and one week
+30 -632
View File
@@ -1,632 +1,30 @@
import gzip
import logging
import urllib.parse
from datetime import timedelta
import xmltodict
from diskcache import Cache
from pyhamtools import LookupLib, Callinfo, callinfo
from pyhamtools.exceptions import APIKeyMissingError
from pyhamtools.locator import latlong_to_locator
from requests.exceptions import ConnectionError, ReadTimeout, ConnectTimeout
from requests_cache import CachedSession
from core.config import config
from core.constants import HTTP_HEADERS, HAMQTH_PRG
from core.data_store import DATA_STORE
from core.url_data_cache import URLDataCache
# QRZ XML field names differ from pyhamtools' normalised names; map them here.
_QRZ_FIELD_MAP = {
"lat": "latitude",
"lon": "longitude",
"grid": "locator",
"ituzone": "ituz",
"cqzone": "cqz",
}
_QRZ_INT_FIELDS = {"adif", "cqz", "ituz"}
_QRZ_FLOAT_FIELDS = {"latitude", "longitude"}
_URL_DATA_CACHE = URLDataCache("callsign_lookup")
def _normalize_qrz_data(raw):
data = {}
for k, v in raw.items():
if v is None:
continue
mapped_key = _QRZ_FIELD_MAP.get(k, k)
if mapped_key in _QRZ_INT_FIELDS:
try:
v = int(v)
except (ValueError, TypeError):
pass
elif mapped_key in _QRZ_FLOAT_FIELDS:
try:
v = float(v)
except (ValueError, TypeError):
pass
data[mapped_key] = v
return data
class LookupHelper:
"""Singleton class that provides lookup functionality."""
def __init__(self):
"""Create the lookup helper. Note that nothing actually happens until the start() method is called, and that all
lookup methods will fail if start() has not yet been called. This therefore needs starting before any spot or
alert handlers are created."""
self._clublog_callsign_data_cache = None
self._lookup_lib_clublog_xml = None
self._clublog_xml_available = None
self._lookup_lib_clublog_api = None
self._clublog_xml_download_location = None
self._clublog_api_available = None
self._clublog_cty_xml_cache = None
self._clublog_api_key = None
self._qrz_callsign_data_cache = None
self._qrz_base_url = "https://xmldata.qrz.com/xml/current/"
# QRZ session keys expire after an hour; cache the login response for 55 minutes.
self._qrz_session_cache = CachedSession("cache/qrz_session_cache",
expire_after=timedelta(minutes=55))
self._hamqth_callsign_data_cache = None
self._hamqth_base_url = "https://www.hamqth.com/xml.php"
# HamQTH session keys expire after an hour. Rather than working out how much time has passed manually, we cheat
# and cache the HTTP response for 55 minutes, so when the login URL is queried within 55 minutes of the previous
# time, you just get the cached response.
self._hamqth_session_lookup_cache = CachedSession("cache/hamqth_session_cache",
expire_after=timedelta(minutes=55))
self._call_info_basic = None
self._lookup_lib_basic = None
self._country_files_cty_plist_download_location = None
self._dxcc_json_download_location = None
def start(self):
# Lookup helpers from pyhamtools. We use five (!) of these. The simplest is country-files.com, which downloads
# the data once on startup, and requires no login/key, but does not have the best coverage.
# If the user provides login details/API keys, we also set up helpers for QRZ.com, HamQTH, Clublog (live API
# request), and Clublog (XML download). The lookup functions iterate through these in a sensible order, looking
# for suitable data.
self._country_files_cty_plist_download_location = "cache/cty.plist"
success = self._download_country_files_cty_plist()
if success:
self._lookup_lib_basic = LookupLib(lookuptype="countryfile",
filename=self._country_files_cty_plist_download_location)
else:
self._lookup_lib_basic = LookupLib(lookuptype="countryfile")
self._call_info_basic = Callinfo(self._lookup_lib_basic)
self._qrz_callsign_data_cache = Cache('cache/qrz_callsign_lookup_cache')
self._hamqth_callsign_data_cache = Cache('cache/hamqth_callsign_lookup_cache')
self._clublog_api_key = str(config["clublog-api-key"])
self._clublog_cty_xml_cache = CachedSession("cache/clublog_cty_xml_cache", expire_after=timedelta(days=10))
self._clublog_api_available = self._clublog_api_key != ""
self._clublog_xml_download_location = "cache/cty.xml"
if self._clublog_api_available:
self._lookup_lib_clublog_api = LookupLib(lookuptype="clublogapi", apikey=self._clublog_api_key)
success = self._download_clublog_ctyxml()
self._clublog_xml_available = success
if success:
self._lookup_lib_clublog_xml = LookupLib(lookuptype="clublogxml",
filename=self._clublog_xml_download_location)
self._clublog_callsign_data_cache = Cache('cache/clublog_callsign_lookup_cache')
def _download_country_files_cty_plist(self):
"""Download the cty.plist file from country-files.com on first startup. The pyhamtools lib can actually download and use
this itself, but it's occasionally offline which causes it to throw an error. By downloading it separately, we can
catch errors and handle them, falling back to a previous copy of the file in the cache, and we can use the
requests_cache library to prevent re-downloading too quickly if the software keeps restarting."""
try:
logging.info("Downloading Country-files.com cty.plist...")
response = _URL_DATA_CACHE.get("https://www.country-files.com/cty/cty.plist",
headers=HTTP_HEADERS)
if response.ok:
with open(self._country_files_cty_plist_download_location, "w") as f:
f.write(response.text)
f.flush()
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 (ConnectTimeout, ReadTimeout):
logging.warning(f"Timeout when downloading Clublog cty.xml.")
except Exception as e:
logging.error("Exception when downloading Clublog cty.xml", e)
return False
def _download_clublog_ctyxml(self):
"""Download the cty.xml (gzipped) file from Clublog on first startup, so we can use it in preference to querying the
database live if possible."""
try:
logging.info("Downloading Clublog cty.xml.gz...")
response = self._clublog_cty_xml_cache.get("https://cdn.clublog.org/cty.php?api=" + self._clublog_api_key,
headers=HTTP_HEADERS)
logging.info("Caching Clublog cty.xml.gz...")
open(self._clublog_xml_download_location + ".gz", 'wb').write(response.content)
with gzip.open(self._clublog_xml_download_location + ".gz", "rb") as uncompressed:
file_content = uncompressed.read()
assert isinstance(file_content, bytes)
logging.info("Caching Clublog cty.xml...")
with open(self._clublog_xml_download_location, "wb") as f:
f.write(file_content)
f.flush()
return True
except Exception as e:
logging.error("Exception when downloading Clublog cty.xml", e)
return False
def infer_country_from_callsign(self, call, credentials=None):
"""Infer a country name from a callsign"""
try:
# Start with the basic country-files.com-based decoder.
country = self._call_info_basic.get_country_name(call)
except (KeyError, ValueError):
country = None
# Couldn't get anything from basic call info database, try QRZ.com
if not country:
qrz_data = self._get_qrz_data_for_callsign(call, credentials)
if qrz_data and "country" in qrz_data:
country = qrz_data["country"]
# Couldn't get anything from QRZ.com database, try HamQTH
if not country:
hamqth_data = self._get_hamqth_data_for_callsign(call, credentials)
if hamqth_data and "country" in hamqth_data:
country = hamqth_data["country"]
# Couldn't get anything from HamQTH database, try Clublog data
if not country:
clublog_data = self._get_clublog_xml_data_for_callsign(call)
if clublog_data and "Name" in clublog_data:
country = clublog_data["Name"]
if not country:
clublog_data = self._get_clublog_api_data_for_callsign(call)
if clublog_data and "Name" in clublog_data:
country = clublog_data["Name"]
# Couldn't get anything from Clublog database, try DXCC data
if not country:
dxcc_data = self._get_dxcc_data_for_callsign(call)
if dxcc_data and "name" in dxcc_data:
country = dxcc_data["name"]
return country
def infer_dxcc_id_from_callsign(self, call, credentials=None):
"""Infer a DXCC ID from a callsign"""
try:
# Start with the basic country-files.com-based decoder.
dxcc = self._call_info_basic.get_adif_id(call)
except (KeyError, ValueError):
dxcc = None
# Couldn't get anything from basic call info database, try QRZ.com
if not dxcc:
qrz_data = self._get_qrz_data_for_callsign(call, credentials)
if qrz_data and "adif" in qrz_data:
dxcc = qrz_data["adif"]
# Couldn't get anything from QRZ.com database, try HamQTH
if not dxcc:
hamqth_data = self._get_hamqth_data_for_callsign(call, credentials)
if hamqth_data and "adif" in hamqth_data:
dxcc = hamqth_data["adif"]
# Couldn't get anything from HamQTH database, try Clublog data
if not dxcc:
clublog_data = self._get_clublog_xml_data_for_callsign(call)
if clublog_data and "DXCC" in clublog_data:
dxcc = clublog_data["DXCC"]
if not dxcc:
clublog_data = self._get_clublog_api_data_for_callsign(call)
if clublog_data and "DXCC" in clublog_data:
dxcc = clublog_data["DXCC"]
# Couldn't get anything from Clublog database, try DXCC data
if not dxcc:
dxcc_data = self._get_dxcc_data_for_callsign(call)
if dxcc_data and "entityCode" in dxcc_data:
dxcc = dxcc_data["entityCode"]
return dxcc
def infer_continent_from_callsign(self, call, credentials=None):
"""Infer a continent shortcode from a callsign"""
try:
# Start with the basic country-files.com-based decoder.
continent = self._call_info_basic.get_continent(call)
except (KeyError, ValueError):
continent = None
# Couldn't get anything from basic call info database, try HamQTH
if not continent:
hamqth_data = self._get_hamqth_data_for_callsign(call, credentials)
if hamqth_data and "continent" in hamqth_data:
continent = hamqth_data["continent"]
# Couldn't get anything from HamQTH database, try Clublog data
if not continent:
clublog_data = self._get_clublog_xml_data_for_callsign(call)
if clublog_data and "Continent" in clublog_data:
continent = clublog_data["Continent"]
if not continent:
clublog_data = self._get_clublog_api_data_for_callsign(call)
if clublog_data and "Continent" in clublog_data:
continent = clublog_data["Continent"]
# Couldn't get anything from Clublog database, try DXCC data
if not continent:
dxcc_data = self._get_dxcc_data_for_callsign(call)
# Some DXCCs are in two continents, if so don't use the continent data as we can't be sure
if dxcc_data and "continent" in dxcc_data and len(dxcc_data["continent"]) == 1:
continent = dxcc_data["continent"][0]
return continent
def infer_cq_zone_from_callsign(self, call, credentials=None):
"""Infer a CQ zone from a callsign"""
try:
# Start with the basic country-files.com-based decoder.
cqz = self._call_info_basic.get_cqz(call)
except (KeyError, ValueError):
cqz = None
# Couldn't get anything from basic call info database, try QRZ.com
if not cqz:
qrz_data = self._get_qrz_data_for_callsign(call, credentials)
if qrz_data and "cqz" in qrz_data:
cqz = qrz_data["cqz"]
# Couldn't get anything from QRZ.com database, try HamQTH
if not cqz:
hamqth_data = self._get_hamqth_data_for_callsign(call, credentials)
if hamqth_data and "cq" in hamqth_data:
cqz = hamqth_data["cq"]
# Couldn't get anything from HamQTH database, try Clublog data
if not cqz:
clublog_data = self._get_clublog_xml_data_for_callsign(call)
if clublog_data and "CQZ" in clublog_data:
cqz = clublog_data["CQZ"]
if not cqz:
clublog_data = self._get_clublog_api_data_for_callsign(call)
if clublog_data and "CQZ" in clublog_data:
cqz = clublog_data["CQZ"]
# Couldn't get anything from Clublog database, try DXCC data
if not cqz:
dxcc_data = self._get_dxcc_data_for_callsign(call)
# Some DXCCs are in multiple zones, if so don't use the zone data as we can't be sure
if dxcc_data and "cq" in dxcc_data and len(dxcc_data["cq"]) == 1:
cqz = dxcc_data["cq"][0]
return cqz
def infer_itu_zone_from_callsign(self, call, credentials=None):
"""Infer a ITU zone from a callsign"""
try:
# Start with the basic country-files.com-based decoder.
ituz = self._call_info_basic.get_ituz(call)
except (KeyError, ValueError):
ituz = None
# Couldn't get anything from basic call info database, try QRZ.com
if not ituz:
qrz_data = self._get_qrz_data_for_callsign(call, credentials)
if qrz_data and "ituz" in qrz_data:
ituz = qrz_data["ituz"]
# Couldn't get anything from QRZ.com database, try HamQTH
if not ituz:
hamqth_data = self._get_hamqth_data_for_callsign(call, credentials)
if hamqth_data and "itu" in hamqth_data:
ituz = hamqth_data["itu"]
# Couldn't get anything from HamQTH database, Clublog doesn't provide this, so try DXCC data
if not ituz:
dxcc_data = self._get_dxcc_data_for_callsign(call)
# Some DXCCs are in multiple zones, if so don't use the zone data as we can't be sure
if dxcc_data and "itu" in dxcc_data and len(dxcc_data["itu"]) == 1:
ituz = dxcc_data["itu"]
return ituz
def infer_name_from_callsign_online_lookup(self, call, credentials=None):
"""Infer an operator name from a callsign (requires QRZ.com/HamQTH)"""
data = self._get_qrz_data_for_callsign(call, credentials)
if data and "name_fmt" in data:
return data["name_fmt"]
if data and "fname" in data:
name = data["fname"]
if "nick" in data:
name = name + " \"" + data["nick"] + "\""
if "name" in data:
name = name + " " + data["name"]
return name
data = self._get_hamqth_data_for_callsign(call, credentials)
if data and "nick" in data:
return data["nick"]
else:
return None
def infer_latlon_from_callsign_online_lookup(self, call, credentials=None):
"""Infer a latitude and longitude from a callsign (requires QRZ.com/HamQTH)
Coordinates that look default are rejected (apologies if your position really is 0,0, enjoy your voyage)"""
data = self._get_qrz_data_for_callsign(call, credentials)
if data and "latitude" in data and "longitude" in data and (
float(data["latitude"]) != 0 or float(data["longitude"]) != 0) and -89.9 < float(
data["latitude"]) < 89.9:
return [float(data["latitude"]), float(data["longitude"])]
data = self._get_hamqth_data_for_callsign(call, credentials)
if data and "latitude" in data and "longitude" in data and (
float(data["latitude"]) != 0 or float(data["longitude"]) != 0) and -89.9 < float(
data["latitude"]) < 89.9:
return [float(data["latitude"]), float(data["longitude"])]
else:
return None
def infer_grid_from_callsign_online_lookup(self, call, credentials=None):
"""Infer a grid locator from a callsign (requires QRZ.com/HamQTH).
Grids that look default are rejected (apologies if your grid really is AA00aa, enjoy your research)"""
data = self._get_qrz_data_for_callsign(call, credentials)
if data and "locator" in data and data["locator"].upper() != "AA00" and data["locator"].upper() != "AA00AA" and \
data["locator"].upper() != "AA00AA00":
return data["locator"]
data = self._get_hamqth_data_for_callsign(call, credentials)
if data and "grid" in data and data["grid"].upper() != "AA00" and data["grid"].upper() != "AA00AA" and data[
"grid"].upper() != "AA00AA00":
return data["grid"]
else:
return None
def infer_qth_from_callsign_online_lookup(self, call, credentials=None):
"""Infer a textual QTH from a callsign (requires QRZ.com/HamQTH)"""
data = self._get_qrz_data_for_callsign(call, credentials)
if data and "addr2" in data:
return data["addr2"]
data = self._get_hamqth_data_for_callsign(call, credentials)
if data and "qth" in data:
return data["qth"]
else:
return None
def infer_latlon_from_callsign_dxcc(self, call):
"""Infer a latitude and longitude from a callsign (using DXCC, probably very inaccurate)"""
try:
data = self._call_info_basic.get_lat_long(call)
if data and "latitude" in data and "longitude" in data:
loc = [float(data["latitude"]), float(data["longitude"])]
else:
loc = None
except KeyError:
loc = None
# Couldn't get anything from basic call info database, try Clublog data
if not loc:
data = self._get_clublog_xml_data_for_callsign(call)
if data and "Lat" in data and "Lon" in data:
loc = [float(data["Lat"]), float(data["Lon"])]
if not loc:
data = self._get_clublog_api_data_for_callsign(call)
if data and "Lat" in data and "Lon" in data:
loc = [float(data["Lat"]), float(data["Lon"])]
return loc
def infer_grid_from_callsign_dxcc(self, call):
"""Infer a grid locator from a callsign (using DXCC, probably very inaccurate)"""
latlon = self.infer_latlon_from_callsign_dxcc(call) or []
grid = None
if latlon:
try:
grid = latlong_to_locator(latlon[0], latlon[1], 8)
except:
logging.debug("Invalid lat/lon received for DXCC")
return grid
def _get_qrz_data_for_callsign(self, call, credentials) -> dict | None:
"""Utility method to get QRZ.com data from cache if possible, if not get it from the API and cache it.
Returns None immediately if no credentials are provided."""
# Return from cache if available (a cached None means 'not found in QRZ')
if call in self._qrz_callsign_data_cache:
return self._qrz_callsign_data_cache.get(call)
# Obtain session key from credentials
session_key = None
if credentials and credentials.qrz_session_key:
session_key = credentials.qrz_session_key
elif credentials and credentials.qrz_username and credentials.qrz_password:
try:
login_response = self._qrz_session_cache.get(
self._qrz_base_url + "?username=" + urllib.parse.quote_plus(credentials.qrz_username) +
"&password=" + urllib.parse.quote_plus(credentials.qrz_password) + "&agent=spothole",
headers=HTTP_HEADERS).content
login_data = xmltodict.parse(login_response)
session = login_data.get("QRZDatabase", {}).get("Session", {})
if "Key" in session:
session_key = str(session["Key"])
else:
logging.warning("QRZ.com login details incorrect, failed to look up with QRZ.")
return None
except Exception:
logging.error("Exception when getting QRZ.com session key")
return None
if not session_key:
return None
# Try the call as given, then fall back to the base call (strips /P, /M etc.)
calls_to_try = [call]
try:
home_call = callinfo.Callinfo.get_homecall(call)
if home_call != call:
calls_to_try.append(home_call)
except ValueError:
logging.debug("Could not look up home call for callsign %s", call)
for lookup_call in calls_to_try:
try:
response = _URL_DATA_CACHE.get(
self._qrz_base_url + "?s=" + session_key + "&callsign=" + urllib.parse.quote_plus(lookup_call),
headers=HTTP_HEADERS, timeout=10)
if response.ok:
qrz_response = xmltodict.parse(response.content).get("QRZDatabase", {})
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
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):
continue
except ConnectionError:
logging.warning(f"Connection error when looking up callsign %s using QRZ", lookup_call)
continue
except (ConnectTimeout, ReadTimeout):
logging.warning(f"Timeout when looking up callsign %s using QRZ.", lookup_call)
continue
except Exception:
logging.error("Exception when looking up callsign %s using QRZ", lookup_call, exc_info=True)
continue
# 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
return None
def _get_hamqth_data_for_callsign(self, call, credentials) -> dict | None:
"""Utility method to get HamQTH data from cache if possible, if not get it from the API and cache it.
Returns None immediately if no credentials are provided."""
# Return from cache if available
if call in self._hamqth_callsign_data_cache:
return self._hamqth_callsign_data_cache.get(call)
# Obtain session ID from credentials
session_id = None
if credentials and credentials.hamqth_session_id:
session_id = credentials.hamqth_session_id
elif credentials and credentials.hamqth_username and credentials.hamqth_password:
try:
session_data = self._hamqth_session_lookup_cache.get(
self._hamqth_base_url + "?u=" + urllib.parse.quote_plus(credentials.hamqth_username) +
"&p=" + urllib.parse.quote_plus(credentials.hamqth_password), headers=HTTP_HEADERS).content
dict_data = xmltodict.parse(session_data)
if "session_id" in dict_data["HamQTH"]["session"]:
session_id = str(dict_data["HamQTH"]["session"]["session_id"])
else:
logging.warning("HamQTH login details incorrect, failed to look up with HamQTH.")
return None
except Exception:
logging.error("Exception when getting HamQTH session ID")
return None
if not session_id:
return None
# Try the call as given, then fall back to the base call (strips /P, /M etc.)
calls_to_try = [call]
try:
home_call = callinfo.Callinfo.get_homecall(call)
if home_call != call:
calls_to_try.append(home_call)
except ValueError:
logging.debug("Could not look up home call for callsign %s", call)
for lookup_call in calls_to_try:
try:
response = _URL_DATA_CACHE.get(
self._hamqth_base_url + "?id=" + session_id + "&callsign=" + urllib.parse.quote_plus(
lookup_call) + "&prg=" + HAMQTH_PRG, headers=HTTP_HEADERS)
if response.ok:
data = xmltodict.parse(response.content)["HamQTH"]["search"]
self._hamqth_callsign_data_cache.add(call, data, expire=604800) # 1 week in seconds
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):
continue
except ConnectionError:
logging.warning(f"Connection error when looking up callsign %s using HamQTH", lookup_call)
continue
except (ConnectTimeout, ReadTimeout):
logging.warning(f"Timeout when looking up callsign %s using HamQTH", lookup_call)
continue
except Exception:
logging.error("Exception when looking up callsign %s using HamQTH", lookup_call, exc_info=True)
continue
# 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
return None
def _get_clublog_api_data_for_callsign(self, call) -> dict | None:
"""Utility method to get Clublog API data from cache if possible, if not get it from the API and cache it"""
# Fetch from cache if we can, otherwise fetch from the API and cache it
if call in self._clublog_callsign_data_cache:
return self._clublog_callsign_data_cache.get(call)
elif self._clublog_api_available:
try:
data = self._lookup_lib_clublog_api.lookup_callsign(callsign=call)
self._clublog_callsign_data_cache.add(call, data, expire=604800) # 1 week in seconds
return data
except (KeyError, ValueError):
# Clublog had no info for the call, but maybe it had prefixes or suffixes. Try again with the base call.
try:
data = self._lookup_lib_clublog_api.lookup_callsign(callsign=callinfo.Callinfo.get_homecall(call))
self._clublog_callsign_data_cache.add(call, data, expire=604800) # 1 week in seconds
return data
except (KeyError, ValueError):
# Clublog had no info for the call, that's OK. Cache a None so we don't try to look this up again
self._clublog_callsign_data_cache.add(call, None, expire=604800) # 1 week in seconds
return None
except APIKeyMissingError:
# User API key was wrong, warn
logging.error("Could not look up via Clublog API, key " + self._clublog_api_key + " was rejected.")
return None
else:
return None
def _get_clublog_xml_data_for_callsign(self, call) -> dict | None:
"""Utility method to get Clublog XML data from file"""
if self._clublog_xml_available:
try:
data = self._lookup_lib_clublog_xml.lookup_callsign(callsign=call)
return data
except (KeyError, ValueError):
# Clublog had no info for the call, that's OK. Cache a None so we don't try to look this up again
self._clublog_callsign_data_cache.add(call, None, expire=604800) # 1 week in seconds
return None
else:
return None
def _get_dxcc_data_for_callsign(self, call) -> dict | None:
"""Utility method to get generic DXCC data from our lookup table, if we can find it"""
for pattern, entity_code in DATA_STORE.dxcc_lookup_by_call_regex:
if pattern.match(call):
return DATA_STORE.dxcc_data[entity_code]
return None
def stop(self):
"""Shutdown method to close down any caches neatly."""
self._qrz_callsign_data_cache.close()
self._hamqth_callsign_data_cache.close()
self._clublog_callsign_data_cache.close()
# Singleton object
lookup_helper = LookupHelper()
from core.data_providers import DATA_PROVIDERS
from data.callsign import Callsign
def get_call_info(callsign, lookup_credentials):
"""Utility method to get the best set of data for a callsign as we can, using all enabled providers.
lookup_credentials is an optional object that carries the user's QRZ.com/HamQTH credentials, if they provided them,
to enable lookup using those providers."""
callsign_data = Callsign(call=callsign)
if callsign:
# Sort callsign providers by priority order, so we query the highest priority (lowest numbers) first, and only
# query other providers for data we are missing as we go along.
for p in sorted(DATA_PROVIDERS.callsign_data_providers, key=lambda p: p.priority):
if p.enabled:
# Get new lookup data
data = p.lookup(callsign, lookup_credentials)
if data:
# If we have new data for fields that were previously unpopulated, add them in
for key, value in data.__dict__.items():
if value is not None and callsign_data.__dict__.get(key) is None:
callsign_data.__dict__[key] = value
# If callsign data is fully populated, avoid looking up using other providers as their data will not
# be used
if callsign_data.fully_populated():
break
return callsign_data
+2
View File
@@ -30,6 +30,8 @@ LOG_WEB_REQUESTS = config.get("log-web-requests", False)
# but for consistency we provide this to the front-end in web-ui-options because it has no impact outside of the web UI.
WEB_UI_OPTIONS["spot-providers-enabled-by-default"] = [p["name"] for p in config["spot-providers"] if p["enabled"] and (
"enabled-by-default-in-web-ui" not in p or p["enabled-by-default-in-web-ui"])]
WEB_UI_OPTIONS["qrz-enabled"] = any(p["class"] == "QRZ" and p["enabled"] for p in config["callsign-data-providers"])
WEB_UI_OPTIONS["hamqth-enabled"] = any(p["class"] == "HamQTH" and p["enabled"] for p in config["callsign-data-providers"])
# If spotting to this server is enabled, "API" is another valid spot source even though it does not come from
# one of our proviers. We set that to also be enabled by default.
if ALLOW_SPOTTING:
+10 -9
View File
@@ -29,15 +29,7 @@ class DataProviders:
def start(self):
for p in self.spot_providers:
if p.enabled:
p.start()
for p in self.alert_providers:
if p.enabled:
p.start()
for p in self.solar_condition_providers:
if p.enabled:
p.start()
# Start data providers before spot/alert providers so the lookup data is there already for incoming spots
for p in self.static_data_providers:
if p.enabled:
p.start()
@@ -47,6 +39,15 @@ class DataProviders:
for p in self.callsign_data_providers:
if p.enabled:
p.start()
for p in self.spot_providers:
if p.enabled:
p.start()
for p in self.alert_providers:
if p.enabled:
p.start()
for p in self.solar_condition_providers:
if p.enabled:
p.start()
def stop(self):
for sp in self.spot_providers:
+21 -5
View File
@@ -20,11 +20,15 @@ class DataStore:
self._MAX_SPOT_COUNT = 100000
self._MAX_ALERT_COUNT = 100000
self._SPOT_ALERT_SNAPSHOT_INTERVAL_SEC = 300
self._CALLSIGN_DATA_TTL_SEC = 30 * 24 * 60 * 60
self.CALLSIGN_DATA_TTL_SEC = 30 * 24 * 60 * 60
# Caches
self.alerts = None
self.spots = None
self.callsigns = None
self.callsign_data_countryfiles = None
self.callsign_data_clublogxml = None
self.callsign_data_clublogapi = None
self.callsign_data_qrz = None
self.callsign_data_hamqth = None
self.dxcc_data = None
self.dxcc_lookup_by_call_regex = []
self.sigrefs = None
@@ -65,8 +69,16 @@ class DataStore:
# Standard disk cache for callsign data. This data does have a TTL to trigger an occasional re-lookup.
# Old data *is* better than no data, but we can't have a background thread re-looking-up every callsign
# we've seen, so we rely on them timing out and this triggering another lookup.
self.callsigns = diskcache.Cache(CACHE_DIR + "callsigns")
logging.info(f"Loaded data for %d callsigns.", len(self.callsigns))
self.callsign_data_countryfiles = diskcache.Cache(CACHE_DIR + "callsign_data_countryfiles")
self.callsign_data_clublogxml = diskcache.Cache(CACHE_DIR + "callsign_data_clublogxml")
self.callsign_data_clublogapi = diskcache.Cache(CACHE_DIR + "callsign_data_clublogapi")
self.callsign_data_qrz = diskcache.Cache(CACHE_DIR + "callsign_data_qrz")
self.callsign_data_hamqth = diskcache.Cache(CACHE_DIR + "callsign_data_hamqth")
unique_keys = set()
for c in [self.callsign_data_countryfiles, self.callsign_data_clublogxml, self.callsign_data_clublogapi,
self.callsign_data_qrz, self.callsign_data_hamqth]:
unique_keys.update(c)
logging.info(f"Loaded data for %d callsigns.", len(unique_keys))
# Special caches for spots and alerts, which have TTL and write snapshots to disk at an interval. We
# specifically load these caches *last* so that any sigref and callsign data is already loaded from disk cache
@@ -97,7 +109,11 @@ class DataStore:
self._status.close()
self.dxcc_data.close()
self.sigrefs.close()
self.callsigns.close()
self.callsign_data_countryfiles.close()
self.callsign_data_clublogxml.close()
self.callsign_data_clublogapi.close()
self.callsign_data_qrz.close()
self.callsign_data_hamqth.close()
# Global object
DATA_STORE = DataStore()
+1 -1
View File
@@ -92,7 +92,7 @@ class LiveDataCache:
time.sleep(interval)
self.save_snapshot()
t = threading.Thread(target=loop, daemon=True, name=f"snapshot-{self._snapshot_dir}")
t = threading.Thread(target=loop, name=f"LiveDataCache-Snapshot-{self._snapshot_dir}")
t.start()
def close(self):
+30 -15
View File
@@ -4,21 +4,20 @@ from pyhamtools.locator import locator_to_latlong, latlong_to_locator
from core.data_store import DATA_STORE
from core.geo_utils import wab_wai_square_to_lat_lon
from data.sig_ref import SIGRef
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 must at minimum have a "sig" and an "id". The rest of the object will be populated and returned. This makes
def get_sig_ref_info(sig, ref_id):
"""Look up details of a SIG reference (e.g. POTA park) such as name, lat/lon, and grid. Takes in a sig name and
a reference ID (both strings) and returns a SigRef object populated with as much data as we can find. This makes
use of SIG ref data in the data store, live lookups from the web, or just automatic calculation depending on which
SIG we are getting data for. Any data currently in the object will be kept, only missing data in the object will
be populated if it can be determined."""
SIG we are getting data for."""
if sig_ref.sig is None or sig_ref.sig == "" or sig_ref.id is None or sig_ref.id == "":
logging.debug("Failed to look up sig_ref info, sig or id were not set.")
return sig_ref
if sig is None or sig == "" or ref_id is None or ref_id == "":
logging.debug("Failed to look up sig_ref info, sig or ref were not set.")
return None
sig = sig_ref.sig
ref_id = sig_ref.id
sig_ref = SIGRef(sig=sig, id=ref_id)
try:
### FUDGES ###
@@ -81,13 +80,29 @@ def populate_sig_ref_info(sig_ref):
key = sig + ":" + ref_id
lookup_data = DATA_STORE.sigrefs.get(key) if key in DATA_STORE.sigrefs else None
if lookup_data:
# Copy new sig ref data into existing object where data was previously missing
for key, value in lookup_data.__dict__.items():
if value is not None and sig_ref.__dict__.get(key) is None:
sig_ref.__dict__[key] = value
return lookup_data
else:
logging.warning("%s database did not contain data for ref %s", sig, ref_id)
# Maybe a super new reference we don't know about yet, but more likely a typo or a test reference,
# just silently ignore it.
logging.debug("%s database did not contain data for ref %s", sig, ref_id)
except Exception:
logging.error("Exception when looking up sig_ref info for " + sig + " ref " + ref_id, exc_info=True)
return sig_ref
def populate_missing_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 must at minimum have a "sig" and an "id". The rest of the object will be populated and returned. Any data
currently in the object will be kept, only missing data in the object will be populated if it can be determined."""
lookup_data = get_sig_ref_info(sig_ref.sig, sig_ref.id)
if lookup_data:
# Copy new sig ref data into existing object where data was previously missing
for key, value in lookup_data.__dict__.items():
if value is not None and sig_ref.__dict__.get(key) is None:
sig_ref.__dict__[key] = value
return sig_ref
+3 -2
View File
@@ -30,7 +30,7 @@ class StatusReporter:
def start(self):
"""Start the reporter thread"""
self._thread = Thread(target=self._run, daemon=True)
self._thread = Thread(target=self._run, name="StatusReporter")
self._thread.start()
def stop(self):
@@ -84,7 +84,8 @@ class StatusReporter:
DATA_STORE.status_data["callsign_data_providers"] = list(
map(lambda p: {"name": p.name, "enabled": p.enabled, "status": p.status,
"last_updated": p.last_update_time.replace(
tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0},
tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0,
"lookup_count": p.lookup_count},
DATA_PROVIDERS.callsign_data_providers))
DATA_STORE.status_data["webserver"] = {"status": WEB_SERVER.web_server_metrics["status"],
"last_api_access": WEB_SERVER.web_server_metrics[
+29 -18
View File
@@ -2,6 +2,7 @@ import logging
import simplejson
from pyhamtools.frequency import freq_to_band
from pyhamtools.locator import latlong_to_locator
from core.constants import UNKNOWN_BAND, BANDS, CW_MODES, PHONE_MODES, DATA_MODES, MODE_ALIASES, ALL_MODES
from core.data_store import DATA_STORE
@@ -84,23 +85,33 @@ def get_callsign_object_from_pyhamtools_callinfo(callsign, callinfo):
"""Utility function to take the data provided by a PyHamTools CallInfo object and populate our own Callsign data
object from it"""
home_call = callinfo.get_homecall(callsign)
data = callinfo.get_all()
try:
home_call = callinfo.get_homecall(callsign)
data = callinfo.get_all(callsign)
country = data["country"] if "country" in data else None
dxcc_id = data["adif"] if "adif" in data else None
continent = data["continent"] if "continent" in data else None
cq_zone = data["cqz"] if "cqz" in data else None
itu_zone = data["ituz"] if "ituz" in data else None
lat = float(data["latitude"]) if "latitude" in data else None
lon = float(data["longitude"]) if "longitude" in data else None
country = data["country"] if "country" in data else None
dxcc_id = data["adif"] if "adif" in data else None
continent = data["continent"] if "continent" in data else None
cq_zone = data["cqz"] if "cqz" in data else None
itu_zone = data["ituz"] if "ituz" in data else None
lat = float(data["latitude"]) if "latitude" in data else None
lon = float(data["longitude"]) if "longitude" in data else None
grid = None
if lat and lon:
grid = latlong_to_locator(lat, lon)
return Callsign(call=callsign,
home_call=home_call,
country=country,
dxcc_id=dxcc_id,
continent=continent,
cq_zone=cq_zone,
itu_zone=itu_zone,
latitude=lat,
longitude=lon)
return Callsign(call=callsign,
home_call=home_call,
country=country,
dxcc_id=dxcc_id,
continent=continent,
cq_zone=cq_zone,
itu_zone=itu_zone,
latitude=lat,
longitude=lon,
grid=grid,
location_source="DXCC")
except (KeyError, ValueError):
# Unknown callsign, can't look anything up, return a Callsign object with basic data so that gets cached
return Callsign(call=callsign)
+11 -12
View File
@@ -7,8 +7,8 @@ from datetime import datetime, timedelta
import pytz
from core.call_lookup_helper import lookup_helper
from core.sig_lookup_helper import populate_sig_ref_info
from core.call_lookup_helper import get_call_info
from core.sig_lookup_helper import populate_missing_sig_ref_info
from core.utils import get_flag_for_dxcc
@@ -86,16 +86,17 @@ class Alert:
# DX country, continent, zones etc. from callsign. CQ/ITU zone are better looked up with a location but we don't
# have a real location for alerts.
call_info = get_call_info(self.dx_calls[0], credentials)
if self.dx_calls and self.dx_calls[0] and not self.dx_country:
self.dx_country = lookup_helper.infer_country_from_callsign(self.dx_calls[0], credentials)
self.dx_country = call_info.country
if self.dx_calls and self.dx_calls[0] and not self.dx_continent:
self.dx_continent = lookup_helper.infer_continent_from_callsign(self.dx_calls[0], credentials)
self.dx_continent = call_info.continent
if self.dx_calls and self.dx_calls[0] and not self.dx_cq_zone:
self.dx_cq_zone = lookup_helper.infer_cq_zone_from_callsign(self.dx_calls[0], credentials)
self.dx_cq_zone = call_info.cq_zone
if self.dx_calls and self.dx_calls[0] and not self.dx_itu_zone:
self.dx_itu_zone = lookup_helper.infer_itu_zone_from_callsign(self.dx_calls[0], credentials)
self.dx_itu_zone = call_info.itu_zone
if self.dx_calls and self.dx_calls[0] and not self.dx_dxcc_id:
self.dx_dxcc_id = lookup_helper.infer_dxcc_id_from_callsign(self.dx_calls[0], credentials)
self.dx_dxcc_id = call_info.dxcc_id
if self.dx_dxcc_id and not self.dx_flag:
self.dx_flag = get_flag_for_dxcc(self.dx_dxcc_id)
@@ -104,7 +105,7 @@ class Alert:
# from WAB and WAI, which count as a SIG even though there's no real lookup, just maths
if self.sig_refs and len(self.sig_refs) > 0:
for sig_ref in self.sig_refs:
populate_sig_ref_info(sig_ref)
populate_missing_sig_ref_info(sig_ref)
# If the spot itself doesn't have a SIG yet, but we have at least one SIG reference, take that reference's SIG
# and apply it to the whole spot.
@@ -124,12 +125,10 @@ class Alert:
self_copy.received_time_iso = ""
self.id = hashlib.sha256(str(self_copy).encode("utf-8")).hexdigest()
# DX operator details lookup, using QRZ.com/HamQTH. This should be the last resort compared to taking the data
# from the actual alerting service, e.g. we don't want to accidentally use a user's QRZ.com home lat/lon
# instead of the one from the park reference they're at.
# DX operator name lookup, using QRZ.com/HamQTH.
if self.dx_calls and not self.dx_names:
self.dx_names = list(
map(lambda c: lookup_helper.infer_name_from_callsign_online_lookup(c, credentials), self.dx_calls))
map(lambda c: get_call_info(c, credentials).name, self.dx_calls))
except Exception as e:
logging.error("Exception while inferring missing data from spot", e, exc_info=True)
+9
View File
@@ -35,4 +35,13 @@ class Callsign:
cq_zone: int | None = None
# ITU zone in which the callsign indicates they are operating
itu_zone: int | None = None
# Location source. This can be "HOME QTH" or "DXCC" depending on which provider gave us a location
location_source: str | None = None
def fully_populated(self):
"""Utility method to indicate that the callsign data is fully populated. Multiple providers can return data for
a callsign, and we try them in sequence until we have all the data we can, in which case there's no point
querying any other providers."""
return self.home_call is not None and self.name is not None and self.qth is not None and self.grid is not None\
and self.latitude is not None and self.longitude is not None and self.country is not None and self.continent\
is not None and self.dxcc_id is not None and self.cq_zone is not None and self.itu_zone is not None
+25 -55
View File
@@ -12,9 +12,9 @@ from pyhamtools.locator import locator_to_latlong, latlong_to_locator
from core.config import MAX_SPOT_AGE
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.call_lookup_helper import lookup_helper
from core.call_lookup_helper import get_call_info
from core.sig_utils import ANY_SIG_REGEX, get_ref_regex_for_sig, get_sig_name_from_comment_name
from core.sig_lookup_helper import populate_sig_ref_info
from core.sig_lookup_helper import populate_missing_sig_ref_info
from core.utils import infer_band_from_freq, infer_mode_from_comment, \
infer_mode_from_frequency, infer_mode_type_from_mode, get_flag_for_dxcc
from data.sig_ref import SIGRef
@@ -173,12 +173,13 @@ class Spot:
self.dx_ssid = split[1]
# DX country, continent etc. from callsign
dx_call_info = get_call_info(self.dx_call, credentials)
if self.dx_call and not self.dx_country:
self.dx_country = lookup_helper.infer_country_from_callsign(self.dx_call, credentials)
self.dx_country = dx_call_info.country
if self.dx_call and not self.dx_continent:
self.dx_continent = lookup_helper.infer_continent_from_callsign(self.dx_call, credentials)
self.dx_continent = dx_call_info.continent
if self.dx_call and not self.dx_dxcc_id:
self.dx_dxcc_id = lookup_helper.infer_dxcc_id_from_callsign(self.dx_call, credentials)
self.dx_dxcc_id = dx_call_info.dxcc_id
if self.dx_dxcc_id and not self.dx_flag:
self.dx_flag = get_flag_for_dxcc(self.dx_dxcc_id)
@@ -205,14 +206,15 @@ class Spot:
# Spotter country, continent, zones etc. from callsign.
# DE call with no digits, or APRS servers starting "T2" are not things we can look up location for
de_call_info = get_call_info(self.de_call, credentials)
if self.de_call and any(char.isdigit() for char in self.de_call) and not (
self.de_call.startswith("T2") and self.source == "APRS-IS"):
if not self.de_country:
self.de_country = lookup_helper.infer_country_from_callsign(self.de_call, credentials)
self.de_country = de_call_info.country
if not self.de_continent:
self.de_continent = lookup_helper.infer_continent_from_callsign(self.de_call, credentials)
self.de_continent = de_call_info.continent
if not self.de_dxcc_id:
self.de_dxcc_id = lookup_helper.infer_dxcc_id_from_callsign(self.de_call, credentials)
self.de_dxcc_id = de_call_info.dxcc_id
if self.de_dxcc_id and not self.de_flag:
self.de_flag = get_flag_for_dxcc(self.de_dxcc_id)
@@ -287,7 +289,7 @@ class Spot:
# from WAB and WAI, which count as a SIG even though there's no real lookup, just maths
if self.sig_refs and len(self.sig_refs) > 0:
for sig_ref in self.sig_refs:
sig_ref = populate_sig_ref_info(sig_ref)
sig_ref = populate_missing_sig_ref_info(sig_ref)
# If the spot itself doesn't have location yet, but the SIG ref does, extract it
if sig_ref.grid and not self.dx_grid:
self.dx_grid = sig_ref.grid
@@ -359,18 +361,16 @@ class Spot:
self_copy.received_time_iso = ""
self.id = hashlib.sha256(str(self_copy).encode("utf-8")).hexdigest()
# DX operator details lookup, using QRZ.com/HamQTH. This should be the last resort compared to taking the data
# from the actual spotting service, e.g. we don't want to accidentally use a user's QRZ.com home lat/lon
# DX operator details lookup. This should be the last resort compared to taking the data from the actual
# spotting service, e.g. we don't want to accidentally use a user's QRZ.com home lat/lon or DXCC lat/lon
# instead of the one from the park reference they're at.
if self.dx_call and not self.dx_name:
self.dx_name = lookup_helper.infer_name_from_callsign_online_lookup(self.dx_call, credentials)
self.dx_name = dx_call_info.name
if self.dx_call and not self.dx_latitude:
latlon = lookup_helper.infer_latlon_from_callsign_online_lookup(self.dx_call, credentials)
if latlon:
self.dx_latitude = latlon[0]
self.dx_longitude = latlon[1]
self.dx_grid = lookup_helper.infer_grid_from_callsign_online_lookup(self.dx_call, credentials)
self.dx_location_source = "HOME QTH"
self.dx_latitude = dx_call_info.latitude
self.dx_longitude = dx_call_info.longitude
self.dx_grid = dx_call_info.grid
self.dx_location_source = dx_call_info.location_source
# Determine a "QTH" string. If we have a SIG ref, pick the first one and turn it into a suitable string,
# otherwise see what they have set on an online lookup service.
@@ -380,39 +380,19 @@ class Spot:
qth += " " + self.sig_refs[0].name
self.dx_qth = qth
else:
self.dx_qth = lookup_helper.infer_qth_from_callsign_online_lookup(self.dx_call, credentials)
# Last resort for getting a DX position, use the DXCC entity.
if self.dx_call and not self.dx_latitude:
latlon = lookup_helper.infer_latlon_from_callsign_dxcc(self.dx_call)
if latlon:
self.dx_latitude = latlon[0]
self.dx_longitude = latlon[1]
self.dx_grid = lookup_helper.infer_grid_from_callsign_dxcc(self.dx_call)
self.dx_location_source = "DXCC"
# It looks like we can sometimes get a string into lat/lon, so try to parse as float, reject if not valid
if isinstance(self.dx_latitude, str) or isinstance(self.dx_longitude, str):
try:
self.dx_latitude = float(str(self.dx_latitude))
self.dx_longitude = float(str(self.dx_longitude))
except (TypeError, ValueError):
logging.warning("Received non-numeric strings in lat/lon (" + str(self.dx_latitude) + ", " + str(
self.dx_longitude) + ") for call " + str(self.dx_call) + ", rejecting it")
self.dx_latitude = None
self.dx_longitude = None
self.dx_qth = dx_call_info.qth
# CQ and ITU zone lookup, preferably from location but failing that, from callsign
if not self.dx_cq_zone:
if self.dx_latitude:
self.dx_cq_zone = lat_lon_to_cq_zone(self.dx_latitude, self.dx_longitude)
elif self.dx_call:
self.dx_cq_zone = lookup_helper.infer_cq_zone_from_callsign(self.dx_call, credentials)
self.dx_cq_zone = dx_call_info.cq_zone
if not self.dx_itu_zone:
if self.dx_latitude:
self.dx_itu_zone = lat_lon_to_itu_zone(self.dx_latitude, self.dx_longitude)
elif self.dx_call:
self.dx_itu_zone = lookup_helper.infer_itu_zone_from_callsign(self.dx_call, credentials)
self.dx_itu_zone = dx_call_info.itu_zone
# DX Location is "good" if it is from a spot, or from QRZ if the callsign doesn't contain a slash, so the operator
# is likely at home.
@@ -424,21 +404,11 @@ class Spot:
# DE with no digits and APRS servers starting "T2" are not things we can look up location for
if self.de_call and any(char.isdigit() for char in self.de_call) and not (
self.de_call.startswith("T2") and self.source == "APRS-IS"):
# DE operator position lookup, using QRZ.com/HamQTH.
# DE operator location lookup
if not self.de_latitude:
latlon = lookup_helper.infer_latlon_from_callsign_online_lookup(self.de_call, credentials)
if latlon:
self.de_latitude = latlon[0]
self.de_longitude = latlon[1]
self.de_grid = lookup_helper.infer_grid_from_callsign_online_lookup(self.de_call, credentials)
# Last resort for getting a DE position, use the DXCC entity.
if not self.de_latitude:
latlon = lookup_helper.infer_latlon_from_callsign_dxcc(self.de_call)
if latlon:
self.de_latitude = latlon[0]
self.de_longitude = latlon[1]
self.de_grid = lookup_helper.infer_grid_from_callsign_dxcc(self.de_call)
self.de_latitude = de_call_info.latitude
self.de_longitude = de_call_info.longitude
self.de_grid = de_call_info.grid
except Exception as e:
logging.error("Exception while inferring missing data from spot", e, exc_info=True)
+1 -1
View File
@@ -25,7 +25,7 @@ class HTTPAlertProvider(AlertProvider):
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
# subsequent polls, so start() returns immediately and the application can continue starting.
logging.info("Set up query of " + self.name + " alert API every " + str(self._poll_interval) + " seconds.")
self._thread = Thread(target=self._run, daemon=True)
self._thread = Thread(target=self._run, name=f"HTTPAlertProvider-{self.name}")
self._thread.start()
def stop(self):
@@ -14,9 +14,12 @@ from providers.callsigndata.callsign_data_provider import CallsignDataProvider
class APIQueryCallsignDataProvider(CallsignDataProvider):
"""Generic callsign data provider class for providers that fetch their data from the web on-demand using an API."""
def __init__(self, name, provider_config):
def __init__(self, name, provider_config, storage):
""" Set up the provider."""
super().__init__(name, provider_config)
super().__init__(name, provider_config, storage)
if self.enabled:
self.status = "Ready"
def start(self):
pass
@@ -1,19 +1,26 @@
import logging
from datetime import datetime
import pytz
from core.data_store import DATA_STORE
class CallsignDataProvider:
"""Generic callsign reference data provider class. Subclasses of this set up the various mechanisms via which
Spothole can look up data for callsigns."""
def __init__(self, name, provider_config):
"""Constructor"""
def __init__(self, name, provider_config, storage):
"""Constructor. As well as name and config, provide the storage object from DATA_STORE that will be used to
store the result of lookups to speed up future access."""
self.name = name
self.enabled = provider_config["enabled"]
self.priority = int(provider_config["priority"])
self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC)
self.status = "Not Started" if self.enabled else "Disabled"
self.lookup_count = 0
self._storage = storage
def start(self):
"""Start the provider. This should return immediately after spawning threads to access remote resources, if
@@ -31,6 +38,22 @@ class CallsignDataProvider:
that have been provided by the user for this session (QRZ.com/HamQTH) to allow us to look up using those
services on the user's behalf. (Clublog is looked up using an API key owned by the server and provided in its
config file, so users need not provide their own.) Returns a Callsign object with as much data populated as
possible."""
possible. Data is cached internally for a set period of 30 days to avoid the need to request data from servers
each time."""
if self.enabled:
if callsign in self._storage:
return self._storage[callsign]
else:
c = self._perform_new_lookup(callsign, lookup_credentials)
if c:
self._storage.set(callsign, c, expire=DATA_STORE.CALLSIGN_DATA_TTL_SEC)
return c
else:
return None
def _perform_new_lookup(self, callsign, lookup_credentials):
"""Makes a new request to the data source for callsign data."""
raise NotImplementedError("Subclasses must implement this method")
+10 -5
View File
@@ -4,6 +4,7 @@ from datetime import datetime
import pytz
from pyhamtools import LookupLib, Callinfo
from core.data_store import DATA_STORE
from core.utils import get_callsign_object_from_pyhamtools_callinfo
from data.callsign import Callsign
from providers.callsigndata.api_query_callsign_data_provider import APIQueryCallsignDataProvider
@@ -25,16 +26,20 @@ class ClublogAPI(APIQueryCallsignDataProvider):
logging.warning(
"Clublog XML callsign data provider configured but no api key was provided, this has been disabled.")
super().__init__("Clublog API", provider_config)
super().__init__("Clublog API", provider_config, DATA_STORE.callsign_data_clublogapi)
def lookup(self, callsign, lookup_credentials):
def _perform_new_lookup(self, callsign, lookup_credentials):
callsign_data = Callsign(call=callsign)
try:
callsign_data = get_callsign_object_from_pyhamtools_callinfo(callsign, self._callinfo)
self.status = "OK"
self.last_update_time = datetime.now(pytz.UTC)
if self._callinfo:
callsign_data = get_callsign_object_from_pyhamtools_callinfo(callsign, self._callinfo)
self.status = "OK"
self.last_update_time = datetime.now(pytz.UTC)
self.lookup_count += 1
else:
return None
except Exception as e:
self.status = "Error"
+20 -3
View File
@@ -1,8 +1,11 @@
import gzip
import logging
from datetime import datetime
import pytz
from pyhamtools import LookupLib, Callinfo
from core.data_store import DATA_STORE
from core.utils import get_callsign_object_from_pyhamtools_callinfo
from data.callsign import Callsign
from providers.callsigndata.file_download_callsign_data_provider import FileDownloadCallsignDataProvider
@@ -26,7 +29,7 @@ class ClublogXML(FileDownloadCallsignDataProvider):
"Clublog XML callsign data provider configured but no api key was provided, this has been disabled.")
super().__init__("Clublog XML", provider_config, self.DATA_URL + "?api=" + self._api_key,
self.CACHE_PATH_ZIPPED, self.POLL_INTERVAL_DAYS)
self.CACHE_PATH_ZIPPED, self.POLL_INTERVAL_DAYS, DATA_STORE.callsign_data_clublogxml)
def _handle_file(self, path):
try:
@@ -48,5 +51,19 @@ class ClublogXML(FileDownloadCallsignDataProvider):
logging.error("Exception when loading Clublog XML.", e, exc_info=True)
return False
def lookup(self, callsign, lookup_credentials):
return get_callsign_object_from_pyhamtools_callinfo(callsign, self._callinfo)
def _perform_new_lookup(self, callsign, lookup_credentials):
callsign_data = Callsign(call=callsign)
try:
if self._callinfo:
callsign_data = get_callsign_object_from_pyhamtools_callinfo(callsign, self._callinfo)
self.status = "OK"
self.lookup_count += 1
else:
return None
except Exception as e:
self.status = "Error"
logging.error("Exception when looking up data from Clublog XML data", e, exc_info=True)
return callsign_data
+19 -3
View File
@@ -2,6 +2,7 @@ import logging
from pyhamtools import LookupLib, Callinfo
from core.data_store import DATA_STORE
from core.utils import get_callsign_object_from_pyhamtools_callinfo
from data.callsign import Callsign
from providers.callsigndata.file_download_callsign_data_provider import FileDownloadCallsignDataProvider
@@ -16,7 +17,8 @@ class CountryFiles(FileDownloadCallsignDataProvider):
_callinfo = None
def __init__(self, provider_config):
super().__init__("CountryFiles.com", provider_config, self.DATA_URL, self.CACHE_PATH, self.POLL_INTERVAL_DAYS)
super().__init__("CountryFiles.com", provider_config, self.DATA_URL, self.CACHE_PATH, self.POLL_INTERVAL_DAYS,
DATA_STORE.callsign_data_countryfiles)
def _handle_file(self, path):
try:
@@ -28,5 +30,19 @@ class CountryFiles(FileDownloadCallsignDataProvider):
logging.error("Exception when loading Country Files cty.plist.", e, exc_info=True)
return False
def lookup(self, callsign, lookup_credentials):
return get_callsign_object_from_pyhamtools_callinfo(callsign, self._callinfo)
def _perform_new_lookup(self, callsign, lookup_credentials):
callsign_data = Callsign(call=callsign)
try:
if self._callinfo:
callsign_data = get_callsign_object_from_pyhamtools_callinfo(callsign, self._callinfo)
self.status = "OK"
self.lookup_count += 1
else:
return None
except Exception as e:
self.status = "Error"
logging.error("Exception when looking up data from Country file", e, exc_info=True)
return callsign_data
@@ -14,9 +14,9 @@ from providers.callsigndata.callsign_data_provider import CallsignDataProvider
class FileDownloadCallsignDataProvider(CallsignDataProvider):
"""Generic callsign data provider class for providers that fetch their data from the web by downloading a file."""
def __init__(self, name, provider_config, url, cache_file_path, poll_interval):
def __init__(self, name, provider_config, url, cache_file_path, poll_interval, storage):
""" Set up the provider, note poll_interval is in *days*."""
super().__init__(name, provider_config)
super().__init__(name, provider_config, storage)
self._url = url
self._cache_file_path = cache_file_path
self._poll_interval = poll_interval
@@ -24,12 +24,15 @@ class FileDownloadCallsignDataProvider(CallsignDataProvider):
self._stop_event = Event()
self._url_data_cache = URLDataCache("callsigndata_" + name)
if self.enabled:
self.status = "Ready"
def start(self):
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
# subsequent polls, so start() returns immediately and the application can continue starting.
logging.info(
"Set up query of " + self.name + " callsign reference data every " + str(self._poll_interval) + " days.")
self._thread = Thread(target=self._run, daemon=True)
self._thread = Thread(target=self._run, name=f"FileDownloadCallsignDataProvider-{self.name}")
self._thread.start()
def stop(self):
+142
View File
@@ -0,0 +1,142 @@
import logging
import urllib.parse
from datetime import timedelta, datetime
import pytz
import xmltodict
from pyhamtools import callinfo
from requests import ConnectTimeout, ReadTimeout
from requests_cache import CachedSession
from core.config import SERVER_OWNER_CALLSIGN
from core.constants import HTTP_HEADERS, SOFTWARE_VERSION
from core.data_store import DATA_STORE, CACHE_DIR
from core.url_data_cache import URLDataCache
from data.callsign import Callsign
from providers.callsigndata.api_query_callsign_data_provider import APIQueryCallsignDataProvider
class HamQTH(APIQueryCallsignDataProvider):
"""Callsign data provider for HamQTH."""
def __init__(self, provider_config):
super().__init__("HamQTH", provider_config, DATA_STORE.callsign_data_hamqth)
self._HAMQTH_BASE_URL = "https://www.hamqth.com/xml.php"
self._PRG = ("Spothole v" + SOFTWARE_VERSION + " operated by " + SERVER_OWNER_CALLSIGN).replace(" ", "_")
self._URL_DATA_CACHE = URLDataCache("hamqth")
# Separate URL cache for session key lookups. Once a session key is returned from logging in with a username
# and password, this is valid for an hour, so our cache stores this specifically for 55 minutes.
self._CREDENTIALS_CACHE = CachedSession(CACHE_DIR + "/urls/hamqth-creds",
expire_after=timedelta(minutes=55))
def _perform_new_lookup(self, callsign, lookup_credentials):
# If we don't have HamQTH credentials, skip this lookup Return None so we don't *cache* the lack of data, because
# # someone might provide credentials next time around.
if not lookup_credentials or not ((lookup_credentials.hamqth_username and lookup_credentials.hamqth_password)
or lookup_credentials.hamqth_session_id):
return None
try:
# Obtain session key from credentials, by looking it up from username & password if necessary.
session_id = None
if lookup_credentials.hamqth_session_id:
session_id = lookup_credentials.hamqth_session_id
elif lookup_credentials.hamqth_username and lookup_credentials.hamqth_password:
try:
session_data = self._CREDENTIALS_CACHE.get(
self._HAMQTH_BASE_URL + "?u=" + urllib.parse.quote_plus(lookup_credentials.hamqth_username) +
"&p=" + urllib.parse.quote_plus(lookup_credentials.hamqth_password),
headers=HTTP_HEADERS).content
dict_data = xmltodict.parse(session_data)
if "session_id" in dict_data["HamQTH"]["session"]:
session_id = str(dict_data["HamQTH"]["session"]["session_id"])
else:
# Log this failure at debug level only, not our problem if user entered the wrong password.
logging.debug("HamQTH login details incorrect, failed to look up with HamQTH.")
return None
except Exception:
logging.error("Exception when getting HamQTH session key")
return None
if not session_id:
return None
# Try the call as given, then fall back to the base call (strips /P, /M etc.)
calls_to_try = [callsign]
try:
home_call = callinfo.Callinfo.get_homecall(callsign)
if home_call != callsign:
calls_to_try.append(home_call)
except ValueError:
logging.debug("Could not look up home call for callsign %s", callsign)
# Try looking up each call using the API
for lookup_call in calls_to_try:
try:
response = self._URL_DATA_CACHE.get(
self._HAMQTH_BASE_URL + "?id=" + session_id + "&callsign=" + urllib.parse.quote_plus(
lookup_call) + "&prg=" + self._PRG, headers=HTTP_HEADERS, timeout=10)
if response.ok:
# Found data, convert it to our object and return it
data = xmltodict.parse(response.content)["HamQTH"]["search"]
self.status = "OK"
self.last_update_time = datetime.now(pytz.UTC)
self.lookup_count += 1
return self.hamqth_response_to_callsign(callsign, data)
elif not response.from_cache:
logging.warning("HTTP %d looking up callsign %s using HamQTH", response.status_code,
lookup_call)
except (KeyError, ValueError):
continue
except ConnectionError:
logging.warning(f"Connection error when looking up callsign %s using HamQTH", lookup_call)
continue
except (ConnectTimeout, ReadTimeout):
logging.warning(f"Timeout when looking up callsign %s using HamQTH", lookup_call)
continue
except Exception:
logging.error("Exception when looking up callsign %s using HamQTH", lookup_call, exc_info=True)
continue
# Not found in HamQTH; return a Callsign object with no data so we cache that and don't keep retrying
return Callsign(call=callsign)
except Exception as e:
self.status = "Error"
logging.error("Exception when looking up data from HamQTH", e, exc_info=True)
# Return None, this won't be cached so we will be asked to query data again for this call next time.
return None
@staticmethod
def hamqth_response_to_callsign(callsign, data):
"""Convert the "Callsign" block in HamQTH's API response to our own Callsign object."""
# Check for sensible latitudes
lat = None
lon = None
if "latitude" in data and "longitude" in data and (
float(data["latitude"]) != 0 or float(data["longitude"]) != 0) and -89.9 < float(
data["latitude"]) < 89.9:
lat = float(data["latitude"])
lon = float(data["longitude"])
# Check for sensible grids
grid = None
if "grid" in data and not data["grid"].startswith("AA00"):
grid = data["grid"]
return Callsign(call=callsign,
home_call=callinfo.Callinfo.get_homecall(callsign),
name=data["nick"] if "nick" in data else None,
qth=data["qth"] if "qth" in data else None,
country=data["country"] if "country" in data else None,
continent=data["continent"] if "continent" in data else None,
latitude=lat,
longitude=lon,
grid=grid,
dxcc_id=int(data["adif"]) if "adif" in data else None,
cq_zone=int(data["cq"]) if "cq" in data else None,
itu_zone=int(data["itu"]) if "itu" in data else None,
location_source="HOME QTH")
+162
View File
@@ -0,0 +1,162 @@
import logging
import urllib.parse
from datetime import timedelta, datetime
import pytz
import xmltodict
from pyhamtools import callinfo
from requests import ConnectTimeout, ReadTimeout
from requests_cache import CachedSession
from core.constants import HTTP_HEADERS
from core.data_store import DATA_STORE, CACHE_DIR
from core.url_data_cache import URLDataCache
from data.callsign import Callsign
from providers.callsigndata.api_query_callsign_data_provider import APIQueryCallsignDataProvider
class QRZ(APIQueryCallsignDataProvider):
"""Callsign data provider for QRZ.com."""
def __init__(self, provider_config):
super().__init__("QRZ.com", provider_config, DATA_STORE.callsign_data_qrz)
self._QRZ_BASE_URL = "https://xmldata.qrz.com/xml/current/"
self._URL_DATA_CACHE = URLDataCache("qrz")
# Separate URL cache for session key lookups. Once a session key is returned from logging in with a username
# and password, this is valid for an hour, so our cache stores this specifically for 55 minutes.
self._CREDENTIALS_CACHE = CachedSession(CACHE_DIR + "/urls/qrz-creds",
expire_after=timedelta(minutes=55))
def _perform_new_lookup(self, callsign, lookup_credentials):
# If we don't have QRZ credentials, skip this lookup. Return None so we don't *cache* the lack of data, because
# someone might provide credentials next time around.
if not lookup_credentials or not ((lookup_credentials.qrz_username and lookup_credentials.qrz_password)
or lookup_credentials.qrz_session_key):
return None
try:
# Obtain session key from credentials, by looking it up from username & password if necessary.
session_key = None
if lookup_credentials.qrz_session_key:
session_key = lookup_credentials.qrz_session_key
elif lookup_credentials.qrz_username and lookup_credentials.qrz_password:
try:
login_response = self._CREDENTIALS_CACHE.get(
self._QRZ_BASE_URL + "?username=" + urllib.parse.quote_plus(lookup_credentials.qrz_username) +
"&password=" + urllib.parse.quote_plus(lookup_credentials.qrz_password) + "&agent=spothole",
headers=HTTP_HEADERS).content
login_data = xmltodict.parse(login_response)
session = login_data.get("QRZDatabase", {}).get("Session", {})
if "Key" in session:
session_key = str(session["Key"])
else:
# Log this failure at debug level only, not our problem if user entered the wrong password.
logging.debug("QRZ.com login details incorrect, failed to look up with QRZ.")
return None
except Exception:
logging.error("Exception when getting QRZ.com session key")
return None
if not session_key:
return None
# Try the call as given, then fall back to the base call (strips /P, /M etc.)
calls_to_try = [callsign]
try:
home_call = callinfo.Callinfo.get_homecall(callsign)
if home_call != callsign:
calls_to_try.append(home_call)
except ValueError:
logging.debug("Could not look up home call for callsign %s", callsign)
# Try looking up each call using the API
for lookup_call in calls_to_try:
try:
response = self._URL_DATA_CACHE.get(
self._QRZ_BASE_URL + "?s=" + session_key + "&callsign=" + urllib.parse.quote_plus(lookup_call),
headers=HTTP_HEADERS, timeout=10)
if response.ok:
qrz_response = xmltodict.parse(response.content).get("QRZDatabase", {})
if qrz_response:
if "Callsign" in qrz_response:
qrz_data = qrz_response.get("Callsign")
self.status = "OK"
self.last_update_time = datetime.now(pytz.UTC)
self.lookup_count += 1
# Found data, convert it to our object and return it
return self.qrz_response_to_callsign(callsign, qrz_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):
continue
except ConnectionError:
logging.warning(f"Connection error when looking up callsign %s using QRZ", lookup_call)
continue
except (ConnectTimeout, ReadTimeout):
logging.warning(f"Timeout when looking up callsign %s using QRZ.", lookup_call)
continue
except Exception:
logging.error("Exception when looking up callsign %s using QRZ", lookup_call, exc_info=True)
continue
# Not found in QRZ; return a Callsign object with no data so we cache that and don't keep retrying
return Callsign(call=callsign)
except Exception as e:
self.status = "Error"
logging.error("Exception when looking up data from QRZ.com", e, exc_info=True)
# Return None, this won't be cached so we will be asked to query data again for this call next time.
return None
@staticmethod
def qrz_response_to_callsign(callsign, data):
"""Convert the "Callsign" block in QRZ's API response to our own Callsign object."""
# Get a name
name = None
if "name_fmt" in data:
name = data["name_fmt"]
if "fname" in data:
name = data["fname"]
if "nick" in data:
name = name + " \"" + data["nick"] + "\""
if "name" in data:
name = name + " " + data["name"]
# Check for sensible latitudes
lat = None
lon = None
if "latitude" in data and "longitude" in data and (
float(data["latitude"]) != 0 or float(data["longitude"]) != 0) and -89.9 < float(
data["latitude"]) < 89.9:
lat = float(data["latitude"])
lon = float(data["longitude"])
# Check for sensible grids
grid = None
if "grid" in data and not data["grid"].startswith("AA00"):
grid = data["grid"]
return Callsign(call=callsign,
home_call=callinfo.Callinfo.get_homecall(callsign),
name=name,
qth=data["addr2"] if "addr2" in data else None,
country=data["country"] if "country" in data else None,
continent=data["continent"] if "continent" in data else None,
latitude=lat,
longitude=lon,
grid=grid,
dxcc_id=int(data["adif"]) if "adif" in data else None,
cq_zone=int(data["cqzone"]) if "cqzone" in data else None,
itu_zone=int(data["ituzone"]) if "ituzone" in data else None,
location_source="HOME QTH")
@@ -28,7 +28,7 @@ class FileDownloadSIGRefDataProvider(SIGRefDataProvider):
# subsequent polls, so start() returns immediately and the application can continue starting.
logging.info(
"Set up query of " + self.sig_name + " SIG ref data every " + str(self._poll_interval) + " days.")
self._thread = Thread(target=self._run, daemon=True)
self._thread = Thread(target=self._run, name=f"FileDownloadSIGRefDataProvider-{self.sig_name}")
self._thread.start()
def stop(self):
+1 -1
View File
@@ -58,7 +58,7 @@ class GIROIonosonde(SolarConditionsProvider):
def start(self):
logging.info(f"Set up query of GIRO ionosonde data API every {POLL_INTERVAL} seconds.")
self._thread = Thread(target=self._run, daemon=True)
self._thread = Thread(target=self._run, name="GIROIonosondeDataProvider")
self._thread.start()
def stop(self):
@@ -24,7 +24,7 @@ class HTTPSolarConditionsProvider(SolarConditionsProvider):
def start(self):
logging.info(
"Set up query of " + self.name + " solar conditions API every " + str(self._poll_interval) + " seconds.")
self._thread = Thread(target=self._run, daemon=True)
self._thread = Thread(target=self._run, name=f"HTTPSolarConditionsProvider-{self.name}")
self._thread.start()
def stop(self):
+1 -1
View File
@@ -30,7 +30,7 @@ class KC2GProp(SolarConditionsProvider):
def start(self):
logging.info(f"Set up query of KC2G ionosonde data API every {POLL_INTERVAL} seconds.")
self._thread = Thread(target=self._run, daemon=True)
self._thread = Thread(target=self._run, name="KC2GPropProvider")
self._thread.start()
def stop(self):
+1 -1
View File
@@ -15,7 +15,7 @@ class APRSIS(SpotProvider):
def __init__(self, provider_config):
super().__init__("APRS-IS", provider_config)
self._thread = Thread(target=self._connect)
self._thread = Thread(target=self._connect, name="APRSISSpotProvider")
self._thread.daemon = True
self._aprsis = None
+1 -1
View File
@@ -36,7 +36,7 @@ class DXCluster(SpotProvider):
self._allow_rbn_spots = provider_config["allow_rbn_spots"] if "allow_rbn_spots" in provider_config else False
self._spot_line_pattern = self._LINE_PATTERN_ALLOW_RBN if self._allow_rbn_spots else self._LINE_PATTERN_EXCLUDE_RBN
self._telnet = None
self._thread = Thread(target=self._handle)
self._thread = Thread(target=self._handle, name=f"DXClusterSpotProvider-{self.name}")
self._thread.daemon = True
self._running = True
+1 -1
View File
@@ -26,7 +26,7 @@ class HTTPSpotProvider(SpotProvider):
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
# subsequent polls, so start() returns immediately and the application can continue starting.
logging.info("Set up query of " + self.name + " spot API every " + str(self._poll_interval) + " seconds.")
self._thread = Thread(target=self._run, daemon=True)
self._thread = Thread(target=self._run, name=f"HTTPSpotProvider-{self.name}")
self._thread.start()
def stop(self):
+1 -1
View File
@@ -27,7 +27,7 @@ class RBN(SpotProvider):
super().__init__(name, provider_config)
self._port = provider_config["port"]
self._telnet = None
self._thread = Thread(target=self._handle)
self._thread = Thread(target=self._handle, name=f"RBNSpotProvider-{self.name}")
self._thread.daemon = True
self._running = True
+46 -27
View File
@@ -1,7 +1,6 @@
import logging
from datetime import datetime
from threading import Thread
from time import sleep
from threading import Event, Lock, Thread
import pytz
from requests_sse import EventSource
@@ -16,24 +15,35 @@ class SSESpotProvider(SpotProvider):
def __init__(self, name, provider_config, url):
super().__init__(name, provider_config)
self._url = url
self._event_source = None
self._thread = None
self._stopped = False
self._last_event_id = None
self._stop_event = Event()
self._event_source_lock = Lock()
self._event_source = None
def start(self):
logging.info("Set up SSE connection to " + self.name + " spot API.")
self._stopped = False
self._thread = Thread(target=self._run)
self._stop_event.clear()
self._thread = Thread(target=self._run, name=f"SSESpotProvider-{self.name}")
self._thread.daemon = True
self._thread.start()
def stop(self):
self._stopped = True
if self._event_source:
self._event_source.close()
self._stop_event.set()
with self._event_source_lock:
event_source = self._event_source
if event_source:
try:
event_source.close()
except Exception:
logging.exception(
"Exception closing SSE connection for " + self.name + " during stop()")
if self._thread:
self._thread.join()
self._thread.join(timeout=15)
if self._thread.is_alive():
logging.warning(self.name + " SSE worker thread did not exit on time and will be killed.")
def _on_open(self):
self.status = "Waiting for Data"
@@ -41,36 +51,45 @@ class SSESpotProvider(SpotProvider):
def _on_error(self):
self.status = "Connecting"
def _set_event_source(self, event_source):
with self._event_source_lock:
self._event_source = event_source
def _run(self):
while not self._stopped:
while not self._stop_event.is_set():
try:
logging.debug("Connecting to " + self.name + " spot API...")
self.status = "Connecting"
with EventSource(self._url, headers=HTTP_HEADERS, latest_event_id=self._last_event_id, timeout=30,
with EventSource(self._url, headers=HTTP_HEADERS, latest_event_id=self._last_event_id, timeout=10,
on_open=self._on_open, on_error=self._on_error) as event_source:
self._event_source = event_source
for event in self._event_source:
if event.type == 'message':
try:
self._last_event_id = event.last_event_id
new_spot = self._sse_message_to_spot(event.data)
if new_spot:
self._submit(new_spot)
self._set_event_source(event_source)
try:
for event in event_source:
if self._stop_event.is_set():
break
if event.type == 'message':
try:
self._last_event_id = event.last_event_id
new_spot = self._sse_message_to_spot(event.data)
if new_spot:
self._submit(new_spot)
self.status = "OK"
self.last_update_time = datetime.now(pytz.UTC)
logging.debug("Received data from " + self.name + " spot API.")
self.status = "OK"
self.last_update_time = datetime.now(pytz.UTC)
logging.debug("Received data from " + self.name + " spot API.")
except Exception:
logging.exception(
"Exception processing message from SSE Spot Provider (" + self.name + ")")
except Exception:
logging.exception(
"Exception processing message from SSE Spot Provider (" + self.name + ")")
finally:
self._set_event_source(None)
except Exception:
self.status = "Error"
logging.exception("Exception in SSE Spot Provider (" + self.name + ")")
else:
self.status = "Disconnected"
sleep(5) # Wait before trying to reconnect
self._stop_event.wait(timeout=5) # Wait before trying to reconnect
def _sse_message_to_spot(self, message_data):
"""Convert an SSE message received from the API into a spot. The whole message data is provided here so the subclass
+1 -1
View File
@@ -24,7 +24,7 @@ class WebsocketSpotProvider(SpotProvider):
def start(self):
logging.info("Set up websocket connection to " + self.name + " spot API.")
self._stopped = False
self._thread = Thread(target=self._run)
self._thread = Thread(target=self._run, name=f"WebsocketSpotProvider-{self.name}")
self._thread.daemon = True
self._thread.start()
@@ -29,7 +29,7 @@ class FileDownloadStaticDataProvider(StaticDataProvider):
# subsequent polls, so start() returns immediately and the application can continue starting.
logging.info(
"Set up query of " + self.name + " static reference data every " + str(self._poll_interval) + " days.")
self._thread = Thread(target=self._run, daemon=True)
self._thread = Thread(target=self._run, name=f"FileDownloadStaticDataProvider-{self.name}")
self._thread.start()
def stop(self):
+5 -23
View File
@@ -8,15 +8,15 @@ import tornado
from tornado import httputil
from tornado.web import Application
from core.call_lookup_helper import get_call_info
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.prometheus_metrics_handler import api_requests_counter
from core.sig_lookup_helper import populate_missing_sig_ref_info
from core.sig_utils import get_ref_regex_for_sig
from core.sig_lookup_helper import populate_sig_ref_info
from core.utils import safe_json_dumps
from data.lookup_credentials import extract_credentials
from data.sig_ref import SIGRef
from data.spot import Spot
class APILookupCallHandler(tornado.web.RequestHandler):
@@ -45,27 +45,9 @@ class APILookupCallHandler(tornado.web.RequestHandler):
if "call" in query_params.keys():
call = str(query_params.get("call")).upper()
if re.match(r"^[A-Z0-9/\-]*$", call):
# Take the callsign, make a "fake spot" so we can run infer_missing() on it, then repack the
# resulting data in the correct way for the API response.
credentials = extract_credentials(query_params)
fake_spot = Spot(dx_call=call)
fake_spot.infer_missing(credentials)
data = {
"call": call,
"name": fake_spot.dx_name,
"qth": fake_spot.dx_qth,
"country": fake_spot.dx_country,
"flag": fake_spot.dx_flag,
"continent": fake_spot.dx_continent,
"dxcc_id": fake_spot.dx_dxcc_id,
"cq_zone": fake_spot.dx_cq_zone,
"itu_zone": fake_spot.dx_itu_zone,
"grid": fake_spot.dx_grid,
"latitude": fake_spot.dx_latitude,
"longitude": fake_spot.dx_longitude,
"location_source": fake_spot.dx_location_source
}
self.write(safe_json_dumps(data))
callsign_data = get_call_info(call, credentials)
self.write(safe_json_dumps(callsign_data))
else:
self.write(safe_json_dumps("Error - '" + call + "' does not look like a valid callsign."))
@@ -112,7 +94,7 @@ class APILookupSIGRefHandler(tornado.web.RequestHandler):
ref_id = str(query_params.get("id")).upper()
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):
data = populate_sig_ref_info(SIGRef(id=ref_id, sig=sig))
data = populate_missing_sig_ref_info(SIGRef(id=ref_id, sig=sig))
self.write(safe_json_dumps(data))
else:
+5 -3
View File
@@ -38,10 +38,12 @@ class APIOptionsHandler(tornado.web.RequestHandler):
"mode_types": MODE_TYPES,
"sigs": SIGS,
# Spot/alert sources are filtered for only ones that are enabled in config, no point letting the user toggle things that aren't even available.
"spot_sources": list(
"spot_providers": list(
map(lambda p: p["name"], filter(lambda p: p["enabled"], self._status_data["spot_providers"]))),
"alert_sources": list(
"alert_providers": list(
map(lambda p: p["name"], filter(lambda p: p["enabled"], self._status_data["alert_providers"]))),
"callsign_data_providers": list(
map(lambda p: p["name"], filter(lambda p: p["enabled"], self._status_data["callsign_data_providers"]))),
"continents": CONTINENTS,
"propagation_modes": list(PROPAGATION_MODES.values()),
"max_spot_age": MAX_SPOT_AGE,
@@ -49,7 +51,7 @@ class APIOptionsHandler(tornado.web.RequestHandler):
# If spotting to this server is enabled, "API" is another valid spot source even though it does not come from
# one of our proviers.
if ALLOW_SPOTTING:
options["spot_sources"].append("API")
options["spot_providers"].append("API")
self.write(safe_json_dumps(options))
self.set_status(200)
+5 -2
View File
@@ -11,6 +11,9 @@ class SSEBroadcaster:
def __init__(self):
self._handlers = set()
self._lock = threading.Lock()
self._loop = None
def bind_to_web_server_loop(self):
self._loop = IOLoop.current()
def register(self, handler):
@@ -22,9 +25,9 @@ class SSEBroadcaster:
self._handlers.discard(handler)
def publish(self, value):
self._loop.add_callback(self._fan_out, value)
self._loop.add_callback(self._broadcast, value)
def _fan_out(self, value):
def _broadcast(self, value):
with self._lock:
handlers = list(self._handlers)
for handler in handlers:
+5
View File
@@ -1,6 +1,7 @@
import asyncio
import logging
import os
import threading
import tornado
from tornado.web import StaticFileHandler
@@ -61,6 +62,10 @@ class WebServer:
async def _start_inner(self):
"""Start method (async). Sets up the Tornado application."""
# Bind the SSE broadcasters to the web server's loop, so they fire correctly
self._spot_broadcaster.bind_to_web_server_loop()
self._alert_broadcaster.bind_to_web_server_loop()
# Prepare a list of common arguments that are passed in to every API & page handler. This is just a basic thing
# to avoid copy-pasting the same thing to every route declaration below.
handler_opts = {"web_server_metrics": self.web_server_metrics}
-4
View File
@@ -4,7 +4,6 @@ import os
import signal
import sys
from core.call_lookup_helper import lookup_helper
from core.config import SERVER_OWNER_CALLSIGN, LOG_LEVEL
from core.constants import SOFTWARE_VERSION
from core.data_providers import DATA_PROVIDERS
@@ -49,9 +48,6 @@ if __name__ == '__main__':
# Set up data store
DATA_STORE.setup()
# Set up lookup helper
lookup_helper.start()
# Set up and start data providers
DATA_PROVIDERS.setup()
DATA_PROVIDERS.start()
+33 -8
View File
@@ -15,6 +15,14 @@ info:
## Changelog
### 2.0
* Added `sig_ref_data_providers`, `static_data_providers` and `callsign_data_providers` to `/status` response
* Added `callsign_data_providers` to `/options` response
* BREAKING: Removed `cleanup` from `/status` response
* BREAKING: in the `/options` response, renamed `spot_sources` and `alert_sources` to `spot_providers` and
`alert_providers`
### 1.4
* Spots can now include a "propagation_mode" field, and the `/options` call enumerates the options that can have.
@@ -22,8 +30,6 @@ info:
* 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
* Added `sig_ref_data_providers`, `static_data_providers` and `callsign_data_providers` to status and removed `cleanup`
### 1.3
@@ -46,8 +52,7 @@ info:
license:
name: The Unlicense
url: https://unlicense.org/#the-unlicense
version: v1.4
version: v2.0
servers:
- url: https://spothole.app/api/v1
@@ -324,7 +329,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/CallLookup'
$ref: '#/components/schemas/CallsignData'
'422':
description: Validation error e.g. callsign missing or format incorrect
content:
@@ -1790,6 +1795,10 @@ components:
The last time at which this provider received data, UTC seconds since UNIX epoch. If this
is zero, the provider has never updated.
example: 1759579508
lookup_count:
type: number
description: The number of callsign lookups performed using this provider since the server was started.
example: 1234
SpotList:
type: array
@@ -1929,12 +1938,24 @@ components:
description: An array of all the supported Special Interest Groups.
items:
$ref: '#/components/schemas/SIG'
sources:
spot_providers:
type: array
description: An array of all the supported data sources.
description: An array of all the supported spot data sources.
items:
type: string
example: "Cluster"
alert_providers:
type: array
description: An array of all the supported alert data sources.
items:
type: string
example: "POTA"
callsign_data_providers:
type: array
description: An array of all the supported callsign lookup providers.
items:
type: string
example: "QRZ.com"
continents:
type: array
description: An array of all the supported continents.
@@ -1961,12 +1982,16 @@ components:
on this server.
example: true
CallLookup:
CallsignData:
type: object
properties:
call:
type: string
description: Callsign, as provided to the API
example: DL/M0TRT/P
home_call:
type: string
description: The "home" call, without prefixes or suffixes
example: M0TRT
name:
type: string
+1 -1
View File
@@ -288,7 +288,7 @@ function loadOptions() {
// Populate the filters panel
generateMultiToggleFilterCard("#dx-continent-options", "dx_continent", options["continents"]);
generateMultiToggleFilterCard("#source-options", "source", options["alert_sources"]);
generateMultiToggleFilterCard("#source-options", "source", options["alert_providers"]);
// Load URL params. These may select things from the various filter & display options, so the function needs
// to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress
+1 -1
View File
@@ -291,7 +291,7 @@ function loadOptions() {
generateMultiToggleFilterCard("#dx-continent-options", "dx_continent", options["continents"]);
generateMultiToggleFilterCard("#de-continent-options", "de_continent", options["continents"]);
generateModesMultiToggleFilterCard(options["modes"]);
generateSourcesMultiToggleFilterCard(options["spot_sources"], spotProvidersEnabledByDefault);
generateSourcesMultiToggleFilterCard(options["spot_providers"], spotProvidersEnabledByDefault);
// Load URL params. These may select things from the various filter & display options, so the function needs
// to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress
+1 -1
View File
@@ -322,7 +322,7 @@ function loadOptions() {
generateMultiToggleFilterCard("#dx-continent-options", "dx_continent", options["continents"]);
generateMultiToggleFilterCard("#de-continent-options", "de_continent", options["continents"]);
generateModesMultiToggleFilterCard(options["modes"]);
generateSourcesMultiToggleFilterCard(options["spot_sources"], spotProvidersEnabledByDefault);
generateSourcesMultiToggleFilterCard(options["spot_providers"], spotProvidersEnabledByDefault);
// Load URL params. These may select things from the various filter & display options, so the function needs
// to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress
+1 -1
View File
@@ -436,7 +436,7 @@ function loadOptions() {
generateMultiToggleFilterCard("#dx-continent-options", "dx_continent", options["continents"]);
generateMultiToggleFilterCard("#de-continent-options", "de_continent", options["continents"]);
generateModesMultiToggleFilterCard(options["modes"]);
generateSourcesMultiToggleFilterCard(options["spot_sources"], spotProvidersEnabledByDefault);
generateSourcesMultiToggleFilterCard(options["spot_providers"], spotProvidersEnabledByDefault);
// Load URL params. These may select things from the various filter & display options, so the function needs
// to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress
+7 -6
View File
@@ -14,7 +14,7 @@ function loadStatus() {
jsonData["spot_providers"].forEach(p => {
$("#spot-providers-status-container").append(`
<div class="row row-cols-1 row-cols-md-4 g-4 mb-2">
<div class="row row-cols-1 row-cols-md-4 g-4 mb-4 mb-md-2">
<div class="col"><strong>${p["name"]}</strong></div>
<div class="col">Status: ${p["status"]}</div>
<div class="col">Last updated: ${(p["enabled"] && p["last_updated"] > 0) ? moment.unix(p["last_updated"]).utc().fromNow() : "N/A"}</div>
@@ -24,7 +24,7 @@ function loadStatus() {
jsonData["alert_providers"].forEach(p => {
$("#alert-providers-status-container").append(`
<div class="row row-cols-1 row-cols-md-4 g-4 mb-2">
<div class="row row-cols-1 row-cols-md-4 g-4 mb-4 mb-md-2">
<div class="col"><strong>${p["name"]}</strong></div>
<div class="col">Status: ${p["status"]}</div>
<div class="col">Last updated: ${(p["enabled"] && p["last_updated"] > 0) ? moment.unix(p["last_updated"]).utc().fromNow() : "N/A"}</div>
@@ -33,7 +33,7 @@ function loadStatus() {
jsonData["solar_condition_providers"].forEach(p => {
$("#condition-providers-status-container").append(`
<div class="row row-cols-1 row-cols-md-4 g-4 mb-2">
<div class="row row-cols-1 row-cols-md-4 g-4 mb-4 mb-md-2">
<div class="col"><strong>${p["name"]}</strong></div>
<div class="col">Status: ${p["status"]}</div>
<div class="col">Last updated: ${(p["enabled"] && p["last_updated"] > 0) ? moment.unix(p["last_updated"]).utc().fromNow() : "N/A"}</div>
@@ -42,7 +42,7 @@ function loadStatus() {
jsonData["static_data_providers"].forEach(p => {
$("#static-data-providers-status-container").append(`
<div class="row row-cols-1 row-cols-md-4 g-4 mb-2">
<div class="row row-cols-1 row-cols-md-4 g-4 mb-4 mb-md-2">
<div class="col"><strong>${p["name"]}</strong></div>
<div class="col">Status: ${p["status"]}</div>
<div class="col">Last updated: ${(p["enabled"] && p["last_updated"] > 0) ? moment.unix(p["last_updated"]).utc().fromNow() : "N/A"}</div>
@@ -51,7 +51,7 @@ function loadStatus() {
jsonData["sig_ref_data_providers"].forEach(p => {
$("#sig-ref-data-providers-status-container").append(`
<div class="row row-cols-1 row-cols-md-4 g-4 mb-2">
<div class="row row-cols-1 row-cols-md-4 g-4 mb-4 mb-md-2">
<div class="col"><strong>${p["sig_name"]}</strong></div>
<div class="col">Status: ${p["status"]}</div>
<div class="col">Last updated: ${(p["enabled"] && p["last_updated"] > 0) ? moment.unix(p["last_updated"]).utc().fromNow() : "N/A"}</div>
@@ -61,10 +61,11 @@ function loadStatus() {
jsonData["callsign_data_providers"].forEach(p => {
$("#callsign-data-providers-status-container").append(`
<div class="row row-cols-1 row-cols-md-4 g-4 mb-2">
<div class="row row-cols-1 row-cols-md-4 g-4 mb-4 mb-md-2">
<div class="col"><strong>${p["name"]}</strong></div>
<div class="col">Status: ${p["status"]}</div>
<div class="col">Last updated: ${(p["enabled"] && p["last_updated"] > 0) ? moment.unix(p["last_updated"]).utc().fromNow() : "N/A"}</div>
<div class="col">Lookups: ${(p["enabled"] && p["lookup_count"] > 0) ? p["lookup_count"] : "N/A"}</div>
</div>`);
});
});
+1 -1
View File
@@ -76,7 +76,7 @@
</div>
<script src="/static/js/add-spot.js?v=1785434214"></script>
<script src="/static/js/add-spot.js?v=1785958091"></script>
<script>$(document).ready(function () {
$("#nav-link-add-spot").addClass("active");
}); <!-- highlight active page in nav --></script>
+9 -2
View File
@@ -8,7 +8,8 @@
</div>
<div class="col-auto">
<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,
show_data_button=web_ui_options["qrz-enabled"] or web_ui_options["hamqth-enabled"]) %}
</div>
</div>
</div>
@@ -50,19 +51,25 @@
</div>
</div>
{% if web_ui_options["qrz-enabled"] or web_ui_options["hamqth-enabled"] %}
<div id="data-area" class="appearing-panel card mb-3">
{% module Template("widgets/data-area-header.html", web_ui_options=web_ui_options) %}
<div class="card-body">
<div class="row row-cols-1 row-cols-md-4 g-4">
{% if web_ui_options["qrz-enabled"] %}
<div class="col">
{% module Template("cards/qrz.html", web_ui_options=web_ui_options) %}
</div>
{% end %}
{% if web_ui_options["hamqth-enabled"] %}
<div class="col">
{% module Template("cards/hamqth.html", web_ui_options=web_ui_options) %}
</div>
{% end %}
</div>
</div>
</div>
{% end %}
<div id="table-container">
<table id="table" class="table">
@@ -75,7 +82,7 @@
</div>
<script src="/static/js/alerts.js?v=1785434213"></script>
<script src="/static/js/alerts.js?v=1785958091"></script>
<script>$(document).ready(function () {
$("#nav-link-alerts").addClass("active");
}); <!-- highlight active page in nav --></script>
+10 -3
View File
@@ -6,7 +6,8 @@
<div class="col-auto me-auto pt-3"></div>
<div class="col-auto">
<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,
show_data_button=web_ui_options["qrz-enabled"] or web_ui_options["hamqth-enabled"]) %}
</div>
</div>
</div>
@@ -54,19 +55,25 @@
</div>
</div>
{% if web_ui_options["qrz-enabled"] or web_ui_options["hamqth-enabled"] %}
<div id="data-area" class="appearing-panel card mb-3">
{% module Template("widgets/data-area-header.html", web_ui_options=web_ui_options) %}
<div class="card-body">
<div class="row row-cols-1 row-cols-md-4 g-4">
{% if web_ui_options["qrz-enabled"] %}
<div class="col">
{% module Template("cards/qrz.html", web_ui_options=web_ui_options) %}
</div>
{% end %}
{% if web_ui_options["hamqth-enabled"] %}
<div class="col">
{% module Template("cards/hamqth.html", web_ui_options=web_ui_options) %}
</div>
{% end %}
</div>
</div>
</div>
{% end %}
<div id="bands-container"></div>
@@ -75,8 +82,8 @@
<script>
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
</script>
<script src="/static/js/spotsbandsandmap.js?v=1785434214"></script>
<script src="/static/js/bands.js?v=1785434214"></script>
<script src="/static/js/spotsbandsandmap.js?v=1785958091"></script>
<script src="/static/js/bands.js?v=1785958091"></script>
<script>$(document).ready(function () {
$("#nav-link-bands").addClass("active");
}); <!-- highlight active page in nav --></script>
+5 -5
View File
@@ -1,6 +1,6 @@
{% extends "skeleton.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/css/style.css?v=1785434213" type="text/css">
<link rel="stylesheet" href="/static/css/style.css?v=1785958091" type="text/css">
<link href="/static/vendor/css/bootstrap-5.3.8.min.css" rel="stylesheet">
<link href="/static/vendor/css/fontawesome-6.7.2.min.css" rel="stylesheet">
<link href="/static/vendor/css/solid-6.7.2.min.css" rel="stylesheet">
@@ -10,10 +10,10 @@
<script src="/static/vendor/js/bootstrap-5.3.8.bundle.min.js"></script>
<script src="/static/vendor/js/tinycolor2-1.6.0.min.js"></script>
<script src="/static/js/utils.js?v=1785434213"></script>
<script src="/static/js/ui-ham.js?v=1785434213"></script>
<script src="/static/js/geo.js?v=1785434213"></script>
<script src="/static/js/common.js?v=1785434213"></script>
<script src="/static/js/utils.js?v=1785958091"></script>
<script src="/static/js/ui-ham.js?v=1785958091"></script>
<script src="/static/js/geo.js?v=1785958091"></script>
<script src="/static/js/common.js?v=1785958091"></script>
{% end %}
{% block body %}
<div class="container">
+1 -1
View File
@@ -284,7 +284,7 @@
</div>
<script src="/static/vendor/js/chart-4.4.9.umd.min.js"></script>
<script src="/static/js/conditions.js?v=1785434213"></script>
<script src="/static/js/conditions.js?v=1785958091"></script>
<script>$(document).ready(function () {
$("#nav-link-conditions").addClass("active");
}); <!-- highlight active page in nav --></script>
+10 -3
View File
@@ -20,7 +20,8 @@
<div class="col-auto me-auto pt-3"></div>
<div class="col-auto">
<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,
show_data_button=web_ui_options["qrz-enabled"] or web_ui_options["hamqth-enabled"]) %}
</div>
</div>
</div>
@@ -74,19 +75,25 @@
</div>
</div>
{% if web_ui_options["qrz-enabled"] or web_ui_options["hamqth-enabled"] %}
<div id="data-area" class="appearing-panel card mb-3">
{% module Template("widgets/data-area-header.html", web_ui_options=web_ui_options) %}
<div class="card-body">
<div class="row row-cols-1 row-cols-md-4 g-4">
{% if web_ui_options["qrz-enabled"] %}
<div class="col">
{% module Template("cards/qrz.html", web_ui_options=web_ui_options) %}
</div>
{% end %}
{% if web_ui_options["hamqth-enabled"] %}
<div class="col">
{% module Template("cards/hamqth.html", web_ui_options=web_ui_options) %}
</div>
{% end %}
</div>
</div>
</div>
{% end %}
</div>
</div>
@@ -108,8 +115,8 @@
<script>
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
</script>
<script src="/static/js/spotsbandsandmap.js?v=1785434213"></script>
<script src="/static/js/map.js?v=1785434213"></script>
<script src="/static/js/spotsbandsandmap.js?v=1785958091"></script>
<script src="/static/js/map.js?v=1785958091"></script>
<script>$(document).ready(function () {
$("#nav-link-map").addClass("active");
}); <!-- highlight active page in nav --></script>
+8 -3
View File
@@ -25,7 +25,8 @@
<div class="col-md-8 text-end">
<div class="d-inline-flex gap-3">
{% module Template("widgets/search.html", web_ui_options=web_ui_options) %}
{% 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,
show_data_button=web_ui_options["qrz-enabled"] or web_ui_options["hamqth-enabled"]) %}
</div>
</div>
</div>
@@ -86,12 +87,16 @@
{% module Template("widgets/data-area-header.html", web_ui_options=web_ui_options) %}
<div class="card-body">
<div class="row row-cols-1 row-cols-md-4 g-4">
{% if web_ui_options["qrz-enabled"] %}
<div class="col">
{% module Template("cards/qrz.html", web_ui_options=web_ui_options) %}
</div>
{% end %}
{% if web_ui_options["hamqth-enabled"] %}
<div class="col">
{% module Template("cards/hamqth.html", web_ui_options=web_ui_options) %}
</div>
{% end %}
<div class="col">
{% module Template("cards/location.html", web_ui_options=web_ui_options) %}
</div>
@@ -116,8 +121,8 @@
<script>
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
</script>
<script src="/static/js/spotsbandsandmap.js?v=1785434213"></script>
<script src="/static/js/spots.js?v=1785434213"></script>
<script src="/static/js/spotsbandsandmap.js?v=1785958091"></script>
<script src="/static/js/spots.js?v=1785958091"></script>
<script>$(document).ready(function () {
$("#nav-link-spots").addClass("active");
}); <!-- highlight active page in nav --></script>
+4 -4
View File
@@ -6,19 +6,19 @@
Spothole
</div>
<div class="card-body">
<div class="row row-cols-1 row-cols-md-4 g-4 mb-2">
<div class="row row-cols-1 row-cols-md-4 g-4 mb-4 mb-md-2">
<div class="col"><strong>Metadata</strong></div>
<div class="col">Software Version: <span id="software-version"></span></div>
<div class="col">Owner Callsign: <span id="server-owner-callsign"></span></div>
<div class="col">Up since: <span id="up-since"></span></div>
</div>
<div class="row row-cols-1 row-cols-md-4 g-4 mb-2">
<div class="row row-cols-1 row-cols-md-4 g-4 mb-4 mb-md-2">
<div class="col"><strong>Performance</strong></div>
<div class="col">Memory Use: <span id="memory-use"></span></div>
<div class="col">Total Spots: <span id="total-spots"></span></div>
<div class="col">Total Alerts: <span id="total-alerts"></span></div>
</div>
<div class="row row-cols-1 row-cols-md-4 g-4 mb-2">
<div class="row row-cols-1 row-cols-md-4 g-4 mb-4 mb-md-2">
<div class="col"><strong>Web Server</strong></div>
<div class="col">Status: <span id="web-server-status"></span></div>
<div class="col">Last API call: <span id="web-server-last-api"></span></div>
@@ -81,7 +81,7 @@
</div>
</div>
<script src="/static/js/status.js?v=1785434213"></script>
<script src="/static/js/status.js?v=1785958091"></script>
<script>
$(document).ready(function () {
$("#nav-link-status").addClass("active");
@@ -5,7 +5,9 @@
<button id="display-button" type="button" class="btn btn-outline-secondary" data-bs-toggle="button"
onclick="toggleDisplayPanel();"><i class="fa-solid fa-desktop"></i><span
class="hideonmobile">&nbsp;Display</span></button>
{% if show_data_button %}
<button id="data-button" type="button" class="btn btn-outline-secondary" data-bs-toggle="button"
onclick="toggleDataPanel();"><i class="fa-solid fa-database"></i><span
class="hideonmobile">&nbsp;Your data</span></button>
{% end %}
</div>