4 Commits
45 changed files with 1300 additions and 715 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:
+71 -8
View File
@@ -116,21 +116,30 @@ spot-providers:
- class: "XOTA" - class: "XOTA"
# xOTA sources have a "name" property so you can define what programme it is for, since xOTA is generic software # xOTA sources have a "name" property so you can define what programme it is for, since xOTA is generic software
# for running "something-on-the-air" programmes. # for running "something-on-the-air" programmes.
name: "39C3 TOTA" name: "C3 TOTA"
enabled: false enabled: false
url: "wss://39c3.totawatch.de/api/spot/live" url: "wss://39c3.totawatch.de/api/spot/live"
# Fixed SIG for all spots from a provider & location CSV are currently only a feature for the "XOTA" provider, # For the "XOTA" provider, a SIG must be set menually here because xOTA is a generic backend for xOTA
# the software found at https://github.com/nischu/xOTA/. This is because this is a generic backend for xOTA # programmes and so different URLs potentially provide different programmes.
# programmes and so different URLs provide different programmes. sig: "Toilets"
sig: "TOTA" # For Toilets on the Air, we prefix the SIG references (T-01 etc) with some characters that define the conference:
locations-csv: "datafiles/39c3-tota.csv" # 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!)
sig-ref-prefix: "C3"
- class: "XOTA" - class: "XOTA"
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"
locations-csv: "datafiles/eh23-tota.csv" sig-ref-prefix: "EH"
- class: "XOTA"
name: "HOPE26 TOTA"
enabled: false
url: "wss://hope-26.totawatch.de/api/spot/live"
sig: "Toilets"
sig-ref-prefix: "HOPE"
# Alert providers to use. Same setup as the spot providers list above. # Alert providers to use. Same setup as the spot providers list above.
@@ -157,6 +166,60 @@ alert-providers:
enabled: true enabled: true
# 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.
sig-ref-data-providers:
- class: "POTA"
enabled: true
- class: "SOTA"
enabled: true
- class: "WWFF"
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"
enabled: true
- class: "SIOTA"
enabled: true
- class: "ZLOTA"
enabled: true
- class: "LLOTA"
enabled: true
- class: "Towers"
enabled: true
- class: "DME"
enabled: true
- class: "Toilets"
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
# conditions, etc.) and make it available via the /api/v1/solar endpoint. # conditions, etc.) and make it available via the /api/v1/solar endpoint.
solar-condition-providers: solar-condition-providers:
+33
View File
@@ -1,3 +1,4 @@
import importlib
import logging import logging
import os import os
@@ -33,3 +34,35 @@ WEB_UI_OPTIONS["spot-providers-enabled-by-default"] = [p["name"] for p in config
# one of our proviers. We set that to also be enabled by default. # one of our proviers. We set that to also be enabled by default.
if ALLOW_SPOTTING: if ALLOW_SPOTTING:
WEB_UI_OPTIONS["spot-providers-enabled-by-default"].append("API") WEB_UI_OPTIONS["spot-providers-enabled-by-default"].append("API")
def get_spot_provider_from_config(config_providers_entry):
"""Utility method to get a spot provider based on the class specified in its config entry."""
module = importlib.import_module('spotproviders.' + config_providers_entry["class"].lower())
provider_class = getattr(module, config_providers_entry["class"])
return provider_class(config_providers_entry)
def get_alert_provider_from_config(config_providers_entry):
"""Utility method to get an alert provider based on the class specified in its config entry."""
module = importlib.import_module('alertproviders.' + config_providers_entry["class"].lower())
provider_class = getattr(module, config_providers_entry["class"])
return provider_class(config_providers_entry)
def get_solar_conditions_provider_from_config(config_providers_entry):
"""Utility method to get a solar conditions provider based on the class specified in its config entry."""
module = importlib.import_module('solarconditionsproviders.' + 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):
"""Utility method to get a SIG reference data provider based on the class specified in its config entry."""
module = importlib.import_module('sigrefdataproviders.' + config_providers_entry["class"].lower())
provider_class = getattr(module, config_providers_entry["class"])
return provider_class(config_providers_entry)
+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}"),
+49 -38
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,57 +14,66 @@ class DataStore:
lookup data using different caching strategies for each.""" lookup data using different caching strategies for each."""
def __init__(self): def __init__(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
Path(cache_dir).mkdir(parents=True, exist_ok=True) def setup(self):
Path(self._CACHE_DIR).mkdir(parents=True, exist_ok=True)
# Special caches for spots and alerts, which have TTL and write snapshots to disk at an interval
self.spots = LiveDataCache(maxsize=self.MAX_SPOT_COUNT, ttl=MAX_SPOT_AGE,
snapshot_dir=cache_dir + "/spots",
snapshot_interval_sec=self.SPOT_ALERT_SNAPSHOT_INTERVAL_SEC)
self.alerts = LiveDataCache(maxsize=self.MAX_ALERT_COUNT, ttl=MAX_ALERT_AGE,
snapshot_dir=cache_dir + "/alerts",
snapshot_interval_sec=self.SPOT_ALERT_SNAPSHOT_INTERVAL_SEC)
# 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(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(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 caches for SIG ref data. Separate provider threads will repopulate these 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. # 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,
self.sigrefs_wwff = diskcache.Cache(cache_dir + "/sigrefs/wwff") # and trying to do two layers of dict in diskcache absolutely destroys performance with unpickling huge dicts,
self.sigrefs_siota = diskcache.Cache(cache_dir + "/sigrefs/siota") # so we have an ugly "SIG:ref" syntax for keys to keep it a single level.
self.sigrefs_wota = diskcache.Cache(cache_dir + "/sigrefs/wota") self.sigrefs = diskcache.Cache(self._CACHE_DIR + "/sigrefs")
self.sigrefs_zlota = diskcache.Cache(cache_dir + "/sigrefs/zlota") logging.info(f"Loaded data for %d SIG references.", len(self.sigrefs))
self.sigrefs_llota = diskcache.Cache(cache_dir + "/sigrefs/llota")
self.sigrefs_dme = diskcache.Cache(cache_dir + "/sigrefs/dme")
# 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(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
# 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.
self.spots = LiveDataCache(maxsize=self._MAX_SPOT_COUNT, ttl=MAX_SPOT_AGE,
snapshot_dir=self._CACHE_DIR + "/spots",
snapshot_interval_sec=self._SPOT_ALERT_SNAPSHOT_INTERVAL_SEC)
logging.info(f"Loaded %d spots from a previous run.", len(self.spots.keys()))
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_wwff.close() self.sigrefs.close()
self.sigrefs_siota.close()
self.sigrefs_wota.close()
self.sigrefs_zlota.close()
self.sigrefs_llota.close()
self.callsigns.close() self.callsigns.close()
# Global object # Global object
+39 -234
View File
@@ -1,19 +1,11 @@
import csv
import logging import logging
from pyhamtools.locator import latlong_to_locator, locator_to_latlong from pyhamtools.locator import latlong_to_locator, locator_to_latlong
from requests.exceptions import ConnectionError, ReadTimeout, ConnectTimeout
from core.constants import SIGS, HTTP_HEADERS from core.constants import SIGS
from core.data_store import DATA_STORE from core.data_store import DATA_STORE
from core.geo_utils import wab_wai_square_to_lat_lon from core.geo_utils import wab_wai_square_to_lat_lon
from core.url_data_cache import URL_DATA_CACHE
# Load Spanish municipality data for the DME programme. There's no convenient lookup API for this, so we embed the data
# file in Spothole and load it on startup.
with open("datafiles/MUNICIPIOS.csv", encoding="latin-1") as _f:
for row in csv.DictReader(_f, delimiter=";"):
DATA_STORE.sigrefs_dme.add(row["COD_INE"][:5], row)
def get_ref_regex_for_sig(sig): def get_ref_regex_for_sig(sig):
"""Utility function to get the regex string for a SIG reference for a named SIG. If no match is found, None will be returned.""" """Utility function to get the regex string for a SIG reference for a named SIG. If no match is found, None will be returned."""
@@ -35,217 +27,31 @@ def get_sig_name_from_comment_name(sig):
def populate_sig_ref_info(sig_ref): def populate_sig_ref_info(sig_ref):
"""Look up details of a SIG reference (e.g. POTA park) such as name, lat/lon, and grid. Takes in a sig_ref object which """Look up details of a SIG reference (e.g. POTA park) such as name, lat/lon, and grid. Takes in a sig_ref object
must at minimum have a "sig" and an "id". The rest of the object will be populated and returned. which must at minimum have a "sig" and an "id". The rest of the object will be populated and returned. 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.
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: if sig_ref.sig is None or sig_ref.sig == "" or sig_ref.id is None or sig_ref.id == "":
logging.warning("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
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":
response = URL_DATA_CACHE.get("https://api-db2.sota.org.uk/api/summits/" + ref_id,
headers=HTTP_HEADERS)
if response.ok:
data = response.json()
if data:
sig_ref.name = data["name"] if "name" in data else None
sig_ref.url = "https://www.sotadata.org.uk/en/summit/" + ref_id
sig_ref.grid = data["locator"] if "locator" in data else None
sig_ref.latitude = data["latitude"] if "latitude" in data else None
sig_ref.longitude = data["longitude"] if "longitude" in data else None
sig_ref.activation_score = data["points"] if "points" in data else None
elif not response.from_cache:
logging.warning("Malformed response looking up %s ref %s", sig, ref_id)
elif not response.from_cache:
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
elif sig.upper() == "WWBOTA":
response = URL_DATA_CACHE.get("https://api.wwbota.org/bunkers/" + ref_id,
headers=HTTP_HEADERS)
if response.ok:
data = response.json()
if data:
sig_ref.name = data["name"] if "name" in data else None
sig_ref.url = "https://bunkerwiki.org/?s=" + ref_id if ref_id.startswith("B/G") else None
sig_ref.grid = data["locator"] if "locator" in data else None
sig_ref.latitude = data["lat"] if "lat" in data else None
sig_ref.longitude = data["long"] if "long" in data else None
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":
response = URL_DATA_CACHE.get("https://www.cqgma.org/api/ref/?" + ref_id,
headers=HTTP_HEADERS)
if response.ok:
data = response.json()
if data:
sig_ref.name = data["name"] if "name" in data else None
sig_ref.url = "https://www.cqgma.org/zinfo.php?ref=" + ref_id
sig_ref.grid = data["locator"] if "locator" in data else None
# For some things (just IOTA?) the GMA actually returns a box where "latitude" and "longitude" are
# the zeroest corner of the box, then "lat2" and "lng2" provide the other corner. We detect this
# and provide a single lat/lon for the centre. Otherwise if we don't have these extra parameters,
# just use the single point we have.
if data.get("latitude") is not None and data.get("longitude") is not None and data.get(
"lat2") is not None and data.get("lng2") is not None:
sig_ref.latitude = (float(data["latitude"]) + float(data["lat2"])) / 2.0
sig_ref.longitude = (float(data["longitude"]) + float(data["lng2"])) / 2.0
else:
sig_ref.latitude = float(data["latitude"]) if data.get("latitude") is not None else None
sig_ref.longitude = float(data["longitude"]) if data.get("longitude") is not None else None
elif not response.from_cache:
logging.warning("Malformed response looking up %s ref %s via GMA", sig, ref_id)
elif not response.from_cache:
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
elif sig.upper() == "WWFF":
response = URL_DATA_CACHE.get("https://wwff.co/wwff-data/wwff_directory.csv",
headers=HTTP_HEADERS)
if response.ok:
if not bool(DATA_STORE.sigrefs_wwff) or not response.from_cache:
# New data from WWFF, update our internal map
for row in csv.DictReader(response.content.decode().splitlines()):
DATA_STORE.sigrefs_wwff.add(row["reference"], row)
row = DATA_STORE.sigrefs_wwff.get(ref_id)
if row:
sig_ref.name = row["name"] if "name" in row else None
sig_ref.url = "https://wwff.co/directory/?showRef=" + ref_id
sig_ref.grid = row["iaruLocator"] if "iaruLocator" in row and row["iaruLocator"] != "-" else None
sig_ref.latitude = float(row["latitude"]) if "latitude" in row and row["latitude"] != "-" else None
sig_ref.longitude = float(row["longitude"]) if "longitude" in row and row[
"longitude"] != "-" else None
elif not response.from_cache:
logging.warning("WWFF database did not contain data for ref %s", ref_id)
elif not response.from_cache:
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
elif sig.upper() == "SIOTA":
response = URL_DATA_CACHE.get("https://www.silosontheair.com/data/silos.csv",
headers=HTTP_HEADERS)
if response.ok:
if not bool(DATA_STORE.sigrefs_siota) or not response.from_cache:
# New data from SIOTA, update our internal map
for row in csv.DictReader(response.content.decode().splitlines()):
DATA_STORE.sigrefs_siota.add(row["SILO_CODE"], row)
row = DATA_STORE.sigrefs_siota.get(ref_id)
if row:
sig_ref.name = row["NAME"] if "NAME" in row else None
sig_ref.grid = row["LOCATOR"] if "LOCATOR" in row else None
sig_ref.latitude = float(row["LAT"]) if "LAT" in row else None
sig_ref.longitude = float(row["LNG"]) if "LNG" in row else None
elif not response.from_cache:
logging.warning("SIOTA database did not contain data for ref %s", ref_id)
elif not response.from_cache:
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
elif sig.upper() == "WOTA":
response = URL_DATA_CACHE.get("https://www.wota.org.uk/mapping/data/summits.json",
headers=HTTP_HEADERS)
if response.ok:
data = response.json()
if data:
if not bool(DATA_STORE.sigrefs_wota) or not response.from_cache:
# New data from WOTA, update our internal map
for feature in data.get("features", []):
DATA_STORE.sigrefs_wota.add(feature["properties"]["wotaId"], feature)
feature = DATA_STORE.sigrefs_wota.get(ref_id)
if feature:
sig_ref.name = feature["properties"]["title"]
# Fudge WOTA URLs. Outlying fell (LDO) URLs don't match their ID numbers but require 214 to be
# added to them
sig_ref.url = "https://www.wota.org.uk/MM_" + ref_id
if ref_id.upper().startswith("LDO-"):
number = int(ref_id.upper().replace("LDO-", ""))
sig_ref.url = "https://www.wota.org.uk/MM_LDO-" + str(number + 214)
sig_ref.grid = feature["properties"]["qthLocator"]
sig_ref.latitude = feature["geometry"]["coordinates"][1]
sig_ref.longitude = feature["geometry"]["coordinates"][0]
elif not response.from_cache:
logging.warning("Malformed response looking up %s ref %s", sig, ref_id)
elif not response.from_cache:
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
elif sig.upper() == "ZLOTA":
response = URL_DATA_CACHE.get("https://ontheair.nz/assets/assets.json", headers=HTTP_HEADERS)
if response.ok:
data = response.json()
if isinstance(data, list):
if not bool(DATA_STORE.sigrefs_zlota) or not response.from_cache:
# New data from ZLOTA, update our internal map
for ref in data:
DATA_STORE.sigrefs_zlota.add(ref["code"], ref)
ref = DATA_STORE.sigrefs_zlota.get(ref_id)
if ref:
sig_ref.name = ref["name"]
sig_ref.url = "https://ontheair.nz/assets/" + ref_id.replace("/", "_")
try:
sig_ref.grid = latlong_to_locator(ref["y"], ref["x"], 6)
except:
logging.debug("Invalid lat/lon received for reference")
sig_ref.latitude = ref["y"]
sig_ref.longitude = ref["x"]
elif not response.from_cache:
logging.warning("Malformed response looking up %s ref %s", sig, ref_id)
elif not response.from_cache:
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
elif sig.upper() == "BOTA":
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":
response = URL_DATA_CACHE.get("https://llota.app/api/public/references",
headers=HTTP_HEADERS)
if response.ok:
data = response.json()
if isinstance(data, list):
if not bool(DATA_STORE.sigrefs_llota) or not response.from_cache:
# New data from LLOTA, update our internal map
for ref in data:
DATA_STORE.sigrefs_llota.add(ref["reference_code"], ref)
ref = DATA_STORE.sigrefs_llota.get(ref_id)
if ref:
sig_ref.name = str(ref["name"])
sig_ref.url = "https://llota.app/list/ref/" + ref_id
sig_ref.grid = str(ref["grid_locator"])
ll = locator_to_latlong(sig_ref.grid)
sig_ref.latitude = ll[0]
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() == "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)
# If the SIG is Tiles, WAB, WAI or BOTA (Beaches), we don't have anything to look up from the data store, we can
# calculate all the information we are going to get directly. So handle those cases first
elif sig.upper() == "TILES": elif sig.upper() == "TILES":
# Tiles on the Air just uses Maidenhead 6-digit squares, so ID, Name and Grid are all the same # Tiles on the Air just uses Maidenhead 6-digit squares, so ID, Name and Grid are all the same
if not sig_ref.name: if not sig_ref.name:
@@ -268,27 +74,26 @@ def populate_sig_ref_info(sig_ref):
except: except:
logging.warning("Invalid lat/lon received for WAB/WAI reference") logging.warning("Invalid lat/lon received for WAB/WAI reference")
elif sig.upper() == "DME": elif sig.upper() == "BOTA":
# Zero-pad to 5 digits to match our source data # For BOTA all we can ever generate is the URL, there is no data file or lookup for lat/longs
row = DATA_STORE.sigrefs_dme.get(ref_id.zfill(5)) if not sig_ref.name:
if row: sig_ref.name = sig_ref.id
sig_ref.name = row["NOMBRE_ACTUAL"] + ", " + row["PROVINCIA"] sig_ref.url = "https://www.beachesontheair.com/beaches/" + sig_ref.name.lower().replace(" ", "-")
sig_ref.latitude = float(row["LATITUD_ETRS89_REGCAN95"].replace(",", ".")) if row.get(
"LATITUD_ETRS89_REGCAN95") else None # OK, this is something we have to look up. Now check to see if our data store contains SIG ref information for
sig_ref.longitude = float(row["LONGITUD_ETRS89_REGCAN95"].replace(",", ".")) if row.get( # this SIG. If so, check for the reference data and use that.
"LONGITUD_ETRS89_REGCAN95") else None elif sig in DATA_STORE.sigrefs:
if sig_ref.latitude and sig_ref.longitude: key = sig + ":" + ref_id
try: lookup_data = DATA_STORE.sigrefs.get(key) if key in DATA_STORE.sigrefs else None
sig_ref.grid = latlong_to_locator(sig_ref.latitude, sig_ref.longitude, 6) if lookup_data:
except Exception: # Copy new sig ref data into existing object
logging.warning("Invalid lat/lon received for DME reference") sig_ref.__dict__.update(lookup_data.__dict__)
else: else:
logging.warning("DME database did not contain data for ref %s", ref_id) logging.warning("%s database did not contain data for ref %s", sig, ref_id)
else:
logging.warning(f"Tried to look up a SIG called %s but Spothole does not know what that is.", sig)
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
+9 -1
View File
@@ -14,7 +14,8 @@ from core.prometheus_metrics_handler import memory_use_gauge, spots_gauge, alert
class StatusReporter: 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):
"""Constructor""" """Constructor"""
self._run_interval = run_interval self._run_interval = run_interval
@@ -22,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._sig_ref_data_providers = sig_ref_data_providers
self._thread = None self._thread = None
self._stop_event = Event() self._stop_event = Event()
self._startup_time = datetime.now(pytz.UTC) self._startup_time = datetime.now(pytz.UTC)
@@ -72,6 +74,12 @@ 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["sig_ref_data_providers"] = list(
map(lambda p: {"sig_name": p.sig_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,
"reference_count": p.reference_count},
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[
"last_api_access_time"].replace( "last_api_access_time"].replace(
+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"""
-18
View File
@@ -1,18 +0,0 @@
ref,lat,lon
T-01,53.56278090617755,9.984341869295505
T-02,53.562383404176416,9.98551893027115
T-03,53.56170184391514,9.985416035619778
T-04,53.562026534393176,9.986372919078974
T-11,53.56284641242506,9.98475590239655
T-12,53.562431705517035,9.98551675702443
T-13,53.56223704898424,9.985774520335664
T-14,53.5617893512591,9.986344302837976
T-21,53.56284641242506,9.98475590239655
T-22,53.56245816412497,9.985456089490567
T-23,53.56199560857136,9.985636761412673
T-24,53.5617893512591,9.986344302837976
T-31,53.56247470064887,9.985611427551902
T-32,53.5617893512591,9.986344302837976
T-41,53.56245039134992,9.985486136112701
T-91,53.56147934973529,9.984626806439744
T-92,53.561396810300735,9.987553052152899
1 ref lat lon
2 T-01 53.56278090617755 9.984341869295505
3 T-02 53.562383404176416 9.98551893027115
4 T-03 53.56170184391514 9.985416035619778
5 T-04 53.562026534393176 9.986372919078974
6 T-11 53.56284641242506 9.98475590239655
7 T-12 53.562431705517035 9.98551675702443
8 T-13 53.56223704898424 9.985774520335664
9 T-14 53.5617893512591 9.986344302837976
10 T-21 53.56284641242506 9.98475590239655
11 T-22 53.56245816412497 9.985456089490567
12 T-23 53.56199560857136 9.985636761412673
13 T-24 53.5617893512591 9.986344302837976
14 T-31 53.56247470064887 9.985611427551902
15 T-32 53.5617893512591 9.986344302837976
16 T-41 53.56245039134992 9.985486136112701
17 T-91 53.56147934973529 9.984626806439744
18 T-92 53.561396810300735 9.987553052152899
-13
View File
@@ -1,13 +0,0 @@
ref,lat,lon
T-01,50.3636495,7.5584857
T-02,50.3636495,7.5584857
T-03,50.3636495,7.5584857
T-11,50.3636495,7.5584857
T-13,50.3636495,7.5584857
T-14,50.3636495,7.5584857
T-21,50.3636495,7.5584857
T-31,50.3636495,7.5584857
T-33,50.3636495,7.5584857
T-34,50.3636495,7.5584857
T-41,50.3636495,7.5584857
T-51,50.3636495,7.5584857
1 ref lat lon
2 T-01 50.3636495 7.5584857
3 T-02 50.3636495 7.5584857
4 T-03 50.3636495 7.5584857
5 T-11 50.3636495 7.5584857
6 T-13 50.3636495 7.5584857
7 T-14 50.3636495 7.5584857
8 T-21 50.3636495 7.5584857
9 T-31 50.3636495 7.5584857
10 T-33 50.3636495 7.5584857
11 T-34 50.3636495 7.5584857
12 T-41 50.3636495 7.5584857
13 T-51 50.3636495 7.5584857
+30
View File
@@ -0,0 +1,30 @@
ref,lat,lon
C3 T-01,53.56278090617755,9.984341869295505
C3 T-02,53.562383404176416,9.98551893027115
C3 T-03,53.56170184391514,9.985416035619778
C3 T-04,53.562026534393176,9.986372919078974
C3 T-11,53.56284641242506,9.98475590239655
C3 T-12,53.562431705517035,9.98551675702443
C3 T-13,53.56223704898424,9.985774520335664
C3 T-14,53.5617893512591,9.986344302837976
C3 T-21,53.56284641242506,9.98475590239655
C3 T-22,53.56245816412497,9.985456089490567
C3 T-23,53.56199560857136,9.985636761412673
C3 T-24,53.5617893512591,9.986344302837976
C3 T-31,53.56247470064887,9.985611427551902
C3 T-32,53.5617893512591,9.986344302837976
C3 T-41,53.56245039134992,9.985486136112701
C3 T-91,53.56147934973529,9.984626806439744
C3 T-92,53.561396810300735,9.987553052152899
EH T-01,50.3636495,7.5584857
EH T-02,50.3636495,7.5584857
EH T-03,50.3636495,7.5584857
EH T-11,50.3636495,7.5584857
EH T-13,50.3636495,7.5584857
EH T-14,50.3636495,7.5584857
EH T-21,50.3636495,7.5584857
EH T-31,50.3636495,7.5584857
EH T-33,50.3636495,7.5584857
EH T-34,50.3636495,7.5584857
EH T-41,50.3636495,7.5584857
EH T-51,50.3636495,7.5584857
1 ref lat lon
2 C3 T-01 53.56278090617755 9.984341869295505
3 C3 T-02 53.562383404176416 9.98551893027115
4 C3 T-03 53.56170184391514 9.985416035619778
5 C3 T-04 53.562026534393176 9.986372919078974
6 C3 T-11 53.56284641242506 9.98475590239655
7 C3 T-12 53.562431705517035 9.98551675702443
8 C3 T-13 53.56223704898424 9.985774520335664
9 C3 T-14 53.5617893512591 9.986344302837976
10 C3 T-21 53.56284641242506 9.98475590239655
11 C3 T-22 53.56245816412497 9.985456089490567
12 C3 T-23 53.56199560857136 9.985636761412673
13 C3 T-24 53.5617893512591 9.986344302837976
14 C3 T-31 53.56247470064887 9.985611427551902
15 C3 T-32 53.5617893512591 9.986344302837976
16 C3 T-41 53.56245039134992 9.985486136112701
17 C3 T-91 53.56147934973529 9.984626806439744
18 C3 T-92 53.561396810300735 9.987553052152899
19 EH T-01 50.3636495 7.5584857
20 EH T-02 50.3636495 7.5584857
21 EH T-03 50.3636495 7.5584857
22 EH T-11 50.3636495 7.5584857
23 EH T-13 50.3636495 7.5584857
24 EH T-14 50.3636495 7.5584857
25 EH T-21 50.3636495 7.5584857
26 EH T-31 50.3636495 7.5584857
27 EH T-33 50.3636495 7.5584857
28 EH T-34 50.3636495 7.5584857
29 EH T-41 50.3636495 7.5584857
30 EH T-51 50.3636495 7.5584857
+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 and row["ARLHS"] != "":
ref_id = row["ARLHS"]
new_data.append(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
+35
View File
@@ -0,0 +1,35 @@
import csv
from pyhamtools.locator import latlong_to_locator
from data.sig_ref import SIGRef
from sigrefdataproviders.local_file_sig_ref_data_provider import LocalFileSIGRefDataProvider
class DME(LocalFileSIGRefDataProvider):
"""SIG ref data provider for Diploma Municipios de Espana"""
SIG = "DME"
PATH = "datafiles/MUNICIPIOS.csv"
def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.PATH)
def _file_to_data(self, path):
new_data = []
with open(path, encoding="latin-1") as _f:
for row in csv.DictReader(_f, delimiter=";"):
ref_id = row["COD_INE"][:5]
latitude = float(row["LATITUD_ETRS89_REGCAN95"].replace(",", ".")) if row.get(
"LATITUD_ETRS89_REGCAN95") else None
longitude = float(row["LONGITUD_ETRS89_REGCAN95"].replace(",", ".")) if row.get(
"LONGITUD_ETRS89_REGCAN95") else None
ref = SIGRef(sig=self.SIG, id=ref_id,
name=row["NOMBRE_ACTUAL"] + ", " + row["PROVINCIA"],
latitude=latitude,
longitude=longitude)
if latitude and longitude:
ref.grid = latlong_to_locator(latitude, longitude, 6)
new_data.append(ref)
return new_data
@@ -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 URL_DATA_CACHE
from sigrefdataproviders.sig_ref_data_provider import SIGRefDataProvider
class FileDownloadSIGRefDataProvider(SIGRefDataProvider):
"""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):
""" Set up the provider, note poll_interval is in *days*."""
super().__init__(sig_name, provider_config)
self._url = url
self._poll_interval = poll_interval
self._thread = None
self._stop_event = Event()
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.sig_name + " SIG ref 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.sig_name + " SIG ref data...")
http_response = 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
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.
if new_data:
self._add_data(new_data)
self.status = "OK"
self.last_update_time = datetime.now(pytz.UTC)
logging.debug("Received SIG ref data for " + self.sig_name)
else:
self.status = "Error"
logging.warning(f"HTTP {http_response.status_code} when downloading SIG ref data for {self.sig_name}.")
except ConnectionError:
logging.warning(f"Connection error when downloading SIG ref data for {self.sig_name}.")
except (ConnectTimeout, ReadTimeout):
logging.warning(f"Timeout when downloading SIG ref data for {self.sig_name}.")
except Exception:
self.status = "Error"
logging.exception("Exception in HTTP SIG Ref Data Provider (" + self.sig_name + ")")
self._stop_event.wait(timeout=1)
def _http_response_to_data(self, http_response):
"""Convert an HTTP response returned by the server into SIG Ref data. The whole response is provided here so the
subclass implementations can check for HTTP status codes if necessary, and handle the response as JSON, CSV,
whatever the remote file actually is."""
raise NotImplementedError("Subclasses must implement this method")
+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.append(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 and row["ILLW"] != "":
ref_id = row["ILLW"]
new_data.append(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.append(SIGRef(sig=self.SIG, id=ref_id, name=ref["name"],
grid=grid, latitude=latitude, longitude=longitude))
return new_data
+32
View File
@@ -0,0 +1,32 @@
from pyhamtools.locator import locator_to_latlong
from data.sig_ref import SIGRef
from sigrefdataproviders.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
class LLOTA(FileDownloadSIGRefDataProvider):
"""SIG ref data provider for Lagos y Lagunas on the Air"""
POLL_INTERVAL_DAYS = 7
SIG = "LLOTA"
DATA_URL = "https://llota.app/api/public/references"
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["reference_code"]
grid = str(ref["grid_locator"])
ll = locator_to_latlong(grid)
new_data.append(SIGRef(sig=self.SIG, id=ref_id, name=str(ref["name"]),
url="https://llota.app/list/ref/" + ref_id,
grid=grid,
latitude=ll[0],
longitude=ll[1]))
return new_data
@@ -0,0 +1,36 @@
import logging
from datetime import datetime
import pytz
from sigrefdataproviders.sig_ref_data_provider import SIGRefDataProvider
class LocalFileSIGRefDataProvider(SIGRefDataProvider):
"""Generic SIG ref data provider class for providers that fetch their data from a local file on startup."""
def __init__(self, sig, provider_config, path):
super().__init__(sig, provider_config)
self._path = path
def start(self):
logging.debug("Loading " + self.sig_name + " SIG ref data from file.")
try:
new_data = self._file_to_data(self._path)
if new_data:
self._add_data(new_data)
self.status = "OK"
self.last_update_time = datetime.now(pytz.UTC)
else:
logging.info("No new SIG ref data found for " + self.sig_name)
except Exception as e:
self.status = "Error"
logging.exception("Exception in local file SIG Ref Data Provider (" + self.sig_name + ")")
def stop(self):
pass
def _file_to_data(self, path):
"""Load a file on the given path and turn it into SIG Ref data."""
raise NotImplementedError("Subclasses must implement this method")
+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.append(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.append(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
+14 -2
View File
@@ -1,19 +1,23 @@
import logging
from datetime import datetime from datetime import datetime
import pytz import pytz
from core.data_store import DATA_STORE
class SIGRefDataProvider: class SIGRefDataProvider:
"""Generic SIG reference data provider class. Subclasses of this query the individual URLs or files for data.""" """Generic SIG reference data provider class. Subclasses of this query the individual URLs or files for data."""
def __init__(self, name, provider_config): def __init__(self, sig_name, provider_config):
"""Constructor""" """Constructor"""
self.name = 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.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
def start(self): def start(self):
@@ -26,3 +30,11 @@ class SIGRefDataProvider:
"""Stop any threads and prepare for application shutdown""" """Stop any threads and prepare for application shutdown"""
raise NotImplementedError("Subclasses must implement this method") raise NotImplementedError("Subclasses must implement this method")
def _add_data(self, new_data):
"""Add all the provided reference data objects to the data store."""
for d in new_data:
DATA_STORE.sigrefs[self.sig_name + ":" + d.id] = d
self.reference_count = len(new_data)
logging.info(f"Loaded %d references for %s into the data store.", self.reference_count, self.sig_name)
+26
View File
@@ -0,0 +1,26 @@
import csv
from data.sig_ref import SIGRef
from sigrefdataproviders.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
class SIOTA(FileDownloadSIGRefDataProvider):
"""SIG ref data provider for Silos on the Air"""
POLL_INTERVAL_DAYS = 30
SIG = "SIOTA"
DATA_URL = "https://www.silosontheair.com/data/silos.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["SILO_CODE"]
new_data.append(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,
latitude=float(row["LAT"]) if "LAT" in row else None,
longitude=float(row["LNG"]) if "LNG" in row else None))
return new_data
+34
View File
@@ -0,0 +1,34 @@
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
ref = 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:
ref.grid = latlong_to_locator(latitude, longitude, 6)
new_data.append(ref)
return new_data
+24
View File
@@ -0,0 +1,24 @@
import csv
from data.sig_ref import SIGRef
from sigrefdataproviders.local_file_sig_ref_data_provider import LocalFileSIGRefDataProvider
class Toilets(LocalFileSIGRefDataProvider):
"""SIG ref data provider for Toilets on the Air"""
SIG = "Toilets"
PATH = "datafiles/toilets.csv"
def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.PATH)
def _file_to_data(self, path):
new_data = []
f = open(path)
csv_data = f.read()
dr = csv.DictReader(csv_data.splitlines())
for row in dr:
new_data.append(SIGRef(sig=self.SIG, id=row["ref"], name=row["ref"], latitude=float(row["lat"]),
longitude=float(row["lon"])))
return 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 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.append(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.append(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
+31
View File
@@ -0,0 +1,31 @@
from data.sig_ref import SIGRef
from sigrefdataproviders.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
class WOTA(FileDownloadSIGRefDataProvider):
"""SIG ref data provider for Wainwrights on the Air"""
POLL_INTERVAL_DAYS = 365
SIG = "WOTA"
DATA_URL = "https://www.wota.org.uk/mapping/data/summits.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 = []
for feature in http_response.json().get("features", []):
ref_id = feature["properties"]["wotaId"]
# Fudge WOTA URLs. Outlying fell (LDO) URLs don't match their ID numbers but require 214 to be
# added to them
url = "https://www.wota.org.uk/MM_" + ref_id
if ref_id.upper().startswith("LDO-"):
number = int(ref_id.upper().replace("LDO-", ""))
url = "https://www.wota.org.uk/MM_LDO-" + str(number + 214)
new_data.append(SIGRef(sig=self.SIG, id=ref_id, name=feature["properties"]["title"], url=url,
grid=feature["properties"]["qthLocator"],
latitude=feature["geometry"]["coordinates"][1],
longitude=feature["geometry"]["coordinates"][0]))
return 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.append(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
+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 WWFF(FileDownloadSIGRefDataProvider):
"""SIG ref data provider for Worldwide Flora & Fauna"""
POLL_INTERVAL_DAYS = 30
SIG = "WWFF"
DATA_URL = "https://wwff.co/wwff-data/wwff_directory.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.append(SIGRef(sig=self.SIG, id=ref_id, name=row["name"] if "name" in row else None,
url="https://wwff.co/directory/?showRef=" + ref_id,
grid=row["iaruLocator"] if "iaruLocator" in row and row[
"iaruLocator"] != "-" else None,
latitude=float(row["latitude"]) if "latitude" in row and row[
"latitude"] != "" and row["latitude"] != "-" else None,
longitude=float(row["longitude"]) if "longitude" in row and row[
"longitude"] != "" and row["longitude"] != "-" else None))
return new_data
+34
View File
@@ -0,0 +1,34 @@
from pyhamtools.locator import latlong_to_locator
from data.sig_ref import SIGRef
from sigrefdataproviders.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
class ZLOTA(FileDownloadSIGRefDataProvider):
"""SIG ref data provider for New Zealand on the Air"""
POLL_INTERVAL_DAYS = 30
SIG = "ZLOTA"
DATA_URL = "https://ontheair.nz/assets/assets.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["code"]
latitude = ref["y"]
longitude = ref["x"]
ref = SIGRef(sig=self.SIG, id=ref_id, name=ref["name"],
url="https://ontheair.nz/assets/" + ref_id.replace("/", "_"),
latitude=latitude,
longitude=longitude)
if latitude and longitude:
ref.grid = latlong_to_locator(latitude, longitude, 6)
new_data.append(ref)
return new_data
+17 -26
View File
@@ -1,11 +1,11 @@
# Main script # Main script
import importlib
import logging import logging
import os import os
import signal import signal
import sys import sys
from core.config import config, SERVER_OWNER_CALLSIGN, LOG_LEVEL 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
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 +17,7 @@ web_server = None
spot_providers = [] spot_providers = []
alert_providers = [] alert_providers = []
solar_condition_providers = [] solar_condition_providers = []
sig_ref_data_providers = []
cleanup_timer = None cleanup_timer = None
run = True run = True
@@ -38,34 +39,13 @@ def shutdown(_signum=None, _frame=None):
for scp in solar_condition_providers: for scp in solar_condition_providers:
if scp.enabled: if scp.enabled:
scp.stop() scp.stop()
for srdp in sig_ref_data_providers:
if srdp.enabled:
srdp.stop()
DATA_STORE.close() DATA_STORE.close()
os._exit(0) os._exit(0)
def get_spot_provider_from_config(config_providers_entry):
"""Utility method to get a spot provider based on the class specified in its config entry."""
module = importlib.import_module('spotproviders.' + config_providers_entry["class"].lower())
provider_class = getattr(module, config_providers_entry["class"])
return provider_class(config_providers_entry)
def get_alert_provider_from_config(config_providers_entry):
"""Utility method to get an alert provider based on the class specified in its config entry."""
module = importlib.import_module('alertproviders.' + config_providers_entry["class"].lower())
provider_class = getattr(module, config_providers_entry["class"])
return provider_class(config_providers_entry)
def get_solar_conditions_provider_from_config(config_providers_entry):
"""Utility method to get a solar conditions provider based on the class specified in its config entry."""
module = importlib.import_module('solarconditionsproviders.' + config_providers_entry["class"].lower())
provider_class = getattr(module, config_providers_entry["class"])
return provider_class(config_providers_entry)
# Main function # Main function
if __name__ == '__main__': if __name__ == '__main__':
# Set up logging # Set up logging
@@ -85,6 +65,9 @@ if __name__ == '__main__':
# Shut down gracefully on SIGINT # Shut down gracefully on SIGINT
signal.signal(signal.SIGINT, shutdown) signal.signal(signal.SIGINT, shutdown)
# Set up data store
DATA_STORE.setup()
# Set up lookup helper # Set up lookup helper
lookup_helper.start() lookup_helper.start()
@@ -112,9 +95,17 @@ if __name__ == '__main__':
if p.enabled: if p.enabled:
p.start() p.start()
# Fetch, set up and start SIG reference data providers
for entry in config.get("sig-ref-data-providers", []):
sig_ref_data_providers.append(get_sig_ref_data_provider_from_config(entry))
for p in sig_ref_data_providers:
if p.enabled:
p.start()
# 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,
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()
+1 -1
View File
@@ -64,7 +64,7 @@ class HTTPSpotProvider(SpotProvider):
logging.warning(f"Timeout when accessing {self.name} spots API.") logging.warning(f"Timeout when accessing {self.name} spots API.")
except Exception: except Exception:
self.status = "Error" self.status = "Error"
logging.exception("Exception in HTTP JSON Spot Provider (" + self.name + ")") logging.exception("Exception in HTTP Spot Provider (" + self.name + ")")
self._stop_event.wait(timeout=1) self._stop_event.wait(timeout=1)
def _http_response_to_spots(self, http_response): def _http_response_to_spots(self, http_response):
-2
View File
@@ -19,8 +19,6 @@ class SOTA(HTTPSpotProvider):
# The actual data lookup all happens after parsing and checking the epoch. # The actual data lookup all happens after parsing and checking the epoch.
EPOCH_URL = "https://api-db2.sota.org.uk/api/spots/epoch" EPOCH_URL = "https://api-db2.sota.org.uk/api/spots/epoch"
SPOTS_URL = "https://api-db2.sota.org.uk/api/spots/60/all/all" SPOTS_URL = "https://api-db2.sota.org.uk/api/spots/60/all/all"
# SOTA spots don't contain lat/lon, we need a separate lookup for that
SUMMIT_URL_ROOT = "https://api-db2.sota.org.uk/api/summits/"
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__("SOTA", provider_config, self.EPOCH_URL, self.POLL_INTERVAL_SEC) super().__init__("SOTA", provider_config, self.EPOCH_URL, self.POLL_INTERVAL_SEC)
+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:
+8 -25
View File
@@ -1,6 +1,4 @@
import csv
import json import json
import logging
from datetime import datetime from datetime import datetime
import pytz import pytz
@@ -12,10 +10,11 @@ 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 and a reference The provider typically doesn't give us a lat/lon or SIG explicitly, so our own config provides a SIG which we can
to a local CSV file with location information. 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 and hence which server several - so a plain lookup of a "TOTA reference" doesn't make sense, it depends on which TOTA, which is why we also
supplied the data, which is why the CSV location lookup is here and not in sig_utils.""" 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."""
LOCATION_DATA = {} LOCATION_DATA = {}
SIG = None SIG = None
@@ -23,26 +22,13 @@ class XOTA(WebsocketSpotProvider):
def __init__(self, provider_config): def __init__(self, provider_config):
name = provider_config["name"] if "name" in provider_config else "xOTA" name = provider_config["name"] if "name" in provider_config else "xOTA"
super().__init__(name, provider_config, provider_config["url"]) super().__init__(name, provider_config, provider_config["url"])
locations_csv = str(provider_config["locations-csv"]) if "locations-csv" in provider_config else None
self.SIG = str(provider_config["sig"]) if "sig" in provider_config else None self.SIG = str(provider_config["sig"]) if "sig" in provider_config else None
self._sig_ref_prefix = str(provider_config["sig-ref-prefix"]) if "sig-ref-prefix" in provider_config else ""
# Load location data
if locations_csv:
try:
f = open(locations_csv)
csv_data = f.read()
dr = csv.DictReader(csv_data.splitlines())
for row in dr:
self.LOCATION_DATA[row["ref"]] = {"lat": row["lat"], "lon": row["lon"]}
except:
logging.exception("Could not look up location data for XOTA source.")
def _ws_message_to_spot(self, b): def _ws_message_to_spot(self, b):
string = b.decode("utf-8") string = b.decode("utf-8")
source_spot = json.loads(string) source_spot = json.loads(string)
ref_id = source_spot["reference"]["title"] ref_id = self._sig_ref_prefix + " " + source_spot["reference"]["title"]
lat = float(self.LOCATION_DATA[ref_id]["lat"]) if ref_id in self.LOCATION_DATA else None
lon = float(self.LOCATION_DATA[ref_id]["lon"]) if ref_id in self.LOCATION_DATA else None
spot = Spot(source=self.name, spot = Spot(source=self.name,
source_id=source_spot["id"], source_id=source_spot["id"],
dx_call=source_spot["stationCallSign"].upper(), dx_call=source_spot["stationCallSign"].upper(),
@@ -50,10 +36,7 @@ class XOTA(WebsocketSpotProvider):
mode=source_spot["mode"].upper(), mode=source_spot["mode"].upper(),
sig=self.SIG, sig=self.SIG,
sig_refs=[ sig_refs=[
SIGRef(id=ref_id, sig=self.SIG or "", url=source_spot["reference"]["website"], latitude=lat, SIGRef(id=ref_id, sig=self.SIG or "", url=source_spot["reference"]["website"])],
longitude=lon)],
time=datetime.now(pytz.UTC).timestamp(), time=datetime.now(pytz.UTC).timestamp(),
dx_latitude=lat,
dx_longitude=lon,
qrt=source_spot["state"] != "active") qrt=source_spot["state"] != "active")
return spot return spot
+33 -12
View File
@@ -23,6 +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`
### 1.3 ### 1.3
@@ -795,7 +796,7 @@ components:
- HEMA - HEMA
- WCA - WCA
- MOTA - MOTA
- SiOTA - SIOTA
- ARLHS - ARLHS
- ILLW - ILLW
- ZLOTA - ZLOTA
@@ -1720,6 +1721,32 @@ components:
is zero, the provider has never updated. is zero, the provider has never updated.
example: 1759579508 example: 1759579508
SIGRefDataProviderStatus:
type: object
properties:
sig_name:
type: string
description: The name of the SIG.
example: WWFF
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
reference_count:
type: number
description: The number of references fetched using this provider.
example: 1234
SpotList: SpotList:
type: array type: array
items: items:
@@ -1787,17 +1814,6 @@ components:
type: integer type: integer
description: Number of alerts currently in the system. description: Number of alerts currently in the system.
example: 123 example: 123
"cleanup":
type: object
properties:
status:
type: string
description: The status of the cleanup thread
example: OK
last_ran:
type: number
description: The last time the cleanup operation ran, UTC seconds since UNIX epoch.
example: 1759579508
"webserver": "webserver":
type: object type: object
properties: properties:
@@ -1828,6 +1844,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'
sig_ref_data_providers:
type: array
description: An array of all the SIG reference data providers.
items:
$ref: '#/components/schemas/SIGRefDataProviderStatus'
Options: Options:
type: object type: object
+10 -3
View File
@@ -12,9 +12,6 @@ function loadStatus() {
$("#web-server-last-api").text(moment.unix(jsonData["webserver"]["last_api_access"]).utc().fromNow()); $("#web-server-last-api").text(moment.unix(jsonData["webserver"]["last_api_access"]).utc().fromNow());
$("#web-server-last-page").text(moment.unix(jsonData["webserver"]["last_page_access"]).utc().fromNow()); $("#web-server-last-page").text(moment.unix(jsonData["webserver"]["last_page_access"]).utc().fromNow());
$("#cleanup-status").text(jsonData["cleanup"]["status"]);
$("#cleanup-last-ran").text(moment.unix(jsonData["cleanup"]["last_ran"]).utc().fromNow());
jsonData["spot_providers"].forEach(p => { jsonData["spot_providers"].forEach(p => {
$("#spot-providers-status-container").append(` $("#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-2">
@@ -42,6 +39,16 @@ function loadStatus() {
<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>`); </div>`);
}); });
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="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 class="col">References: ${(p["enabled"] && p["reference_count"] > 0) ? p["reference_count"] : "N/A"}</div>
</div>`);
});
}); });
} }
+9 -5
View File
@@ -24,11 +24,6 @@
<div class="col">Last API call: <span id="web-server-last-api"></span></div> <div class="col">Last API call: <span id="web-server-last-api"></span></div>
<div class="col">Last page req: <span id="web-server-last-page"></span></div> <div class="col">Last page req: <span id="web-server-last-page"></span></div>
</div> </div>
<div class="row row-cols-1 row-cols-md-4 g-4 mb-2">
<div class="col"><strong>Cleanup Service</strong></div>
<div class="col">Status: <span id="cleanup-status"></span></div>
<div class="col">Last ran: <span id="cleanup-last-ran"></span></div>
</div>
</div> </div>
</div> </div>
@@ -59,6 +54,15 @@
</div> </div>
</div> </div>
<div class="card mt-3">
<div class="card-header">
SIG Reference Data Providers
</div>
<div class="card-body" id="sig-ref-data-providers-status-container">
</div>
</div>
<script src="/static/js/status.js?v=1785434213"></script> <script src="/static/js/status.js?v=1785434213"></script>
<script> <script>
$(document).ready(function () { $(document).ready(function () {