Refactor of caching & data storage part 6 #118

This commit is contained in:
Ian Renton
2026-08-01 09:11:40 +01:00
parent c472872e10
commit ed7f13f079
19 changed files with 354 additions and 123 deletions
+7
View File
@@ -166,6 +166,13 @@ alert-providers:
enabled: true enabled: true
# Static reference data providers to use. This allows Spothole to download data such as mapping between callsign
# prefixes and DXCC entities.
static-data-providers:
- class: "K0SWE"
enabled: true
# SIG reference data providers to use. This allows Spothole to download, for example, the WWFF directory that maps WWFF # SIG reference data providers to use. This allows Spothole to download, for example, the WWFF directory that maps WWFF
# park IDs to their name and location. # park IDs to their name and location.
sig-ref-data-providers: sig-ref-data-providers:
+8
View File
@@ -60,6 +60,14 @@ def get_solar_conditions_provider_from_config(config_providers_entry):
return provider_class(config_providers_entry) return provider_class(config_providers_entry)
def get_static_data_provider_from_config(config_providers_entry):
"""Utility method to get a static reference data provider based on the class specified in its config entry."""
module = importlib.import_module('staticdataproviders.' + config_providers_entry["class"].lower())
provider_class = getattr(module, config_providers_entry["class"])
return provider_class(config_providers_entry)
def get_sig_ref_data_provider_from_config(config_providers_entry): def get_sig_ref_data_provider_from_config(config_providers_entry):
"""Utility method to get a SIG reference data provider based on the class specified in its config entry.""" """Utility method to get a SIG reference data provider based on the class specified in its config entry."""
+9 -4
View File
@@ -22,6 +22,7 @@ class DataStore:
self.alerts = None self.alerts = None
self.spots = None self.spots = None
self.callsigns = None self.callsigns = None
self.dxcc_data = None
self.sigrefs = None self.sigrefs = None
self.status_data = None self.status_data = None
self._status = None self._status = None
@@ -42,10 +43,13 @@ class DataStore:
self._status.add("status_data", {}) self._status.add("status_data", {})
self.status_data = self._status.get("status_data") self.status_data = self._status.get("status_data")
# Standard disk cache for SIG ref data. Separate provider threads will repopulate theis on a regular basis # Standard disk cache for static reference and SIG ref data. Separate provider threads will repopulate these on
# but there's no need for a TTL since old data is better than no data. We need to key on both SIG and reference, # a regular basis but there's no need for a TTL since old data is better than no data.
# and trying to do two layers of dict in diskcache absolutely destroys performance with unpickling huge dicts, self.dxcc_data = diskcache.Cache(CACHE_DIR + "dxcc_data")
# so we have an ugly "SIG:ref" syntax for keys to keep it a single level.
# For SIG reference data specifically, we need to key on both SIG *and* reference, and trying to do two layers
# of dict in diskcache absolutely destroys performance with unpickling huge dicts, so we have an ugly "SIG:ref"
# syntax for keys to keep it a single level.
self.sigrefs = diskcache.Cache(CACHE_DIR + "sigrefs") self.sigrefs = diskcache.Cache(CACHE_DIR + "sigrefs")
logging.info(f"Loaded data for %d SIG references.", len(self.sigrefs)) logging.info(f"Loaded data for %d SIG references.", len(self.sigrefs))
@@ -73,6 +77,7 @@ class DataStore:
self.alerts.close() self.alerts.close()
self._solar.close() self._solar.close()
self._status.close() self._status.close()
self.dxcc_data.close()
self.sigrefs.close() self.sigrefs.close()
self.callsigns.close() self.callsigns.close()
+4 -106
View File
@@ -1,7 +1,5 @@
import gzip import gzip
import json
import logging import logging
import re
import urllib.parse import urllib.parse
from datetime import timedelta from datetime import timedelta
@@ -9,14 +7,13 @@ import xmltodict
from diskcache import Cache from diskcache import Cache
from pyhamtools import LookupLib, Callinfo, callinfo from pyhamtools import LookupLib, Callinfo, callinfo
from pyhamtools.exceptions import APIKeyMissingError from pyhamtools.exceptions import APIKeyMissingError
from pyhamtools.frequency import freq_to_band
from pyhamtools.locator import latlong_to_locator from pyhamtools.locator import latlong_to_locator
from requests.exceptions import ConnectionError, ReadTimeout, ConnectTimeout from requests.exceptions import ConnectionError, ReadTimeout, ConnectTimeout
from requests_cache import CachedSession from requests_cache import CachedSession
from core.config import config from core.config import config
from core.constants import BANDS, UNKNOWN_BAND, CW_MODES, PHONE_MODES, DATA_MODES, ALL_MODES, \ from core.constants import HTTP_HEADERS, HAMQTH_PRG
HTTP_HEADERS, HAMQTH_PRG, MODE_ALIASES from core.data_store import DATA_STORE
from core.url_data_cache import URLDataCache from core.url_data_cache import URLDataCache
# QRZ XML field names differ from pyhamtools' normalised names; map them here. # QRZ XML field names differ from pyhamtools' normalised names; map them here.
@@ -84,7 +81,6 @@ class LookupHelper:
self._lookup_lib_basic = None self._lookup_lib_basic = None
self._country_files_cty_plist_download_location = None self._country_files_cty_plist_download_location = None
self._dxcc_json_download_location = None self._dxcc_json_download_location = None
self._dxcc_data = None
def start(self): def start(self):
# Lookup helpers from pyhamtools. We use five (!) of these. The simplest is country-files.com, which downloads # Lookup helpers from pyhamtools. We use five (!) of these. The simplest is country-files.com, which downloads
@@ -118,22 +114,6 @@ class LookupHelper:
filename=self._clublog_xml_download_location) filename=self._clublog_xml_download_location)
self._clublog_callsign_data_cache = Cache('cache/clublog_callsign_lookup_cache') self._clublog_callsign_data_cache = Cache('cache/clublog_callsign_lookup_cache')
# We also get a lookup of DXCC data from K0SWE to use for additional lookups of e.g. flags.
self._dxcc_json_download_location = "cache/dxcc.json"
success = self._download_dxcc_json()
if success:
with open(self._dxcc_json_download_location) as f:
tmp_dxcc_data = json.load(f)["dxcc"]
# Reformat as a map for faster lookup
self._dxcc_data = {}
for dxcc in tmp_dxcc_data:
self._dxcc_data[dxcc["entityCode"]] = dxcc
else:
logging.error("Could not download DXCC data, flags and similar data may be missing!")
# Precompile regex matches for DXCCs to improve efficiency when iterating through them
for dxcc in (self._dxcc_data.values() if self._dxcc_data else []):
dxcc["_prefixRegexCompiled"] = re.compile(dxcc["prefixRegex"])
def _download_country_files_cty_plist(self): 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 """Download the cty.plist file from country-files.com on first startup. The pyhamtools lib can actually download and use
@@ -163,31 +143,6 @@ class LookupHelper:
logging.error("Exception when downloading Clublog cty.xml", e) logging.error("Exception when downloading Clublog cty.xml", e)
return False return False
def _download_dxcc_json(self):
"""Download the dxcc.json file on first startup."""
try:
logging.info("Downloading dxcc.json...")
response = _URL_DATA_CACHE.get(
"https://raw.githubusercontent.com/k0swe/dxcc-json/refs/heads/main/dxcc.json",
headers=HTTP_HEADERS)
if response.ok:
with open(self._dxcc_json_download_location, "w") as f:
f.write(response.text)
f.flush()
return True
else:
logging.warning(f"HTTP {response.status_code} when downloading dxcc.json.")
return False
except ConnectionError:
logging.warning(f"Connection error when downloading dxcc.json.")
except (ConnectTimeout, ReadTimeout):
logging.warning(f"Timeout when downloading dxcc.json.")
except Exception as e:
logging.error("Exception when downloading dxcc.json", e)
return False
def _download_clublog_ctyxml(self): 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 """Download the cty.xml (gzipped) file from Clublog on first startup, so we can use it in preference to querying the
@@ -374,7 +329,8 @@ class LookupHelper:
def get_flag_for_dxcc(self, dxcc): def get_flag_for_dxcc(self, dxcc):
"""Get an emoji flag for a given DXCC entity ID""" """Get an emoji flag for a given DXCC entity ID"""
return self._dxcc_data[dxcc]["flag"] if dxcc in self._dxcc_data else None dxcc_data = DATA_STORE.dxcc_data[dxcc]
return dxcc_data["flag"] if dxcc_data else None
def infer_name_from_callsign_online_lookup(self, call, credentials=None): def infer_name_from_callsign_online_lookup(self, call, credentials=None):
"""Infer an operator name from a callsign (requires QRZ.com/HamQTH)""" """Infer an operator name from a callsign (requires QRZ.com/HamQTH)"""
@@ -680,61 +636,3 @@ class LookupHelper:
# Singleton object # Singleton object
lookup_helper = LookupHelper() lookup_helper = LookupHelper()
def infer_mode_from_comment(comment):
"""Infer a mode from the comment"""
for mode in ALL_MODES:
if mode in comment.upper():
return mode
for mode in MODE_ALIASES.keys():
if mode in comment.upper():
return MODE_ALIASES[mode]
return None
def infer_mode_type_from_mode(mode):
"""Infer a "mode family" from a mode."""
if mode.upper() in CW_MODES:
return "CW"
elif mode.upper() in PHONE_MODES:
return "PHONE"
elif mode.upper() in DATA_MODES:
return "DATA"
else:
if mode.upper() != "OTHER":
logging.warning("Found an unrecognised mode: " + mode + ". Developer should categorise this.")
return None
def infer_band_from_freq(freq):
"""Infer a band from a frequency in Hz"""
for b in BANDS:
if b.start_freq <= freq <= b.end_freq:
return b
return UNKNOWN_BAND
def infer_mode_from_frequency(freq):
"""Infer a mode from the frequency (in Hz) according to the band plan. Just a guess really."""
try:
khz = freq / 1000.0
mode = freq_to_band(khz)["mode"]
# Some additional common digimode ranges in addition to what the 3rd-party freq_to_band function returns.
# This is mostly here just because freq_to_band is very specific about things like FT8 frequencies, and e.g.
# a spot at 7074.5 kHz will be indicated as LSB, even though it's clearly in the FT8 range. Future updates
# might include other common digimode centres of activity here, but this achieves the main goal of keeping
# large numbers of clearly-FT* spots off the list of people filtering out digimodes.
if (7074 <= khz < 7077) or (10136 <= khz < 10139) or (14074 <= khz < 14077) or (18100 <= khz < 18103) or (
21074 <= khz < 21077) or (24915 <= khz < 24918) or (28074 <= khz < 28077):
mode = "FT8"
if (7047.5 <= khz < 7050.5) or (10140 <= khz < 10143) or (14080 <= khz < 14083) or (
18104 <= khz < 18107) or (21140 <= khz < 21143) or (24919 <= khz < 24922) or (28180 <= khz < 28183):
mode = "FT4"
return mode
except KeyError:
return None
+7 -1
View File
@@ -15,7 +15,7 @@ class StatusReporter:
"""Provides a timed update of the application's status data.""" """Provides a timed update of the application's status data."""
def __init__(self, run_interval, web_server, spot_providers, alert_providers, solar_condition_providers, def __init__(self, run_interval, web_server, spot_providers, alert_providers, solar_condition_providers,
sig_ref_data_providers): static_data_providers, sig_ref_data_providers):
"""Constructor""" """Constructor"""
self._run_interval = run_interval self._run_interval = run_interval
@@ -23,6 +23,7 @@ class StatusReporter:
self._spot_providers = spot_providers self._spot_providers = spot_providers
self._alert_providers = alert_providers self._alert_providers = alert_providers
self._solar_condition_providers = solar_condition_providers self._solar_condition_providers = solar_condition_providers
self._static_data_providers = static_data_providers
self._sig_ref_data_providers = sig_ref_data_providers self._sig_ref_data_providers = sig_ref_data_providers
self._thread = None self._thread = None
self._stop_event = Event() self._stop_event = Event()
@@ -74,6 +75,11 @@ class StatusReporter:
"last_updated": p.last_update_time.replace( "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},
self._solar_condition_providers)) self._solar_condition_providers))
DATA_STORE.status_data["static_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},
self._static_data_providers))
DATA_STORE.status_data["sig_ref_data_providers"] = list( DATA_STORE.status_data["sig_ref_data_providers"] = list(
map(lambda p: {"sig_name": p.sig_name, "enabled": p.enabled, "status": p.status, map(lambda p: {"sig_name": p.sig_name, "enabled": p.enabled, "status": p.status,
"last_updated": p.last_update_time.replace( "last_updated": p.last_update_time.replace(
+64 -1
View File
@@ -1,8 +1,71 @@
import logging
import simplejson import simplejson
from pyhamtools.frequency import freq_to_band
from core.constants import UNKNOWN_BAND, BANDS, CW_MODES, PHONE_MODES, DATA_MODES, MODE_ALIASES, ALL_MODES
def safe_json_dumps(obj): def safe_json_dumps(obj):
"""Safe version of json.dumps that also converts objects to dicts so they can be output, and ignores NaN floats """Safe version of json.dumps that also converts objects to dicts so they can be output, and ignores NaN floats
which are invalid in JSON.""" which are invalid in JSON."""
return simplejson.dumps(obj, ensure_ascii=False, ignore_nan=True, default=lambda o: o.__dict__) return simplejson.dumps(obj, ensure_ascii=False, ignore_nan=True, default=lambda o: o.__dict__)
def infer_mode_from_comment(comment):
"""Infer a mode from the comment"""
for mode in ALL_MODES:
if mode in comment.upper():
return mode
for mode in MODE_ALIASES.keys():
if mode in comment.upper():
return MODE_ALIASES[mode]
return None
def infer_mode_type_from_mode(mode):
"""Infer a "mode family" from a mode."""
if mode.upper() in CW_MODES:
return "CW"
elif mode.upper() in PHONE_MODES:
return "PHONE"
elif mode.upper() in DATA_MODES:
return "DATA"
else:
if mode.upper() != "OTHER":
logging.warning("Found an unrecognised mode: " + mode + ". Developer should categorise this.")
return None
def infer_band_from_freq(freq):
"""Infer a band from a frequency in Hz"""
for b in BANDS:
if b.start_freq <= freq <= b.end_freq:
return b
return UNKNOWN_BAND
def infer_mode_from_frequency(freq):
"""Infer a mode from the frequency (in Hz) according to the band plan. Just a guess really."""
try:
khz = freq / 1000.0
mode = freq_to_band(khz)["mode"]
# Some additional common digimode ranges in addition to what the 3rd-party freq_to_band function returns.
# This is mostly here just because freq_to_band is very specific about things like FT8 frequencies, and e.g.
# a spot at 7074.5 kHz will be indicated as LSB, even though it's clearly in the FT8 range. Future updates
# might include other common digimode centres of activity here, but this achieves the main goal of keeping
# large numbers of clearly-FT* spots off the list of people filtering out digimodes.
if (7074 <= khz < 7077) or (10136 <= khz < 10139) or (14074 <= khz < 14077) or (18100 <= khz < 18103) or (
21074 <= khz < 21077) or (24915 <= khz < 24918) or (28074 <= khz < 28077):
mode = "FT8"
if (7047.5 <= khz < 7050.5) or (10140 <= khz < 10143) or (14080 <= khz < 14083) or (
18104 <= khz < 18107) or (21140 <= khz < 21143) or (24919 <= khz < 24922) or (28180 <= khz < 28183):
mode = "FT4"
return mode
except KeyError:
return None
+3 -2
View File
@@ -12,9 +12,10 @@ from pyhamtools.locator import locator_to_latlong, latlong_to_locator
from core.config import MAX_SPOT_AGE from core.config import MAX_SPOT_AGE
from core.constants import MODE_ALIASES, PROPAGATION_MODES from core.constants import MODE_ALIASES, PROPAGATION_MODES
from core.geo_utils import lat_lon_to_cq_zone, lat_lon_to_itu_zone from core.geo_utils import lat_lon_to_cq_zone, lat_lon_to_itu_zone
from core.lookup_helper import lookup_helper, infer_band_from_freq, infer_mode_from_comment, \ from core.lookup_helper import lookup_helper
infer_mode_from_frequency, infer_mode_type_from_mode
from core.sig_utils import populate_sig_ref_info, ANY_SIG_REGEX, get_ref_regex_for_sig, get_sig_name_from_comment_name from core.sig_utils import populate_sig_ref_info, ANY_SIG_REGEX, get_ref_regex_for_sig, get_sig_name_from_comment_name
from core.utils import infer_band_from_freq, infer_mode_from_comment, \
infer_mode_from_frequency, infer_mode_type_from_mode
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
+1 -1
View File
@@ -10,7 +10,7 @@ from tornado.web import Application
from core.config import ALLOW_SPOTTING from core.config import ALLOW_SPOTTING
from core.constants import UNKNOWN_BAND from core.constants import UNKNOWN_BAND
from core.lookup_helper import infer_band_from_freq from core.utils import infer_band_from_freq
from core.prometheus_metrics_handler import api_requests_counter from core.prometheus_metrics_handler import api_requests_counter
from core.sig_utils import get_ref_regex_for_sig from core.sig_utils import get_ref_regex_for_sig
from core.utils import safe_json_dumps from core.utils import safe_json_dumps
@@ -21,7 +21,7 @@ class FileDownloadSIGRefDataProvider(SIGRefDataProvider):
self._poll_interval = poll_interval self._poll_interval = poll_interval
self._thread = None self._thread = None
self._stop_event = Event() self._stop_event = Event()
self._url_data_cache = URLDataCache("sigrefdata-" + sig_name) self._url_data_cache = URLDataCache("sigrefdata_" + sig_name)
def start(self): def start(self):
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between # Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
@@ -50,7 +50,7 @@ class FileDownloadSIGRefDataProvider(SIGRefDataProvider):
if http_response.ok: if http_response.ok:
# Pass off to the subclass for processing # Pass off to the subclass for processing
new_data = self._http_response_to_data(http_response) new_data = self._http_response_to_data(http_response)
# Submit the new spots for processing. There might not be any spots for the less popular programs. # Add the new data to the SIG Ref data store
if new_data: if new_data:
self._add_data(new_data) self._add_data(new_data)
@@ -62,8 +62,10 @@ class FileDownloadSIGRefDataProvider(SIGRefDataProvider):
logging.warning(f"HTTP {http_response.status_code} when downloading SIG ref data for {self.sig_name}.") logging.warning(f"HTTP {http_response.status_code} when downloading SIG ref data for {self.sig_name}.")
except ConnectionError: except ConnectionError:
self.status = "Error"
logging.warning(f"Connection error when downloading SIG ref data for {self.sig_name}.") logging.warning(f"Connection error when downloading SIG ref data for {self.sig_name}.")
except (ConnectTimeout, ReadTimeout): except (ConnectTimeout, ReadTimeout):
self.status = "Error"
logging.warning(f"Timeout when downloading SIG ref data for {self.sig_name}.") logging.warning(f"Timeout when downloading SIG ref data for {self.sig_name}.")
except Exception: except Exception:
self.status = "Error" self.status = "Error"
@@ -22,10 +22,11 @@ class LocalFileSIGRefDataProvider(SIGRefDataProvider):
self.status = "OK" self.status = "OK"
self.last_update_time = datetime.now(pytz.UTC) self.last_update_time = datetime.now(pytz.UTC)
else: else:
logging.info("No new SIG ref data found for " + self.sig_name) self.status = "Error"
logging.info("Failed to load SIG ref data for " + self.sig_name)
except Exception as e: except Exception as e:
self.status = "Error" self.status = "Error"
logging.exception("Exception in local file SIG Ref Data Provider (" + self.sig_name + ")") logging.error("Exception in local file SIG Ref Data Provider (" + self.sig_name + ")", e, exc_info=True)
def stop(self): def stop(self):
pass pass
@@ -15,7 +15,6 @@ class SIGRefDataProvider:
self.sig_name = sig_name self.sig_name = sig_name
self.enabled = provider_config["enabled"] self.enabled = provider_config["enabled"]
self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC) self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC)
self.last_spot_time = datetime.min.replace(tzinfo=pytz.UTC)
self.status = "Not Started" if self.enabled else "Disabled" self.status = "Not Started" if self.enabled else "Disabled"
self.reference_count = 0 self.reference_count = 0
+14 -2
View File
@@ -5,7 +5,8 @@ import signal
import sys import sys
from core.config import config, SERVER_OWNER_CALLSIGN, LOG_LEVEL, get_sig_ref_data_provider_from_config, \ from core.config import config, SERVER_OWNER_CALLSIGN, LOG_LEVEL, get_sig_ref_data_provider_from_config, \
get_spot_provider_from_config, get_alert_provider_from_config, get_solar_conditions_provider_from_config get_spot_provider_from_config, get_alert_provider_from_config, get_solar_conditions_provider_from_config, \
get_static_data_provider_from_config
from core.constants import SOFTWARE_VERSION from core.constants import SOFTWARE_VERSION
from core.data_store import DATA_STORE from core.data_store import DATA_STORE
from core.lookup_helper import lookup_helper from core.lookup_helper import lookup_helper
@@ -17,6 +18,7 @@ web_server = None
spot_providers = [] spot_providers = []
alert_providers = [] alert_providers = []
solar_condition_providers = [] solar_condition_providers = []
static_data_providers = []
sig_ref_data_providers = [] sig_ref_data_providers = []
cleanup_timer = None cleanup_timer = None
run = True run = True
@@ -42,6 +44,9 @@ def shutdown(_signum=None, _frame=None):
for srdp in sig_ref_data_providers: for srdp in sig_ref_data_providers:
if srdp.enabled: if srdp.enabled:
srdp.stop() srdp.stop()
for srdp in static_data_providers:
if srdp.enabled:
srdp.stop()
DATA_STORE.close() DATA_STORE.close()
os._exit(0) os._exit(0)
@@ -95,6 +100,13 @@ if __name__ == '__main__':
if p.enabled: if p.enabled:
p.start() p.start()
# Fetch, set up and start static reference data providers
for entry in config.get("static-data-providers", []):
static_data_providers.append(get_static_data_provider_from_config(entry))
for p in static_data_providers:
if p.enabled:
p.start()
# Fetch, set up and start SIG reference data providers # Fetch, set up and start SIG reference data providers
for entry in config.get("sig-ref-data-providers", []): for entry in config.get("sig-ref-data-providers", []):
sig_ref_data_providers.append(get_sig_ref_data_provider_from_config(entry)) sig_ref_data_providers.append(get_sig_ref_data_provider_from_config(entry))
@@ -104,7 +116,7 @@ if __name__ == '__main__':
# Set up status reporter # Set up status reporter
status_reporter = StatusReporter(web_server=web_server, spot_providers=spot_providers, status_reporter = StatusReporter(web_server=web_server, spot_providers=spot_providers,
alert_providers=alert_providers, alert_providers=alert_providers, static_data_providers=static_data_providers,
sig_ref_data_providers=sig_ref_data_providers, sig_ref_data_providers=sig_ref_data_providers,
solar_condition_providers=solar_condition_providers, run_interval=5) solar_condition_providers=solar_condition_providers, run_interval=5)
status_reporter.start() status_reporter.start()
+28 -1
View File
@@ -23,7 +23,7 @@ info:
* Added `comment_names` to SIGs in the `/options`, to reflect how they might be referred to in spot comments where * 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`. it differs from their `name`.
* Added `propagation_mode` field to spots * Added `propagation_mode` field to spots
* Added `sig_ref_data_providers` to status and removed `cleanup` * Added `sig_ref_data_providers` and `static_data_providers` to status and removed `cleanup`
### 1.3 ### 1.3
@@ -1721,6 +1721,28 @@ components:
is zero, the provider has never updated. is zero, the provider has never updated.
example: 1759579508 example: 1759579508
StaticDataProviderStatus:
type: object
properties:
sig_name:
type: string
description: The name of the provider.
example: K0SWE
enabled:
type: boolean
description: Whether the provider is enabled or not.
example: true
status:
type: string
description: The status of the provider.
example: OK
last_updated:
type: number
description: >
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
SIGRefDataProviderStatus: SIGRefDataProviderStatus:
type: object type: object
properties: properties:
@@ -1844,6 +1866,11 @@ components:
description: An array of all the solar conditions providers. description: An array of all the solar conditions providers.
items: items:
$ref: '#/components/schemas/SolarConditionsProviderStatus' $ref: '#/components/schemas/SolarConditionsProviderStatus'
static_data_providers:
type: array
description: An array of all the static reference data providers.
items:
$ref: '#/components/schemas/StaticDataProviderStatus'
sig_ref_data_providers: sig_ref_data_providers:
type: array type: array
description: An array of all the SIG reference data providers. description: An array of all the SIG reference data providers.
+9
View File
@@ -40,6 +40,15 @@ function loadStatus() {
</div>`); </div>`);
}); });
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="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>
</div>`);
});
jsonData["sig_ref_data_providers"].forEach(p => { jsonData["sig_ref_data_providers"].forEach(p => {
$("#sig-ref-data-providers-status-container").append(` $("#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-2">
@@ -0,0 +1,77 @@
import logging
from datetime import datetime
from threading import Thread, Event
import pytz
from requests import ReadTimeout
from requests.exceptions import ConnectionError, ConnectTimeout
from core.constants import HTTP_HEADERS
from core.url_data_cache import URLDataCache
from staticdataproviders.static_data_provider import StaticDataProvider
class FileDownloadStaticDataProvider(StaticDataProvider):
"""Generic static reference data provider class for providers that fetch their data from the web by downloading a
file."""
def __init__(self, name, provider_config, url, poll_interval):
""" Set up the provider, note poll_interval is in *days*."""
super().__init__(name, provider_config)
self._url = url
self._poll_interval = poll_interval
self._thread = None
self._stop_event = Event()
self._url_data_cache = URLDataCache("staticdata_" + name)
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 + " static reference data every " + str(self._poll_interval) + " days.")
self._thread = Thread(target=self._run, daemon=True)
self._thread.start()
def stop(self):
self._stop_event.set()
def _run(self):
while True:
self._poll()
if self._stop_event.wait(timeout=self._poll_interval * 60 * 60 * 24):
break
def _poll(self):
try:
# Request data from API. Use the data cache (with a TTL of 1 day) here, not as the main mechanism for
# caching, but just so continual restarts of the software during testing don't hammer the servers.
logging.debug("Downloading " + self.name + " static reference data...")
http_response = self._url_data_cache.get(self._url, headers=HTTP_HEADERS)
# Check response code was good
if http_response.ok:
# Pass off to the subclass for processing
ok = self._handle_http_response(http_response)
if ok:
self.status = "OK"
self.last_update_time = datetime.now(pytz.UTC)
logging.info("Updated static reference data for " + self.name)
else:
self.status = "Error"
logging.warning(f"HTTP {http_response.status_code} when downloading static reference data for {self.name}.")
except ConnectionError:
self.status = "Error"
logging.warning(f"Connection error when downloading static reference data for {self.name}.")
except (ConnectTimeout, ReadTimeout):
self.status = "Error"
logging.warning(f"Timeout when downloading static reference data for {self.name}.")
except Exception:
self.status = "Error"
logging.exception("Exception in HTTP static reference data provider (" + self.name + ")")
self._stop_event.wait(timeout=1)
def _handle_http_response(self, http_response):
"""Handle an HTTP response returned by the server and load the data from it. Return true if successful,
false otherwise."""
raise NotImplementedError("Subclasses must implement this method")
+40
View File
@@ -0,0 +1,40 @@
import logging
import re
from core.data_store import DATA_STORE
from staticdataproviders.file_download_static_data_provider import FileDownloadStaticDataProvider
class K0SWE(FileDownloadStaticDataProvider):
"""Static data provider for K0SWE's dxcc.json, which provides callsign regex to DXCC entity mapping, plus DXCC to
continent, flag emoji etc."""
POLL_INTERVAL_DAYS = 7
DATA_URL = "https://raw.githubusercontent.com/k0swe/dxcc-json/refs/heads/main/dxcc.json"
def __init__(self, provider_config):
super().__init__("K0SWE DXCC JSON", provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _handle_http_response(self, http_response):
try:
dxcc_list = http_response.json()["dxcc"]
# Reformat as a map for to place in the data store
dxcc_map = {}
for dxcc in dxcc_list:
dxcc_map[dxcc["entityCode"]] = dxcc
# Precompile regex matches for DXCCs to improve efficiency when iterating through them
for dxcc in dxcc_map.values():
dxcc["_prefixRegexCompiled"] = re.compile(dxcc["prefixRegex"])
# Add to data store
for k, v in dxcc_map.items():
DATA_STORE.dxcc_data[k] = v
return True
except Exception as e:
logging.error("Exception when loading K0SWE dxcc.json.", e, exc_info=True)
return False
@@ -0,0 +1,36 @@
import logging
from datetime import datetime
import pytz
from staticdataproviders.static_data_provider import StaticDataProvider
class LocalFileStaticDataProvider(StaticDataProvider):
"""Generic static reference data provider class for providers that fetch their data from a local file on startup."""
def __init__(self, name, provider_config, path):
super().__init__(name, provider_config)
self._path = path
def start(self):
logging.debug("Loading " + self.name + " static reference data from file.")
try:
ok = self._load_data(self._path)
if ok:
self.status = "OK"
self.last_update_time = datetime.now(pytz.UTC)
else:
self.status = "Error"
logging.info("Failed to load data for " + self.name)
except Exception as e:
self.status = "Error"
logging.error("Exception in local file Static Data Provider (" + self.name + ")", e, exc_info=True)
def stop(self):
pass
def _load_data(self, path):
"""Load data from the given file path. Return true if successful, false otherwise."""
raise NotImplementedError("Subclasses must implement this method")
@@ -0,0 +1,31 @@
import logging
from datetime import datetime
import pytz
from core.data_store import DATA_STORE
class StaticDataProvider:
"""Generic static reference data provider class. Subclasses of this query the individual URLs or files for data."""
def __init__(self, name, provider_config):
"""Constructor"""
self.name = name
self.enabled = provider_config["enabled"]
self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC)
self.status = "Not Started" if self.enabled else "Disabled"
self.reference_count = 0
def start(self):
"""Start the provider. This should return immediately after spawning threads to access the remote resources"""
raise NotImplementedError("Subclasses must implement this method")
def stop(self):
"""Stop any threads and prepare for application shutdown"""
raise NotImplementedError("Subclasses must implement this method")
+9
View File
@@ -54,6 +54,15 @@
</div> </div>
</div> </div>
<div class="card mt-3">
<div class="card-header">
Static Reference Data Providers
</div>
<div class="card-body" id="static-data-providers-status-container">
</div>
</div>
<div class="card mt-3"> <div class="card mt-3">
<div class="card-header"> <div class="card-header">
SIG Reference Data Providers SIG Reference Data Providers