Refactor of caching & data storage part 4 #118

This commit is contained in:
Ian Renton
2026-07-31 21:02:37 +01:00
parent 0f59af6f9e
commit 23edd4e02e
37 changed files with 822 additions and 528 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ class BOTA(HTTPAlertProvider):
def _http_response_to_alerts(self, http_response): def _http_response_to_alerts(self, http_response):
new_alerts = [] new_alerts = []
# Find the table of upcoming alerts # Find the table of upcoming alerts
bs = BeautifulSoup(http_response.content.decode(), features="lxml") bs = BeautifulSoup(http_response.content.decode("utf-8-sig"), features="lxml")
if not bs.body: if not bs.body:
return new_alerts return new_alerts
div = bs.body.find('div', attrs={'class': 'view-activations-public'}) div = bs.body.find('div', attrs={'class': 'view-activations-public'})
+1 -1
View File
@@ -22,7 +22,7 @@ class NG3K(HTTPAlertProvider):
def _http_response_to_alerts(self, http_response): def _http_response_to_alerts(self, http_response):
new_alerts = [] new_alerts = []
rss = cast(RSS, Parser.parse(http_response.content.decode())) rss = cast(RSS, Parser.parse(http_response.content.decode("utf-8-sig")))
# Iterate through source data # Iterate through source data
for source_alert in rss.channel.items: for source_alert in rss.channel.items:
# Deal with "the format"... # Deal with "the format"...
+2 -2
View File
@@ -22,7 +22,7 @@ class ParksNPeaks(HTTPAlertProvider):
# Iterate through source data # Iterate through source data
for source_alert in http_response.json(): for source_alert in http_response.json():
# Calculate some things # Calculate some things
sig = source_alert["Class"] sig = source_alert["Class"].upper()
if " - " in source_alert["Location"]: if " - " in source_alert["Location"]:
split = source_alert["Location"].split(" - ") split = source_alert["Location"].split(" - ")
sig_ref = split[0] sig_ref = split[0]
@@ -50,7 +50,7 @@ class ParksNPeaks(HTTPAlertProvider):
is_dxpedition=False) is_dxpedition=False)
# Log a warning for the developer if PnP gives us an unknown programme we've never seen before # Log a warning for the developer if PnP gives us an unknown programme we've never seen before
if sig and sig not in ["POTA", "SOTA", "WWFF", "SiOTA", "ZLOTA", "KRMNPA", "LLOTA", "QRP"]: if sig and sig not in ["POTA", "SOTA", "WWFF", "SIOTA", "ZLOTA", "KRMNPA", "LLOTA", "QRP"]:
logging.warning("PNP alert found with sig " + sig + ", developer needs to add support for this!") logging.warning("PNP alert found with sig " + sig + ", developer needs to add support for this!")
# If this is POTA, SOTA or WWFF data we already have it through other means, so ignore. Otherwise, add to # If this is POTA, SOTA or WWFF data we already have it through other means, so ignore. Otherwise, add to
+1 -1
View File
@@ -22,7 +22,7 @@ class WOTA(HTTPAlertProvider):
def _http_response_to_alerts(self, http_response): def _http_response_to_alerts(self, http_response):
new_alerts = [] new_alerts = []
rss = cast(RSS, RSSParser.parse(http_response.content.decode())) rss = cast(RSS, RSSParser.parse(http_response.content.decode("utf-8-sig")))
# Iterate through source data # Iterate through source data
for source_alert in rss.channel.items: for source_alert in rss.channel.items:
+34 -4
View File
@@ -121,7 +121,7 @@ spot-providers:
url: "wss://39c3.totawatch.de/api/spot/live" url: "wss://39c3.totawatch.de/api/spot/live"
# For the "XOTA" provider, a SIG must be set menually here because xOTA is a generic backend for xOTA # For the "XOTA" provider, a SIG must be set menually here because xOTA is a generic backend for xOTA
# programmes and so different URLs potentially provide different programmes. # programmes and so different URLs potentially provide different programmes.
sig: "TOTA" sig: "Toilets"
# For Toilets on the Air, we prefix the SIG references (T-01 etc) with some characters that define the conference: # For Toilets on the Air, we prefix the SIG references (T-01 etc) with some characters that define the conference:
# C3, EH or HOPE - so we can look up the correct locations in our database, because each conference starts from T-01 # C3, EH or HOPE - so we can look up the correct locations in our database, because each conference starts from T-01
# but refers to a toilet in a different building (or continent!) # but refers to a toilet in a different building (or continent!)
@@ -131,14 +131,14 @@ spot-providers:
name: "EH23 TOTA" name: "EH23 TOTA"
enabled: false enabled: false
url: "wss://eh23.totawatch.de/api/spot/live" url: "wss://eh23.totawatch.de/api/spot/live"
sig: "TOTA" sig: "Toilets"
sig-ref-prefix: "EH" sig-ref-prefix: "EH"
- class: "XOTA" - class: "XOTA"
name: "HOPE26 TOTA" name: "HOPE26 TOTA"
enabled: false enabled: false
url: "wss://hope-26.totawatch.de/api/spot/live" url: "wss://hope-26.totawatch.de/api/spot/live"
sig: "TOTA" sig: "Toilets"
sig-ref-prefix: "HOPE" sig-ref-prefix: "HOPE"
@@ -169,9 +169,36 @@ alert-providers:
# 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:
- class: "POTA"
enabled: true
- class: "SOTA"
enabled: true
- class: "WWFF" - class: "WWFF"
enabled: true enabled: true
- class: "WWBOTA"
enabled: true
- class: "GMA"
enabled: true
- class: "MOTA"
enabled: true
- class: "ILLW"
enabled: true
- class: "ARLHS"
enabled: true
- class: "WCA"
enabled: true
- class: "IOTA"
enabled: true
- class: "WOTA" - class: "WOTA"
enabled: true enabled: true
@@ -184,10 +211,13 @@ sig-ref-data-providers:
- class: "LLOTA" - class: "LLOTA"
enabled: true enabled: true
- class: "Towers"
enabled: true
- class: "DME" - class: "DME"
enabled: true enabled: true
- class: "TOTA" - class: "Toilets"
enabled: true enabled: true
# Solar condition providers to use. These poll external APIs for solar propagation data (SFI, A/K indices, band # Solar condition providers to use. These poll external APIs for solar propagation data (SFI, A/K indices, band
+2 -2
View File
@@ -19,9 +19,9 @@ SIGS = [
SIG(name="HEMA", comment_names=["HEMA"], description="HuMPs Excluding Marilyns Award", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{3}\-\d{3}"), SIG(name="HEMA", comment_names=["HEMA"], description="HuMPs Excluding Marilyns Award", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{3}\-\d{3}"),
SIG(name="IOTA", comment_names=["IOTA"], description="Islands on the Air", ref_regex=r"[A-Z]{2}\-\d{3}"), SIG(name="IOTA", comment_names=["IOTA"], description="Islands on the Air", ref_regex=r"[A-Z]{2}\-\d{3}"),
SIG(name="MOTA", comment_names=["MOTA"], description="Mills on the Air", ref_regex=r"X\d{4,6}"), SIG(name="MOTA", comment_names=["MOTA"], description="Mills on the Air", ref_regex=r"X\d{4,6}"),
SIG(name="ARLHS", comment_names=["ARLHS"], description="Amateur Radio Lighthouse Society", ref_regex=r"[A-Z]{3}\-\d{3,4}"), SIG(name="ARLHS", comment_names=["ARLHS"], description="Amateur Radio Lighthouse Society", ref_regex=r"[A-Z]{3}[\- ]\d{3,4}"),
SIG(name="ILLW", comment_names=["ILLW"], description="International Lighthouse & Lightship Weekend", ref_regex=r"[A-Z]{2}\d{4}"), SIG(name="ILLW", comment_names=["ILLW"], description="International Lighthouse & Lightship Weekend", ref_regex=r"[A-Z]{2}\d{4}"),
SIG(name="SiOTA", comment_names=["SIOTA"], description="Silos on the Air", ref_regex=r"[A-Z]{2}\-[A-Z]{3}\d"), SIG(name="SIOTA", comment_names=["SIOTA"], description="Silos on the Air", ref_regex=r"[A-Z]{2}\-[A-Z]{3}\d"),
SIG(name="WCA", comment_names=["WCA"], description="World Castles Award", ref_regex=r"[A-Z0-9]{1,3}\-\d{5}"), SIG(name="WCA", comment_names=["WCA"], description="World Castles Award", ref_regex=r"[A-Z0-9]{1,3}\-\d{5}"),
SIG(name="ZLOTA", comment_names=["ZLOTA"], description="New Zealand on the Air", ref_regex=r"ZL[A-Z]/[A-Z]{2}\-\d{3,4}"), SIG(name="ZLOTA", comment_names=["ZLOTA"], description="New Zealand on the Air", ref_regex=r"ZL[A-Z]/[A-Z]{2}\-\d{3,4}"),
SIG(name="WOTA", comment_names=["WOTA"], description="Wainwrights on the Air", ref_regex=r"[A-Z]{3}-[0-9]{2}"), SIG(name="WOTA", comment_names=["WOTA"], description="Wainwrights on the Air", ref_regex=r"[A-Z]{3}-[0-9]{2}"),
+40 -24
View File
@@ -1,8 +1,10 @@
import logging
from pathlib import Path from pathlib import Path
import diskcache import diskcache
from core.config import MAX_SPOT_AGE, MAX_ALERT_AGE from core.config import MAX_SPOT_AGE, MAX_ALERT_AGE
from core.constants import SIGS
from core.live_data_cache import LiveDataCache from core.live_data_cache import LiveDataCache
from data.solar_conditions import SolarConditions from data.solar_conditions import SolarConditions
@@ -12,51 +14,65 @@ class DataStore:
lookup data using different caching strategies for each.""" lookup data using different caching strategies for each."""
def __init__(self): def __init__(self):
self.CACHE_DIR = "./cache" self._CACHE_DIR = "./cache"
self.MAX_SPOT_COUNT = 10000 self._MAX_SPOT_COUNT = 100000
self.MAX_ALERT_COUNT = 10000 self._MAX_ALERT_COUNT = 100000
self.SPOT_ALERT_SNAPSHOT_INTERVAL_SEC = 300 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
self.alerts = None
self.spots = None
self.callsigns = None
self.sigrefs = None
self.status_data = None
self._status = None
self.solar_conditions = None
self._solar = None
def setup(self): def setup(self):
Path(self.CACHE_DIR).mkdir(parents=True, exist_ok=True) Path(self._CACHE_DIR).mkdir(parents=True, exist_ok=True)
# Standard disk cache for solar data and status data, but each cache contains only a single object which we # Standard disk cache for solar data and status data, but each cache contains only a single object which we
# expose to the wider application # expose to the wider application
self.solar = diskcache.Cache(self.CACHE_DIR + "/solar") self._solar = diskcache.Cache(self._CACHE_DIR + "/solar")
if "solar_conditions" not in self.solar: if "solar_conditions" not in self._solar:
self.solar.add("solar_conditions", SolarConditions()) self._solar.add("solar_conditions", SolarConditions())
self.solar_conditions = self.solar.get("solar_conditions") self.solar_conditions = self._solar.get("solar_conditions")
self.status = diskcache.Cache(self.CACHE_DIR + "/status") self._status = diskcache.Cache(self._CACHE_DIR + "/status")
if "status_data" not in self.status: if "status_data" not in self._status:
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 SIG ref data. Separate provider threads will repopulate theis on a regular basis
# but there's no need for a TTL since old data is better than no data. This is a two-layer dict, keys are SIG # but there's no need for a TTL since old data is better than no data. This is a two-layer dict, keys are SIG
# name and then reference ID, with the final value being a SIGRef object. # name and then reference ID, with the final value being a SIGRef object.
self.sigrefs = diskcache.Cache(self.CACHE_DIR + "/sigrefs") self.sigrefs = diskcache.Cache(self._CACHE_DIR + "/sigrefs")
for k in list(self.sigrefs.iterkeys()):
logging.info(f"Loaded data for %d references in %s SIG.", len(self.sigrefs[k]), k)
# Standard disk cache for callsign data. This data does have a TTL to trigger an occasional re-lookup. # 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 # 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. # we've seen, so we rely on them timing out and this triggering another lookup.
self.callsigns = diskcache.Cache(self.CACHE_DIR + "/callsigns") self.callsigns = diskcache.Cache(self._CACHE_DIR + "/callsigns")
logging.info(f"Loaded data for %d callsigns.", len(self.callsigns))
# Special caches for spots and alerts, which have TTL and write snapshots to disk at an interval. We # 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 # specifically load these caches *last* so that any sigref and callsign data is already loaded from disk cache
# before the spots and alerts are live in the system. # before the spots and alerts are live in the system.
self.spots = LiveDataCache(maxsize=self.MAX_SPOT_COUNT, ttl=MAX_SPOT_AGE, self.spots = LiveDataCache(maxsize=self._MAX_SPOT_COUNT, ttl=MAX_SPOT_AGE,
snapshot_dir=self.CACHE_DIR + "/spots", snapshot_dir=self._CACHE_DIR + "/spots",
snapshot_interval_sec=self.SPOT_ALERT_SNAPSHOT_INTERVAL_SEC) snapshot_interval_sec=self._SPOT_ALERT_SNAPSHOT_INTERVAL_SEC)
self.alerts = LiveDataCache(maxsize=self.MAX_ALERT_COUNT, ttl=MAX_ALERT_AGE, logging.info(f"Loaded %d spots from a previous run.", len(self.spots.keys()))
snapshot_dir=self.CACHE_DIR + "/alerts",
snapshot_interval_sec=self.SPOT_ALERT_SNAPSHOT_INTERVAL_SEC) self.alerts = LiveDataCache(maxsize=self._MAX_ALERT_COUNT, ttl=MAX_ALERT_AGE,
snapshot_dir=self._CACHE_DIR + "/alerts",
snapshot_interval_sec=self._SPOT_ALERT_SNAPSHOT_INTERVAL_SEC)
logging.info(f"Loaded %d alerts from a previous run.", len(self.alerts.keys()))
def close(self): def close(self):
self.spots.close() self.spots.close()
self.alerts.close() self.alerts.close()
self.solar.close() self._solar.close()
self.status.close() self._status.close()
self.sigrefs.close() self.sigrefs.close()
self.callsigns.close() self.callsigns.close()
+54 -142
View File
@@ -35,65 +35,67 @@ def populate_sig_ref_info(sig_ref):
SIG we are getting data for. SIG we are getting data for.
Note there is currently no support for KRMNPA location lookup, see issue #61.""" Note there is currently no support for KRMNPA location lookup, see issue #61."""
if sig_ref.sig is None or sig_ref.id is None or sig_ref.id == "": 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.") logging.debug("Failed to look up sig_ref info, sig or id were not set.")
return sig_ref return sig_ref
sig = sig_ref.sig or "" sig = sig_ref.sig
ref_id = sig_ref.id ref_id = sig_ref.id
# DME fudge. Our database has leading zeros padding to 5 digits which is the expected format, but not all activators
# add leading zeros.
if sig.upper() == "DME":
ref_id = ref_id.zfill(5)
try: try:
if sig.upper() == "POTA": # If the SIG is HEMA or KRMNPA, we have no current lookup for this so just skip it.
response = URL_DATA_CACHE.get("https://api.pota.app/park/" + ref_id, headers=HTTP_HEADERS) if sig.upper() == "HEMA" or sig.upper() == "KRMNPA":
if response.ok: return sig_ref
data = response.json()
if data:
fullname = str(data["name"]) if "name" in data else None
if fullname and "parktypeDesc" in data and data["parktypeDesc"] != "":
fullname = fullname + " " + data["parktypeDesc"]
sig_ref.name = fullname
sig_ref.url = "https://pota.app/#/park/" + ref_id
sig_ref.grid = data["grid6"] if "grid6" in data else None
sig_ref.latitude = data["latitude"] if "latitude" in data else None
sig_ref.longitude = data["longitude"] if "longitude" in data else None
elif not response.from_cache:
logging.warning("Malformed response looking up %s ref %s", sig, ref_id)
elif not response.from_cache:
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
elif sig.upper() == "SOTA": # If the SIG is Tiles, WAB, WAI or BOTA (Beaches), we don't have anything to look up from the data store, we can
response = URL_DATA_CACHE.get("https://api-db2.sota.org.uk/api/summits/" + ref_id, # calculate all the information we are going to get directly. So handle those cases first
headers=HTTP_HEADERS) elif sig.upper() == "TILES":
if response.ok: # Tiles on the Air just uses Maidenhead 6-digit squares, so ID, Name and Grid are all the same
data = response.json() if not sig_ref.name:
if data: sig_ref.name = sig_ref.id
sig_ref.name = data["name"] if "name" in data else None if not sig_ref.grid:
sig_ref.url = "https://www.sotadata.org.uk/en/summit/" + ref_id sig_ref.grid = sig_ref.id
sig_ref.grid = data["locator"] if "locator" in data else None if sig_ref.grid and not sig_ref.latitude:
sig_ref.latitude = data["latitude"] if "latitude" in data else None ll = locator_to_latlong(str(sig_ref.grid))
sig_ref.longitude = data["longitude"] if "longitude" in data else None sig_ref.latitude = ll[0]
sig_ref.activation_score = data["points"] if "points" in data else None sig_ref.longitude = ll[1]
elif not response.from_cache:
logging.warning("Malformed response looking up %s ref %s", sig, ref_id)
elif not response.from_cache:
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
elif sig.upper() == "WWBOTA": elif sig.upper() == "WAB" or sig.upper() == "WAI":
response = URL_DATA_CACHE.get("https://api.wwbota.org/bunkers/" + ref_id, ll = wab_wai_square_to_lat_lon(ref_id)
headers=HTTP_HEADERS) if ll:
if response.ok: sig_ref.name = ref_id
data = response.json() try:
if data: sig_ref.grid = latlong_to_locator(ll[0], ll[1], 6)
sig_ref.name = data["name"] if "name" in data else None sig_ref.latitude = ll[0]
sig_ref.url = "https://bunkerwiki.org/?s=" + ref_id if ref_id.startswith("B/G") else None sig_ref.longitude = ll[1]
sig_ref.grid = data["locator"] if "locator" in data else None except:
sig_ref.latitude = data["lat"] if "lat" in data else None logging.warning("Invalid lat/lon received for WAB/WAI reference")
sig_ref.longitude = data["long"] if "long" in data else None
elif not response.from_cache:
logging.warning("Malformed response looking up %s ref %s", sig, ref_id)
elif not response.from_cache:
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
elif sig.upper() == "GMA" or sig.upper() == "ARLHS" or sig.upper() == "ILLW" or sig.upper() == "WCA" or sig.upper() == "MOTA" or sig.upper() == "IOTA": elif sig.upper() == "BOTA":
# For BOTA all we can ever generate is the URL, there is no data file or lookup for lat/longs
if not sig_ref.name:
sig_ref.name = sig_ref.id
sig_ref.url = "https://www.beachesontheair.com/beaches/" + sig_ref.name.lower().replace(" ", "-")
# OK, this is something we have to look up. Now check to see if our data store contains SIG ref information for
# this SIG. If so, check for the reference data and use that.
elif sig in DATA_STORE.sigrefs:
lookup_data = DATA_STORE.sigrefs[sig][ref_id] if ref_id in DATA_STORE.sigrefs[sig] else None
if lookup_data:
# Copy new sig ref data into existing object
sig_ref.__dict__.update(lookup_data.__dict__)
else:
logging.warning("%s database did not contain data for ref %s", sig, ref_id)
elif False:
# TODO remove
# OK, this is not a SIG we have stored data for. Maybe it's of a type we can query information for live.
if sig.upper() == "IOTA":
response = URL_DATA_CACHE.get("https://www.cqgma.org/api/ref/?" + ref_id, response = URL_DATA_CACHE.get("https://www.cqgma.org/api/ref/?" + ref_id,
headers=HTTP_HEADERS) headers=HTTP_HEADERS)
if response.ok: if response.ok:
@@ -119,99 +121,9 @@ def populate_sig_ref_info(sig_ref):
elif not response.from_cache: elif not response.from_cache:
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id) logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
elif sig.upper() == "WWFF":
lookup_data = DATA_STORE.sigrefs["WWFF"][ref_id] if ref_id in DATA_STORE.sigrefs["WWFF"] else None
if lookup_data:
# Copy new sig ref data into existing object
sig_ref.__dict__.update(lookup_data.__dict__)
else: else:
logging.warning("WWFF database did not contain data for ref %s", ref_id) logging.warning(f"Tried to look up a SIG called %s but Spothole does not know what that is.", sig)
elif sig.upper() == "SIOTA":
lookup_data = DATA_STORE.sigrefs["SIOTA"][ref_id] if ref_id in DATA_STORE.sigrefs["SIOTA"] else None
if lookup_data:
# Copy new sig ref data into existing object
sig_ref.__dict__.update(lookup_data.__dict__)
else:
logging.warning("SIOTA database did not contain data for ref %s", ref_id)
elif sig.upper() == "WOTA":
lookup_data = DATA_STORE.sigrefs["WOTA"][ref_id] if ref_id in DATA_STORE.sigrefs["WOTA"] else None
if lookup_data:
# Copy new sig ref data into existing object
sig_ref.__dict__.update(lookup_data.__dict__)
else:
logging.warning("WOTA database did not contain data for ref %s", ref_id)
elif sig.upper() == "ZLOTA":
lookup_data = DATA_STORE.sigrefs["ZLOTA"][ref_id] if ref_id in DATA_STORE.sigrefs["ZLOTA"] else None
if lookup_data:
# Copy new sig ref data into existing object
sig_ref.__dict__.update(lookup_data.__dict__)
else:
logging.warning("ZLOTA database did not contain data for ref %s", ref_id)
elif sig.upper() == "BOTA":
if not sig_ref.name:
sig_ref.name = sig_ref.id
sig_ref.url = "https://www.beachesontheair.com/beaches/" + sig_ref.name.lower().replace(" ", "-")
elif sig.upper() == "LLOTA":
lookup_data = DATA_STORE.sigrefs["LLOTA"][ref_id] if ref_id in DATA_STORE.sigrefs["LLOTA"] else None
if lookup_data:
# Copy new sig ref data into existing object
sig_ref.__dict__.update(lookup_data.__dict__)
else:
logging.warning("LLOTA database did not contain data for ref %s", ref_id)
elif sig.upper() == "DME":
# Zero-pad to 5 digits to match our source data
lookup_data = DATA_STORE.sigrefs["DME"][ref_id.zfill(5)] if ref_id.zfill(5) in DATA_STORE.sigrefs["DME"] else None
if lookup_data:
# Copy new sig ref data into existing object
sig_ref.__dict__.update(lookup_data.__dict__)
else:
logging.warning("DME database did not contain data for ref %s", ref_id)
elif sig.upper() == "TOTA":
lookup_data = DATA_STORE.sigrefs["TOTA"][ref_id] if ref_id in DATA_STORE.sigrefs["TOTA"] else None
if lookup_data:
# Copy new sig ref data into existing object
sig_ref.__dict__.update(lookup_data.__dict__)
else:
logging.warning("TOTA database did not contain data for ref %s", ref_id)
elif sig.upper() == "WWTOTA":
if not sig_ref.name:
sig_ref.name = sig_ref.id
sig_ref.url = "https://wwtota.com/seznam/karta_rozhledny.php?ref=" + str(sig_ref.name)
elif sig.upper() == "TILES":
# Tiles on the Air just uses Maidenhead 6-digit squares, so ID, Name and Grid are all the same
if not sig_ref.name:
sig_ref.name = sig_ref.id
if not sig_ref.grid:
sig_ref.grid = sig_ref.id
if sig_ref.grid and not sig_ref.latitude:
ll = locator_to_latlong(str(sig_ref.grid))
sig_ref.latitude = ll[0]
sig_ref.longitude = ll[1]
elif sig.upper() == "WAB" or sig.upper() == "WAI":
ll = wab_wai_square_to_lat_lon(ref_id)
if ll:
sig_ref.name = ref_id
try:
sig_ref.grid = latlong_to_locator(ll[0], ll[1], 6)
sig_ref.latitude = ll[0]
sig_ref.longitude = ll[1]
except:
logging.warning("Invalid lat/lon received for WAB/WAI reference")
except ConnectionError:
logging.warning("Connection error when looking up sig_ref info for " + sig + " ref " + ref_id)
except (ConnectTimeout, ReadTimeout):
logging.warning(f"Timeout when looking up sig_ref info for " + sig + " ref " + ref_id)
except Exception: except Exception:
logging.error("Exception when looking up sig_ref info for " + sig + " ref " + ref_id, exc_info=True) logging.error("Exception when looking up sig_ref info for " + sig + " ref " + ref_id, exc_info=True)
return sig_ref return sig_ref
+2 -1
View File
@@ -77,7 +77,8 @@ class StatusReporter:
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(
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,
"reference_count": p.reference_count},
self._sig_ref_data_providers)) self._sig_ref_data_providers))
DATA_STORE.status_data["webserver"] = {"status": self._web_server.web_server_metrics["status"], DATA_STORE.status_data["webserver"] = {"status": self._web_server.web_server_metrics["status"],
"last_api_access": self._web_server.web_server_metrics[ "last_api_access": self._web_server.web_server_metrics[
+5
View File
@@ -1,6 +1,7 @@
import copy import copy
import hashlib import hashlib
import json import json
import logging
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime, timedelta
@@ -64,6 +65,7 @@ class Alert:
def infer_missing(self, credentials=None): def infer_missing(self, credentials=None):
"""Infer missing parameters where possible""" """Infer missing parameters where possible"""
try:
# If we somehow don't have a start time, set it to zero so it sorts off the bottom of any list but # If we somehow don't have a start time, set it to zero so it sorts off the bottom of any list but
# clients can still reliably parse it as a number. # clients can still reliably parse it as a number.
if not self.start_time: if not self.start_time:
@@ -128,6 +130,9 @@ class Alert:
self.dx_names = list( self.dx_names = list(
map(lambda c: lookup_helper.infer_name_from_callsign_online_lookup(c, credentials), self.dx_calls)) map(lambda c: lookup_helper.infer_name_from_callsign_online_lookup(c, credentials), self.dx_calls))
except Exception as e:
logging.error("Exception while inferring missing data from spot", e, exc_info=True)
def to_json(self): def to_json(self):
"""JSON serialise""" """JSON serialise"""
+4
View File
@@ -147,6 +147,7 @@ class Spot:
def infer_missing(self, credentials=None): def infer_missing(self, credentials=None):
"""Infer missing parameters where possible""" """Infer missing parameters where possible"""
try:
# If we somehow don't have a spot time, set it to zero so it sorts off the bottom of any list but # If we somehow don't have a spot time, set it to zero so it sorts off the bottom of any list but
# clients can still reliably parse it as a number. # clients can still reliably parse it as a number.
if not self.time: if not self.time:
@@ -437,6 +438,9 @@ class Spot:
self.de_longitude = latlon[1] self.de_longitude = latlon[1]
self.de_grid = lookup_helper.infer_grid_from_callsign_dxcc(self.de_call) self.de_grid = lookup_helper.infer_grid_from_callsign_dxcc(self.de_call)
except Exception as e:
logging.error("Exception while inferring missing data from spot", e, exc_info=True)
def to_json(self): def to_json(self):
"""JSON serialise""" """JSON serialise"""
+2 -1
View File
@@ -72,7 +72,8 @@ class WebServer:
{"sse_spot_broadcaster": self._spot_broadcaster, **handler_opts}), {"sse_spot_broadcaster": self._spot_broadcaster, **handler_opts}),
(r"/api/v1/alerts/stream", APIAlertsStreamHandler, (r"/api/v1/alerts/stream", APIAlertsStreamHandler,
{"sse_alert_broadcaster": self._alert_broadcaster, **handler_opts}), {"sse_alert_broadcaster": self._alert_broadcaster, **handler_opts}),
(r"/api/v1/solar", APISolarConditionsHandler, {"solar_conditions": self._data_store.solar, **handler_opts}), (r"/api/v1/solar", APISolarConditionsHandler, {"solar_conditions": self._data_store.solar_conditions,
**handler_opts}),
(r"/api/v1/dxstats", APIDxStatsHandler, {"spots": self._data_store.spots, **handler_opts}), (r"/api/v1/dxstats", APIDxStatsHandler, {"spots": self._data_store.spots, **handler_opts}),
(r"/api/v1/options", APIOptionsHandler, {"status_data": self._data_store.status_data, **handler_opts}), (r"/api/v1/options", APIOptionsHandler, {"status_data": self._data_store.status_data, **handler_opts}),
(r"/api/v1/status", APIStatusHandler, {"status_data": self._data_store.status_data, **handler_opts}), (r"/api/v1/status", APIStatusHandler, {"status_data": self._data_store.status_data, **handler_opts}),
+30
View File
@@ -0,0 +1,30 @@
import csv
from data.sig_ref import SIGRef
from sigrefdataproviders.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
class ARLHS(FileDownloadSIGRefDataProvider):
"""SIG ref data provider for Amateur Radio Light House Society"""
POLL_INTERVAL_DAYS = 30
SIG = "ARLHS"
DATA_URL = "https://www.gma.rocks/download/lighthouse.csv"
def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response):
new_data = {}
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()[1:]):
if "ARLHS" in row:
ref_id = row["ARLHS"]
new_data[ref_id] = SIGRef(sig=self.SIG, id=ref_id, name=row["Name"] if "Name" in row else None,
url="https://www.cqgma.org/zinfo.php?ref=" + ref_id,
latitude=float(row["Latitude"]) if "Latitude" in row and row[
"Latitude"] != "" else None,
longitude=float(row["Longitude"]) if "Longitude" in row and row[
"Longitude"] != "" else None,
grid=row["Maidenhead Locator"])
return new_data
@@ -3,11 +3,11 @@ from datetime import datetime
from threading import Thread, Event from threading import Thread, Event
import pytz import pytz
import requests
from requests import ReadTimeout from requests import ReadTimeout
from requests.exceptions import ConnectionError, ConnectTimeout from requests.exceptions import ConnectionError, ConnectTimeout
from core.constants import HTTP_HEADERS from core.constants import HTTP_HEADERS
from core.url_data_cache import URL_DATA_CACHE
from sigrefdataproviders.sig_ref_data_provider import SIGRefDataProvider from sigrefdataproviders.sig_ref_data_provider import SIGRefDataProvider
@@ -15,6 +15,7 @@ class FileDownloadSIGRefDataProvider(SIGRefDataProvider):
"""Generic SIG ref data provider class for providers that fetch their data from the web by downloading a file.""" """Generic SIG ref data provider class for providers that fetch their data from the web by downloading a file."""
def __init__(self, sig_name, provider_config, url, poll_interval): def __init__(self, sig_name, provider_config, url, poll_interval):
""" Set up the provider, note poll_interval is in *days*."""
super().__init__(sig_name, provider_config) super().__init__(sig_name, provider_config)
self._url = url self._url = url
self._poll_interval = poll_interval self._poll_interval = poll_interval
@@ -25,7 +26,7 @@ class FileDownloadSIGRefDataProvider(SIGRefDataProvider):
# 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
# subsequent polls, so start() returns immediately and the application can continue starting. # subsequent polls, so start() returns immediately and the application can continue starting.
logging.info( logging.info(
"Set up query of " + self.sig_name + " SIG ref data every " + str(self._poll_interval) + " seconds.") "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, daemon=True)
self._thread.start() self._thread.start()
@@ -35,14 +36,15 @@ class FileDownloadSIGRefDataProvider(SIGRefDataProvider):
def _run(self): def _run(self):
while True: while True:
self._poll() self._poll()
if self._stop_event.wait(timeout=self._poll_interval): if self._stop_event.wait(timeout=self._poll_interval * 60 * 60 * 24):
break break
def _poll(self): def _poll(self):
try: try:
# Request data from API # 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.sig_name + " SIG ref data...") logging.debug("Downloading " + self.sig_name + " SIG ref data...")
http_response = requests.get(self._url, headers=HTTP_HEADERS, timeout=(5, 30)) http_response = URL_DATA_CACHE.get(self._url, headers=HTTP_HEADERS)
# Check response code was good # Check response code was good
if http_response.ok: if http_response.ok:
# Pass off to the subclass for processing # Pass off to the subclass for processing
+29
View File
@@ -0,0 +1,29 @@
import csv
from data.sig_ref import SIGRef
from sigrefdataproviders.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
class GMA(FileDownloadSIGRefDataProvider):
"""SIG ref data provider for Global Mountain Activity"""
POLL_INTERVAL_DAYS = 30
SIG = "GMA"
DATA_URL = "https://www.gma.rocks/download/summits.csv"
def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response):
new_data = {}
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()[1:]):
ref_id = row["Reference"]
new_data[ref_id] = SIGRef(sig=self.SIG, id=ref_id, name=row["Name"] if "Name" in row else None,
url="https://www.cqgma.org/zinfo.php?ref=" + ref_id,
latitude=float(row["Latitude"]) if "Latitude" in row and row[
"Latitude"] != "" else None,
longitude=float(row["Longitude"]) if "Longitude" in row and row[
"Longitude"] != "" else None,
grid=row["Maidenhead Locator"])
return new_data
+30
View File
@@ -0,0 +1,30 @@
import csv
from data.sig_ref import SIGRef
from sigrefdataproviders.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
class ILLW(FileDownloadSIGRefDataProvider):
"""SIG ref data provider for International Lighthouse & Lightship Weekend"""
POLL_INTERVAL_DAYS = 30
SIG = "ILLW"
DATA_URL = "https://www.gma.rocks/download/lighthouse.csv"
def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response):
new_data = {}
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()[1:]):
if "ILLW" in row:
ref_id = row["ILLW"]
new_data[ref_id] = SIGRef(sig=self.SIG, id=ref_id, name=row["Name"] if "Name" in row else None,
url="https://www.cqgma.org/zinfo.php?ref=" + ref_id,
latitude=float(row["Latitude"]) if "Latitude" in row and row[
"Latitude"] != "" else None,
longitude=float(row["Longitude"]) if "Longitude" in row and row[
"Longitude"] != "" else None,
grid=row["Maidenhead Locator"])
return new_data
+36
View File
@@ -0,0 +1,36 @@
import logging
from pyhamtools.locator import latlong_to_locator
from data.sig_ref import SIGRef
from sigrefdataproviders.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
class IOTA(FileDownloadSIGRefDataProvider):
"""SIG ref data provider for Islands on the Air"""
POLL_INTERVAL_DAYS = 365
SIG = "IOTA"
DATA_URL = "https://www.iota-world.org/islands-on-the-air/downloads/download-file.html?path=groups.json"
def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response):
new_data = {}
data = http_response.json()
if isinstance(data, list):
for ref in data:
ref_id = ref["refno"]
latitude = float(ref["latitude_min"]) + float(ref["latitude_max"]) / 2.0
longitude = float(ref["longitude_min"]) + float(ref["longitude_max"]) / 2.0
grid = None
try:
grid = latlong_to_locator(latitude, longitude, 6)
except ValueError:
logging.debug(f"Error converting lat/lon to locator for an IOTA reference %f %f", latitude, longitude)
new_data[ref_id] = SIGRef(sig=self.SIG, id=ref_id, name=ref["name"],
grid=grid, latitude=latitude, longitude=longitude)
return new_data
+2 -2
View File
@@ -7,12 +7,12 @@ from sigrefdataproviders.file_download_sig_ref_data_provider import FileDownload
class LLOTA(FileDownloadSIGRefDataProvider): class LLOTA(FileDownloadSIGRefDataProvider):
"""SIG ref data provider for Lagos y Lagunas on the Air""" """SIG ref data provider for Lagos y Lagunas on the Air"""
POLL_INTERVAL_SEC = 7 * 24 * 60 * 60 # 7 days POLL_INTERVAL_DAYS = 7
SIG = "LLOTA" SIG = "LLOTA"
DATA_URL = "https://llota.app/api/public/references" DATA_URL = "https://llota.app/api/public/references"
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_SEC) super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response):
new_data = {} new_data = {}
@@ -14,7 +14,7 @@ class LocalFileSIGRefDataProvider(SIGRefDataProvider):
self._path = path self._path = path
def start(self): def start(self):
logging.info("Loading " + self.sig_name + " SIG ref data from file.") logging.debug("Loading " + self.sig_name + " SIG ref data from file.")
try: try:
new_data = self._file_to_data(self._path) new_data = self._file_to_data(self._path)
if new_data: if new_data:
+29
View File
@@ -0,0 +1,29 @@
import csv
from data.sig_ref import SIGRef
from sigrefdataproviders.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
class MOTA(FileDownloadSIGRefDataProvider):
"""SIG ref data provider for Mills on the Air"""
POLL_INTERVAL_DAYS = 30
SIG = "MOTA"
DATA_URL = "https://www.gma.rocks/download/mills.csv"
def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response):
new_data = {}
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()[1:]):
ref_id = row["Reference"]
new_data[ref_id] = SIGRef(sig=self.SIG, id=ref_id, name=row["Name"] if "Name" in row else None,
url="https://www.cqgma.org/zinfo.php?ref=" + ref_id,
latitude=float(row["Latitude"]) if "Latitude" in row and row[
"Latitude"] != "" else None,
longitude=float(row["Longitude"]) if "Longitude" in row and row[
"Longitude"] != "" else None,
grid=row["Maidenhead Locator"])
return new_data
+29
View File
@@ -0,0 +1,29 @@
import csv
from data.sig_ref import SIGRef
from sigrefdataproviders.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
class POTA(FileDownloadSIGRefDataProvider):
"""SIG ref data provider for Parks on the Air"""
POLL_INTERVAL_DAYS = 7
SIG = "POTA"
DATA_URL = "https://pota.app/all_parks_ext.csv"
def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response):
new_data = {}
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()):
ref_id = row["reference"]
new_data[ref_id] = SIGRef(sig=self.SIG, id=ref_id, name=row["name"] if "name" in row else None,
url="https://pota.app/#/park/" + ref_id,
grid=row["grid"] if "grid" in row else None,
latitude=float(row["latitude"]) if "latitude" in row and row[
"latitude"] != "" else None,
longitude=float(row["longitude"]) if "longitude" in row and row[
"longitude"] != "" else None)
return new_data
@@ -1,3 +1,4 @@
import logging
from datetime import datetime from datetime import datetime
import pytz import pytz
@@ -16,6 +17,7 @@ class SIGRefDataProvider:
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.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
# Create an empty dict to store data if one doesn't already exist # Create an empty dict to store data if one doesn't already exist
if not sig_name in DATA_STORE.sigrefs: if not sig_name in DATA_STORE.sigrefs:
@@ -38,3 +40,5 @@ class SIGRefDataProvider:
objects.""" objects."""
DATA_STORE.sigrefs[self.sig_name] = new_data DATA_STORE.sigrefs[self.sig_name] = new_data
self.reference_count = len(new_data)
logging.info(f"Loaded %d references for %s into the data store.", self.reference_count, self.sig_name)
+3 -3
View File
@@ -7,16 +7,16 @@ from sigrefdataproviders.file_download_sig_ref_data_provider import FileDownload
class SIOTA(FileDownloadSIGRefDataProvider): class SIOTA(FileDownloadSIGRefDataProvider):
"""SIG ref data provider for Silos on the Air""" """SIG ref data provider for Silos on the Air"""
POLL_INTERVAL_SEC = 30 * 24 * 60 * 60 # 30 days POLL_INTERVAL_DAYS = 30
SIG = "SIOTA" SIG = "SIOTA"
DATA_URL = "https://www.silosontheair.com/data/silos.csv" DATA_URL = "https://www.silosontheair.com/data/silos.csv"
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_SEC) super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response):
new_data = {} new_data = {}
for row in csv.DictReader(http_response.content.decode().splitlines()): for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()):
ref_id = row["SILO_CODE"] ref_id = row["SILO_CODE"]
new_data[ref_id] = SIGRef(sig=self.SIG, id=ref_id, name=row["NAME"] if "NAME" in row else None, new_data[ref_id] = SIGRef(sig=self.SIG, id=ref_id, name=row["NAME"] if "NAME" in row else None,
grid=row["LOCATOR"] if "LOCATOR" in row else None, grid=row["LOCATOR"] if "LOCATOR" in row else None,
+33
View File
@@ -0,0 +1,33 @@
import csv
from pyhamtools.locator import latlong_to_locator
from data.sig_ref import SIGRef
from sigrefdataproviders.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
class SOTA(FileDownloadSIGRefDataProvider):
"""SIG ref data provider for Summits on the Air"""
POLL_INTERVAL_DAYS = 30
SIG = "SOTA"
DATA_URL = "https://storage.sota.org.uk/summitslist.csv"
def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response):
new_data = {}
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()[1:]):
ref_id = row["SummitCode"]
latitude = float(row["Latitude"]) if "Latitude" in row and row["Latitude"] != "" else None
longitude = float(row["Longitude"]) if "Longitude" in row and row["Longitude"] != "" else None
new_data[ref_id] = SIGRef(sig=self.SIG, id=ref_id, name=row["SummitName"] if "SummitName" in row else None,
url="https://www.sotadata.org.uk/en/summit/" + ref_id,
latitude=latitude,
longitude=longitude,
activation_score=int(row["Points"]) if "Points" in row else None)
if latitude and longitude:
new_data[ref_id].grid = latlong_to_locator(latitude, longitude, 6)
return new_data
@@ -4,11 +4,11 @@ from data.sig_ref import SIGRef
from sigrefdataproviders.local_file_sig_ref_data_provider import LocalFileSIGRefDataProvider from sigrefdataproviders.local_file_sig_ref_data_provider import LocalFileSIGRefDataProvider
class TOTA(LocalFileSIGRefDataProvider): class Toilets(LocalFileSIGRefDataProvider):
"""SIG ref data provider for Toilets on the Air""" """SIG ref data provider for Toilets on the Air"""
SIG = "TOTA" SIG = "Toilets"
PATH = "datafiles/tota.csv" PATH = "datafiles/toilets.csv"
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.PATH) super().__init__(self.SIG, provider_config, self.PATH)
+27
View File
@@ -0,0 +1,27 @@
import csv
from data.sig_ref import SIGRef
from sigrefdataproviders.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
class Towers(FileDownloadSIGRefDataProvider):
"""SIG ref data provider for Towers on the Air"""
POLL_INTERVAL_DAYS = 30
SIG = "Towers"
DATA_URL = "https://wwtota.com/servis/generate_csv.php?ref=&filter=all"
def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response):
new_data = {}
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines(), delimiter=";"):
ref_id = row["Ref"]
new_data[ref_id] = SIGRef(sig=self.SIG, id=ref_id, name=row["Nazev"] if "Nazev" in row else None,
url="https://wwtota.com/seznam/karta_rozhledny.php?ref=" + ref_id,
grid=row["Lokator"] if "Lokator" in row and row["Lokator"] != "" else None,
latitude=float(row["Lat"]) if "Lat" in row and row["Lat"] != "" else None,
longitude=float(row["Lon"]) if "Lon" in row and row["Lon"] != "" else None)
return new_data
+44
View File
@@ -0,0 +1,44 @@
import csv
import logging
from pyhamtools.locator import latlong_to_locator
from data.sig_ref import SIGRef
from sigrefdataproviders.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
class WCA(FileDownloadSIGRefDataProvider):
"""SIG ref data provider for World Castles Award"""
POLL_INTERVAL_DAYS = 30
SIG = "WCA"
DATA_URL = "https://polo.ham2k.com/data/activities/wca/all-castles.csv"
def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response):
new_data = {}
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()):
ref_id = row["REF"]
coordsStr = row["COORDINATES"]
latitude = None
longitude = None
grid = None
try:
if coordsStr:
split = coordsStr.split(", ")
latitude = float(split[0])
longitude = float(split[1])
grid = latlong_to_locator(latitude, longitude)
except ValueError:
logging.debug(f"Encountered dodgy formatting in WCA CSV, skipping location data for %s", ref_id)
new_data[ref_id] = SIGRef(sig=self.SIG, id=ref_id, name=row["CLEAN NAME"] if "CLEAN NAME" in row else None,
url="https://www.cqgma.org/zinfo.php?ref=" + ref_id,
latitude=latitude,
longitude=longitude,
grid=grid)
return new_data
+2 -2
View File
@@ -5,12 +5,12 @@ from sigrefdataproviders.file_download_sig_ref_data_provider import FileDownload
class WOTA(FileDownloadSIGRefDataProvider): class WOTA(FileDownloadSIGRefDataProvider):
"""SIG ref data provider for Wainwrights on the Air""" """SIG ref data provider for Wainwrights on the Air"""
POLL_INTERVAL_SEC = 365 * 24 * 60 * 60 # 365 days POLL_INTERVAL_DAYS = 365
SIG = "WOTA" SIG = "WOTA"
DATA_URL = "https://www.wota.org.uk/mapping/data/summits.json" DATA_URL = "https://www.wota.org.uk/mapping/data/summits.json"
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_SEC) super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response):
new_data = {} new_data = {}
+27
View File
@@ -0,0 +1,27 @@
import csv
from data.sig_ref import SIGRef
from sigrefdataproviders.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
class WWBOTA(FileDownloadSIGRefDataProvider):
"""SIG ref data provider for Worldwide Bunkers on the Air"""
POLL_INTERVAL_DAYS = 30
SIG = "WWBOTA"
DATA_URL = "https://api.wwbota.org/bunkers/?format=CSV"
def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response):
new_data = {}
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()):
ref_id = row["Reference"]
new_data[ref_id] = SIGRef(sig=self.SIG, id=ref_id, name=row["Name"] if "Name" in row else None,
url="https://bunkerwiki.org/?s=" + ref_id if ref_id.startswith("B/G") else None,
grid=row["Locator"] if "Locator" in row and row["Locator"] != "" else None,
latitude=float(row["Lat"]) if "Lat" in row and row["Lat"] != "" else None,
longitude=float(row["Long"]) if "Long" in row and row["Long"] != "" else None)
return new_data
+3 -3
View File
@@ -7,16 +7,16 @@ from sigrefdataproviders.file_download_sig_ref_data_provider import FileDownload
class WWFF(FileDownloadSIGRefDataProvider): class WWFF(FileDownloadSIGRefDataProvider):
"""SIG ref data provider for Worldwide Flora & Fauna""" """SIG ref data provider for Worldwide Flora & Fauna"""
POLL_INTERVAL_SEC = 30 * 24 * 60 * 60 # 30 days POLL_INTERVAL_DAYS = 30
SIG = "WWFF" SIG = "WWFF"
DATA_URL = "https://wwff.co/wwff-data/wwff_directory.csv" DATA_URL = "https://wwff.co/wwff-data/wwff_directory.csv"
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_SEC) super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response):
new_data = {} new_data = {}
for row in csv.DictReader(http_response.content.decode().splitlines()): for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()):
ref_id = row["reference"] ref_id = row["reference"]
new_data[ref_id] = SIGRef(sig=self.SIG, id=ref_id, name=row["name"] if "name" in row else None, new_data[ref_id] = SIGRef(sig=self.SIG, id=ref_id, name=row["name"] if "name" in row else None,
url="https://wwff.co/directory/?showRef=" + ref_id, url="https://wwff.co/directory/?showRef=" + ref_id,
+2 -2
View File
@@ -7,12 +7,12 @@ from sigrefdataproviders.file_download_sig_ref_data_provider import FileDownload
class ZLOTA(FileDownloadSIGRefDataProvider): class ZLOTA(FileDownloadSIGRefDataProvider):
"""SIG ref data provider for New Zealand on the Air""" """SIG ref data provider for New Zealand on the Air"""
POLL_INTERVAL_SEC = 30 * 24 * 60 * 60 # 30 days POLL_INTERVAL_DAYS = 30
SIG = "ZLOTA" SIG = "ZLOTA"
DATA_URL = "https://ontheair.nz/assets/assets.json" DATA_URL = "https://ontheair.nz/assets/assets.json"
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_SEC) super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response):
new_data = {} new_data = {}
+2 -2
View File
@@ -30,8 +30,8 @@ class Towers(HTTPSpotProvider):
dx_call=source_spot["call"].upper(), dx_call=source_spot["call"].upper(),
freq=likely_freq, freq=likely_freq,
comment=source_spot["comment"], comment=source_spot["comment"],
sig="WWTOTA", sig="Towers",
sig_refs=[SIGRef(id=source_spot["ref"], sig="WWTOTA")], sig_refs=[SIGRef(id=source_spot["ref"], sig="Towers")],
time=datetime.strptime(response_json["updated"][:10] + source_spot["time"], time=datetime.strptime(response_json["updated"][:10] + source_spot["time"],
"%Y-%m-%d%H:%M").timestamp()) "%Y-%m-%d%H:%M").timestamp())
+1 -1
View File
@@ -25,7 +25,7 @@ class WOTA(HTTPSpotProvider):
def _http_response_to_spots(self, http_response): def _http_response_to_spots(self, http_response):
new_spots = [] new_spots = []
rss = cast(RSS, Parser.parse(http_response.content.decode())) rss = cast(RSS, Parser.parse(http_response.content.decode("utf-8-sig")))
# Iterate through source data # Iterate through source data
for source_spot in rss.channel.items: for source_spot in rss.channel.items:
+1 -1
View File
@@ -11,7 +11,7 @@ from spotproviders.websocket_spot_provider import WebsocketSpotProvider
class XOTA(WebsocketSpotProvider): class XOTA(WebsocketSpotProvider):
"""Spot provider for servers based on the "xOTA" software at https://github.com/nischu/xOTA/ """Spot provider for servers based on the "xOTA" software at https://github.com/nischu/xOTA/
The provider typically doesn't give us a lat/lon or SIG explicitly, so our own config provides a SIG which we can The provider typically doesn't give us a lat/lon or SIG explicitly, so our own config provides a SIG which we can
then use for lookups. This functionality is implemented for TOTA events, of which there are then use for lookups. This functionality is implemented for Toilets on the Air events, of which there are
several - so a plain lookup of a "TOTA reference" doesn't make sense, it depends on which TOTA, which is why we also several - so a plain lookup of a "TOTA reference" doesn't make sense, it depends on which TOTA, which is why we also
provide a sig_ref_prefix in our config. This is applied to the reference ID, so e.g. "T-01" at C3 might become provide a sig_ref_prefix in our config. This is applied to the reference ID, so e.g. "T-01" at C3 might become
"C3 T-01". This allows us to provide location lookups for TOTA at several conferences.""" "C3 T-01". This allows us to provide location lookups for TOTA at several conferences."""
+5 -1
View File
@@ -796,7 +796,7 @@ components:
- HEMA - HEMA
- WCA - WCA
- MOTA - MOTA
- SiOTA - SIOTA
- ARLHS - ARLHS
- ILLW - ILLW
- ZLOTA - ZLOTA
@@ -1742,6 +1742,10 @@ components:
The last time at which this provider received data, UTC seconds since UNIX epoch. If this The last time at which this provider received data, UTC seconds since UNIX epoch. If this
is zero, the provider has never updated. is zero, the provider has never updated.
example: 1759579508 example: 1759579508
reference_count:
type: number
description: The number of references fetched using this provider.
example: 1234
SpotList: SpotList:
type: array type: array
+1
View File
@@ -46,6 +46,7 @@ function loadStatus() {
<div class="col"><strong>${p["sig_name"]}</strong></div> <div class="col"><strong>${p["sig_name"]}</strong></div>
<div class="col">Status: ${p["status"]}</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">Last updated: ${(p["enabled"] && p["last_updated"] > 0) ? moment.unix(p["last_updated"]).utc().fromNow() : "N/A"}</div>
<div class="col">Reference count: ${(p["enabled"] && p["reference_count"] > 0) ? p["reference_count"] : "N/A"}</div>
</div>`); </div>`);
}); });
}); });